diff options
author | Daniel Mueller <deso@posteo.net> | 2019-01-06 16:59:11 -0800 |
---|---|---|
committer | Daniel Mueller <deso@posteo.net> | 2019-01-06 16:59:11 -0800 |
commit | e78d0432b8db215cf76cb410de354287fc2da8ba (patch) | |
tree | 1c8e160868d7749b44f66e317848170947b28249 | |
parent | 6bb629b4d1035c3fd851244060f99da78a7bd929 (diff) | |
download | nitrocli-e78d0432b8db215cf76cb410de354287fc2da8ba.tar.gz nitrocli-e78d0432b8db215cf76cb410de354287fc2da8ba.tar.bz2 |
Introduce support for user-provided extensions
This change introduces support for discovering and executing
user-provided extensions to the program. Extensions are useful for
allowing users to provide additional functionality on top of the
nitrocli proper. Implementation wise we stick to an approach similar to
git or cargo subcommands in nature: we find executables in the PATH
environment variable whose file name starts with "nitrocli-" and allow
for them to be invoked as a nitrocli command.
-rw-r--r-- | nitrocli/CHANGELOG.md | 2 | ||||
-rw-r--r-- | nitrocli/Cargo.lock | 104 | ||||
-rw-r--r-- | nitrocli/Cargo.toml | 3 | ||||
-rw-r--r-- | nitrocli/doc/nitrocli.1 | 30 | ||||
-rw-r--r-- | nitrocli/doc/nitrocli.1.pdf | bin | 18442 -> 20399 bytes | |||
-rw-r--r-- | nitrocli/src/args.rs | 204 | ||||
-rw-r--r-- | nitrocli/src/error.rs | 8 | ||||
-rw-r--r-- | nitrocli/src/main.rs | 8 | ||||
-rw-r--r-- | nitrocli/src/tests/extension_model_test.py | 52 | ||||
-rw-r--r-- | nitrocli/src/tests/mod.rs | 12 | ||||
-rw-r--r-- | nitrocli/src/tests/run.rs | 112 |
11 files changed, 530 insertions, 5 deletions
diff --git a/nitrocli/CHANGELOG.md b/nitrocli/CHANGELOG.md index e042cbc..dd95c6d 100644 --- a/nitrocli/CHANGELOG.md +++ b/nitrocli/CHANGELOG.md @@ -1,5 +1,7 @@ Unreleased ---------- +- Added support for user provided extensions through lookup via the + `PATH` environment variable - Bumped required Rust version to `1.32` diff --git a/nitrocli/Cargo.lock b/nitrocli/Cargo.lock index 45f8090..9ae9299 100644 --- a/nitrocli/Cargo.lock +++ b/nitrocli/Cargo.lock @@ -22,6 +22,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] +name = "c2-chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "lazy_static 1.2.0", + "ppv-lite86 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] name = "cc" version = "1.0.40" @@ -53,6 +62,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] +name = "getrandom" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.62", + "wasi 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] name = "lazy_static" version = "1.2.0" @@ -80,6 +99,7 @@ dependencies = [ "nitrokey-test 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "nitrokey-test-state 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -116,6 +136,11 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] +name = "ppv-lite86" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] name = "proc-macro2" version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -132,10 +157,47 @@ dependencies = [ ] [[package]] +name = "rand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "getrandom 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.62", + "rand_chacha 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_hc 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_chacha" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "c2-chacha 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "rand_core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] name = "rand_core" version = "0.3.0" [[package]] +name = "rand_core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "getrandom 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand_core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] name = "rand_os" version = "0.1.1" dependencies = [ @@ -156,6 +218,11 @@ dependencies = [ ] [[package]] +name = "redox_syscall" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] name = "regex" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -176,6 +243,14 @@ dependencies = [ ] [[package]] +name = "remove_dir_all" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] name = "syn" version = "0.15.26" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -186,6 +261,19 @@ dependencies = [ ] [[package]] +name = "tempfile" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.62", + "rand 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)", + "remove_dir_all 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] name = "thread_local" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -209,6 +297,11 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] +name = "wasi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] name = "winapi" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -230,23 +323,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [metadata] "checksum aho-corasick 0.6.9 (registry+https://github.com/rust-lang/crates.io-index)" = "1e9a933f4e58658d7b12defcf96dc5c720f20832deebe3e0a19efd3b6aaeeb9e" "checksum bitflags 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "228047a76f468627ca71776ecdebd732a3423081fcf5125585bcd7c49886ce12" +"checksum c2-chacha 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7d64d04786e0f528460fc884753cf8dddcc466be308f6026f8e355c41a0e4101" "checksum cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "082bb9b28e00d3c9d39cc03e64ce4cea0f1bb9b3fde493f0cbc008472d22bdf4" "checksum cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" "checksum fuchsia-zircon 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" "checksum fuchsia-zircon-sys 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" +"checksum getrandom 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "fc344b02d3868feb131e8b5fe2b9b0a1cc42942679af493061fc13b853243872" "checksum memchr 2.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "e1dd4eaac298c32ce07eb6ed9242eda7d82955b9170b7d6db59b2e02cc63fcb8" "checksum nitrokey-test 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7e7e81b55db51769209e88a63cdbb4f2dc7ee9cd20ccaf32fbb940a3b0c50259" "checksum nitrokey-test-state 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a59b732ed6d5212424ed31ec9649f05652bcbc38f45f2292b27a6044e7098803" +"checksum ppv-lite86 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)" = "e3cbf9f658cdb5000fcf6f362b8ea2ba154b9f146a61c7a20d647034c6b6561b" "checksum proc-macro2 0.4.26 (registry+https://github.com/rust-lang/crates.io-index)" = "38fddd23d98b2144d197c0eca5705632d4fe2667d14a6be5df8934f8d74f1978" "checksum quote 0.6.11 (registry+https://github.com/rust-lang/crates.io-index)" = "cdd8e04bd9c52e0342b406469d494fcb033be4bdbe5c606016defbb1681411e1" +"checksum rand 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d47eab0e83d9693d40f825f86948aa16eff6750ead4bdffc4ab95b8b3a7f052c" +"checksum rand_chacha 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "03a2a90da8c7523f554344f921aa97283eadf6ac484a6d2a7d0212fa7f8d6853" +"checksum rand_core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "615e683324e75af5d43d8f7a39ffe3ee4a9dc42c5c701167a71dc59c3a493aca" +"checksum rand_hc 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" "checksum rdrand 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +"checksum redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)" = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" "checksum regex 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "37e7cbbd370869ce2e8dff25c7018702d10b21a20ef7135316f8daecd6c25b7f" "checksum regex-syntax 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)" = "8c2f35eedad5295fdf00a63d7d4b238135723f92b434ec06774dad15c7ab0861" +"checksum remove_dir_all 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "4a83fa3702a688b9359eccba92d153ac33fd2e8462f9e0e3fdf155239ea7792e" "checksum syn 0.15.26 (registry+https://github.com/rust-lang/crates.io-index)" = "f92e629aa1d9c827b2bb8297046c1ccffc57c99b947a680d3ccff1f136a3bee9" +"checksum tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" "checksum thread_local 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "c6b53e329000edc2b34dbe8545fd20e55a333362d0a321909685a19bd28c3f1b" "checksum ucd-util 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "535c204ee4d8434478593480b8f86ab45ec9aae0e83c568ca81abf0fd0e88f86" "checksum unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" "checksum utf8-ranges 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "796f7e48bef87609f7ade7e06495a87d5cd06c7866e6a5cbfceffc558a243737" +"checksum wasi 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "fd5442abcac6525a045cc8c795aedb60da7a2e5e89c7bf18a0d5357849bb23c7" "checksum winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "92c1eb33641e276cfa214a0522acad57be5c56b10cb348b3c5117db75f3ac4b0" "checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" "checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/nitrocli/Cargo.toml b/nitrocli/Cargo.toml index add2f77..f48e7ca 100644 --- a/nitrocli/Cargo.toml +++ b/nitrocli/Cargo.toml @@ -64,6 +64,9 @@ version = "0.1" [dev-dependencies.regex] version = "1" +[dev-dependencies.tempfile] +version = "3" + [patch.crates-io] argparse = { path = "../argparse" } base32 = { path = "../base32" } diff --git a/nitrocli/doc/nitrocli.1 b/nitrocli/doc/nitrocli.1 index 75f5960..4301bea 100644 --- a/nitrocli/doc/nitrocli.1 +++ b/nitrocli/doc/nitrocli.1 @@ -16,8 +16,8 @@ and the password safe. .TP \fB\-m\fR, \fB\-\-model pro\fR|\fBstorage\fR Restrict connections to the given device model. -If this option is not set, nitrocli will connect to any connected Nitrokey Pro -or Nitrokey Storage device. +If this option is not set, \fBnitrocli\fR will connect to any connected Nitrokey +Pro or Nitrokey Storage device. .TP \fB\-v\fR, \fB\-\-verbose\fR Enable additional logging and control its verbosity. Logging enabled through @@ -264,6 +264,32 @@ The admin PIN cannot be unblocked. This operation is equivalent to the unblock PIN option provided by \fBgpg\fR(1) (using the \fB\-\-change\-pin\fR option). +.SS Extensions +In addition to the above built-in commands, \fBnitrocli\fR supports +user-provided functionality in the form of extensions. An extension can be any +executable file whose filename starts with "nitrocli-" and that is discoverable +through lookup via the \fBPATH\fR environment variable. + +An extension should honor the following set of options, which are supplied to +the extension by \fBnitrocli\fR itself: +.TP +\fB\-\-nitrocli\fR \fIpath\fR +The path to the \fBnitrocli\fR binary. This path can be used to recursively +invoke \fBnitrocli\fR to implement certain functionality. This option is +guaranteed to be supplied. +.TP +\fB\-\-model pro\fR|\fBstorage\fR +Restrict connections to the given device model (see the Options section for more +details). This option is supplied only if it was provided by the user to the +invocation of \fBnitrocli\fR itself. +.TP +\fB\-\-verbosity\fR \fIlevel\fR +Control the logging verbosity by setting the log level to \fIlevel\fR. The +default level is 0, which corresponds to an invocation of \fBnitrocli\fR +without additional logging related options. Each additional occurrence of +\fB\-v\fR/\fB\-\-verbose\fR increments the log level accordingly. This option is +guaranteed to be supplied. + .SH ENVIRONMENT The program honors a set of environment variables that can be used to suppress interactive PIN entry through \fBpinentry\fR(1). The following diff --git a/nitrocli/doc/nitrocli.1.pdf b/nitrocli/doc/nitrocli.1.pdf Binary files differindex cd15a66..4cb75da 100644 --- a/nitrocli/doc/nitrocli.1.pdf +++ b/nitrocli/doc/nitrocli.1.pdf diff --git a/nitrocli/src/args.rs b/nitrocli/src/args.rs index f47f1a0..2e56e9e 100644 --- a/nitrocli/src/args.rs +++ b/nitrocli/src/args.rs @@ -17,8 +17,14 @@ // * along with this program. If not, see <http://www.gnu.org/licenses/>. * // ************************************************************************* +use std::collections; +use std::env; use std::ffi; +use std::fmt; +use std::fs; use std::io; +use std::path; +use std::process; use std::result; use std::str; @@ -28,6 +34,7 @@ use crate::pinentry; use crate::RunCtx; type Result<T> = result::Result<T, Error>; +type Extensions = collections::BTreeMap<String, path::PathBuf>; /// Wraps a writer and buffers its output. /// @@ -141,6 +148,87 @@ Enum! {Builtin, [ Unencrypted => ("unencrypted", unencrypted), ]} +#[derive(Debug)] +enum Command { + Builtin(Builtin), + Extension(String), +} + +impl Command { + pub fn execute( + &self, + ctx: &mut ExecCtx<'_>, + args: Vec<String>, + extensions: Extensions, + ) -> Result<()> { + match self { + Command::Builtin(command) => command.execute(ctx, args), + Command::Extension(extension) => { + match extensions.get(extension) { + Some(path) => { + // Note that theoretically we could just exec the extension + // and be done. However, the problem with that approach is + // that it makes testing extension support much more nasty, + // because the test process would be overwritten in the + // process, requiring us to essentially fork & exec nitrocli + // beforehand -- which is much more involved from a cargo + // test context. + let mut cmd = process::Command::new(path); + + if let Some(model) = ctx.model { + let _ = cmd.args(&["--model", model.as_ref()]); + }; + + let out = cmd + // TODO: We may want to take this path from the command + // execution context. + .args(&["--nitrocli", &env::current_exe()?.to_string_lossy()]) + .args(&["--verbosity", &ctx.verbosity.to_string()]) + .args(&args[1..]) + .output() + .map_err(Into::<Error>::into)?; + ctx.stdout.write_all(&out.stdout)?; + ctx.stderr.write_all(&out.stderr)?; + if out.status.success() { + Ok(()) + } else { + Err(Error::ExtensionFailed( + extension.to_string(), + out.status.code(), + )) + } + } + None => Err(Error::Error(format!("Unknown command: {}", extension))), + } + } + } + } +} + +impl fmt::Display for Command { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Command::Builtin(cmd) => write!(f, "{}", cmd), + Command::Extension(ext) => write!(f, "{}", ext), + } + } +} + +impl str::FromStr for Command { + type Err = (); + + fn from_str(s: &str) -> result::Result<Self, Self::Err> { + Ok(match Builtin::from_str(s) { + Ok(cmd) => Command::Builtin(cmd), + // Note that at this point we cannot know whether the extension + // exists or not and so we always return success. However, if we + // fail looking up the corresponding command an error will be + // emitted later on. + Err(()) => Command::Extension(s.to_string()), + }) + } +} + Enum! {ConfigCommand, [ Get => ("get", config_get), Set => ("set", config_set), @@ -913,6 +1001,60 @@ fn pws_status(ctx: &mut ExecCtx<'_>, args: Vec<String>) -> Result<()> { commands::pws_status(ctx, all) } +/// Find all the available extensions. Extensions are (executable) files +/// that have the "nitrocli-" prefix and are discoverable via the `PATH` +/// environment variable. +// Note that we use a BTreeMap here to have a stable ordering among +// extensions. That makes for a nicer user experience over a HashMap as +// they appear in the help text and random changes in position are +// confusing. +fn find_extensions(path: &ffi::OsStr) -> Result<Extensions> { + // The std::env module has several references to the PATH environment + // variable, indicating that this name is considered platform + // independent from their perspective. We do the same. + let dirs = env::split_paths(path); + let mut commands = Extensions::new(); + let prefix = format!("{}-", crate::NITROCLI); + + for dir in dirs { + match fs::read_dir(&path::Path::new(&dir)) { + Ok(entries) => { + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + let file = String::from(entry.file_name().to_str().unwrap()); + // Note that we deliberately do not check whether the file + // we found is executable. If it is not we will just fail + // later on with a permission denied error. The reasons for + // this behavior are two fold: + // 1) Checking whether a file is executable in Rust is + // painful (as of 1.37 there exists the PermissionsExt + // trait but it is available only for Unix based + // systems). + // 2) It is considered a better user experience to show an + // extension that we found (we list them in the help + // text) even if it later turned out to be not usable + // over not showing it and silently doing nothing -- + // mostly because anything residing in PATH should be + // executable anyway and given that its name also starts + // with nitrocli- we are pretty sure that's a bug on the + // user's side. + if file.starts_with(&prefix) { + let mut file = file; + file.replace_range(..prefix.len(), ""); + assert!(commands.insert(file, path).is_none()); + } + } + } + } + Err(ref err) if err.kind() == io::ErrorKind::NotFound => (), + x => x.map(|_| ())?, + } + } + Ok(commands) +} + /// Parse the command-line arguments and execute the selected command. pub(crate) fn handle_arguments(ctx: &mut RunCtx<'_>, args: Vec<String>) -> Result<()> { use std::io::Write; @@ -924,8 +1066,20 @@ pub(crate) fn handle_arguments(ctx: &mut RunCtx<'_>, args: Vec<String>) -> Resul fmt_enum!(DeviceModel::all_variants()) ); let mut verbosity = 0; - let mut command = Builtin::Status; - let cmd_help = cmd_help!(command); + let path = ctx.path.take().unwrap_or_else(ffi::OsString::new); + let extensions = find_extensions(&path)?; + let mut command = Command::Builtin(Builtin::Status); + let commands = Builtin::all_variants() + .iter() + .map(AsRef::as_ref) + .map(ToOwned::to_owned) + .chain(extensions.keys().cloned()) + .collect::<Vec<_>>() + .join("|"); + // argparse's help text formatting is pretty bad for our intents and + // purposes. In particular, line breaks are just ignored by its custom + // line wrapping algorithm. + let cmd_help = format!("The command to execute ({})", commands); let mut subargs = vec![]; let mut parser = argparse::ArgumentParser::new(); let _ = parser.refer(&mut version).add_option( @@ -981,6 +1135,50 @@ pub(crate) fn handle_arguments(ctx: &mut RunCtx<'_>, args: Vec<String>) -> Resul no_cache: ctx.no_cache, verbosity, }; - command.execute(&mut ctx, subargs) + command.execute(&mut ctx, subargs, extensions) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_extensions_available() -> Result<()> { + let exts = find_extensions(&ffi::OsString::new())?; + assert!(exts.is_empty(), "{:?}", exts); + Ok(()) + } + + #[test] + fn discover_extensions() -> Result<()> { + let dir1 = tempfile::tempdir()?; + let dir2 = tempfile::tempdir()?; + + { + let ext1_path = dir1.path().join("nitrocli-ext1"); + let ext2_path = dir1.path().join("nitrocli-ext2"); + let ext3_path = dir2.path().join("nitrocli-super-1337-extensions111one"); + let _ext1 = fs::File::create(&ext1_path)?; + let _ext2 = fs::File::create(&ext2_path)?; + let _ext3 = fs::File::create(&ext3_path)?; + + let path = env::join_paths(&[dir1.path(), dir2.path()]) + .map_err(|err| Error::Error(err.to_string()))?; + let exts = find_extensions(&path)?; + + let mut it = exts.iter(); + // Because we control the file names and the order of directories + // in `PATH` we can safely assume a fixed order in which + // extensions should be discovered. + assert_eq!(it.next(), Some((&"ext1".to_string(), &ext1_path))); + assert_eq!(it.next(), Some((&"ext2".to_string(), &ext2_path))); + assert_eq!( + it.next(), + Some((&"super-1337-extensions111one".to_string(), &ext3_path)) + ); + assert_eq!(it.next(), None); + } + Ok(()) } } diff --git a/nitrocli/src/error.rs b/nitrocli/src/error.rs index 819bed8..1f6424e 100644 --- a/nitrocli/src/error.rs +++ b/nitrocli/src/error.rs @@ -45,6 +45,7 @@ pub enum Error { NitrokeyError(Option<&'static str>, nitrokey::Error), Utf8Error(str::Utf8Error), Error(String), + ExtensionFailed(String, Option<i32>), } impl TryInto<nitrokey::Error> for Error { @@ -99,6 +100,13 @@ impl fmt::Display for Error { Error::Utf8Error(_) => write!(f, "Encountered UTF-8 conversion error"), Error::IoError(ref e) => write!(f, "IO error: {}", e), Error::Error(ref e) => write!(f, "{}", e), + Error::ExtensionFailed(ref ext, rc) => { + write!(f, "Extension {} failed", ext)?; + if let Some(rc) = rc { + write!(f, " with exit code {}", rc)?; + } + Ok(()) + } } } } diff --git a/nitrocli/src/main.rs b/nitrocli/src/main.rs index bb4b007..92ecfe6 100644 --- a/nitrocli/src/main.rs +++ b/nitrocli/src/main.rs @@ -101,6 +101,8 @@ pub(crate) struct RunCtx<'io> { pub stdout: &'io mut dyn io::Write, /// The `Write` object used as standard error throughout the program. pub stderr: &'io mut dyn io::Write, + /// The content of the `PATH` environment variable. + pub path: Option<ffi::OsString>, /// The admin PIN, if provided through an environment variable. pub admin_pin: Option<ffi::OsString>, /// The user PIN, if provided through an environment variable. @@ -129,6 +131,11 @@ fn run<'ctx, 'io: 'ctx>(ctx: &'ctx mut RunCtx<'io>, args: Vec<String>) -> i32 { // argparse printed an error message _ => 1, }, + Error::ExtensionFailed(_, rc) => { + // We let the extension itself deal with error reporting, we + // just mirror its exit code (if any). + rc.unwrap_or(1) + } _ => { let _ = eprintln!(ctx, "{}", err); 1 @@ -146,6 +153,7 @@ fn main() { let ctx = &mut RunCtx { stdout: &mut stdout, stderr: &mut stderr, + path: env::var_os("PATH"), admin_pin: env::var_os(NITROCLI_ADMIN_PIN), user_pin: env::var_os(NITROCLI_USER_PIN), new_admin_pin: env::var_os(NITROCLI_NEW_ADMIN_PIN), diff --git a/nitrocli/src/tests/extension_model_test.py b/nitrocli/src/tests/extension_model_test.py new file mode 100644 index 0000000..651c8e7 --- /dev/null +++ b/nitrocli/src/tests/extension_model_test.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python + +from argparse import ( + ArgumentParser, +) +from enum import ( + Enum, +) +from sys import ( + argv, + exit, +) + + +class Action(Enum): + """An action to perform.""" + NITROCLI = "nitrocli" + MODEL = "model" + VERBOSITY = "verbosity" + + @classmethod + def all(cls): + """Return the list of all the enum members' values.""" + return [x.value for x in cls.__members__.values()] + + +def main(args): + """The extension's main function.""" + parser = ArgumentParser() + parser.add_argument(choices=Action.all(), dest="what") + parser.add_argument("--nitrocli", action="store", default=None) + parser.add_argument("--model", action="store", default=None) + # We deliberately store the argument to this option as a string + # because we can differentiate between None and a valid value, in + # order to verify that it really is supplied. + parser.add_argument("--verbosity", action="store", default=None) + + namespace = parser.parse_args(args[1:]) + if namespace.what == Action.NITROCLI.value: + print(namespace.nitrocli) + elif namespace.what == Action.MODEL.value: + print(namespace.model) + elif namespace.what == Action.VERBOSITY.value: + print(namespace.verbosity) + else: + return 1 + + return 0 + + +if __name__ == "__main__": + exit(main(argv)) diff --git a/nitrocli/src/tests/mod.rs b/nitrocli/src/tests/mod.rs index dc15117..ce3f861 100644 --- a/nitrocli/src/tests/mod.rs +++ b/nitrocli/src/tests/mod.rs @@ -85,6 +85,15 @@ impl Builder { self } + /// Set the `PATH` used for looking up extensions. + fn path<P>(mut self, path: P) -> Self + where + P: Into<ffi::OsString>, + { + self.0.path = Some(path.into()); + self + } + /// Set the password to use for certain operations. fn password<P>(mut self, password: P) -> Self where @@ -102,6 +111,7 @@ impl Builder { struct Nitrocli { model: Option<nitrokey::Model>, + path: Option<ffi::OsString>, admin_pin: Option<ffi::OsString>, user_pin: Option<ffi::OsString>, new_admin_pin: Option<ffi::OsString>, @@ -113,6 +123,7 @@ impl Nitrocli { pub fn new() -> Self { Self { model: None, + path: None, admin_pin: Some(nitrokey::DEFAULT_ADMIN_PIN.into()), user_pin: Some(nitrokey::DEFAULT_USER_PIN.into()), new_admin_pin: None, @@ -166,6 +177,7 @@ impl Nitrocli { let ctx = &mut crate::RunCtx { stdout: &mut stdout, stderr: &mut stderr, + path: self.path.clone(), admin_pin: self.admin_pin.clone(), user_pin: self.user_pin.clone(), new_admin_pin: self.new_admin_pin.clone(), diff --git a/nitrocli/src/tests/run.rs b/nitrocli/src/tests/run.rs index c59c660..2212577 100644 --- a/nitrocli/src/tests/run.rs +++ b/nitrocli/src/tests/run.rs @@ -17,6 +17,11 @@ // * along with this program. If not, see <http://www.gnu.org/licenses/>. * // ************************************************************************* +use std::fs; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path; + use super::*; #[test] @@ -101,3 +106,110 @@ fn version_option() { test(&re, "--version"); test(&re, "-V"); } + +#[test] +fn extension() -> crate::Result<()> { + let ext_dir = tempfile::tempdir()?; + { + let mut ext = fs::OpenOptions::new() + .create(true) + .mode(0o755) + .write(true) + .open(ext_dir.path().join("nitrocli-ext"))?; + + ext.write_all( + br#"#!/usr/bin/env python +print("success") +"#, + )?; + } + + let path = ext_dir.path().as_os_str().to_os_string(); + let out = Nitrocli::make().path(path).build().handle(&["ext"])?; + assert_eq!(out, "success\n"); + Ok(()) +} + +#[test] +fn extension_failure() -> crate::Result<()> { + let ext_dir = tempfile::tempdir()?; + { + let mut ext = fs::OpenOptions::new() + .create(true) + .mode(0o755) + .write(true) + .open(ext_dir.path().join("nitrocli-ext"))?; + + ext.write_all( + br#"#!/usr/bin/env python +import sys +sys.exit(42); +"#, + )?; + } + + let path = ext_dir.path().as_os_str().to_os_string(); + let err = Nitrocli::make() + .path(path) + .build() + .handle(&["ext"]) + .unwrap_err(); + + match err { + crate::Error::ExtensionFailed(ext, rc) => { + assert_eq!(&ext, "ext"); + assert_eq!(rc, Some(42)); + } + _ => panic!("Unexpected error variant found: {:?}", err), + }; + Ok(()) +} + +#[test_device] +fn extension_arguments(model: nitrokey::Model) -> crate::Result<()> { + fn test<F>(model: nitrokey::Model, what: &str, args: &[&str], check: F) -> crate::Result<()> + where + F: FnOnce(&str) -> bool, + { + let ext_dir = tempfile::tempdir()?; + { + let mut ext = fs::OpenOptions::new() + .create(true) + .mode(0o755) + .write(true) + .open(ext_dir.path().join("nitrocli-ext"))?; + + ext.write_all(include_bytes!("extension_model_test.py"))?; + } + + let mut args = args.to_vec(); + args.append(&mut vec!["ext", what]); + + let path = ext_dir.path().as_os_str().to_os_string(); + let out = Nitrocli::make() + .model(model) + .path(path) + .build() + .handle(&args)?; + + assert!(check(&out), out); + Ok(()) + } + + test(model, "model", &[], |out| { + out == model.to_string().to_lowercase() + "\n" + })?; + test(model, "nitrocli", &[], |out| { + path::Path::new(out) + .file_stem() + .unwrap() + .to_str() + .unwrap() + .trim() + .contains("nitrocli") + })?; + test(model, "verbosity", &[], |out| out == "0\n")?; + test(model, "verbosity", &["-v"], |out| out == "1\n")?; + test(model, "verbosity", &["-v", "--verbose"], |out| out == "2\n")?; + Ok(()) +} |