diff options
-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(()) +} |