aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Mueller <deso@posteo.net>2020-08-25 19:04:50 -0700
committerDaniel Mueller <deso@posteo.net>2020-10-11 17:07:07 -0700
commit8cf63c6790192c30c81294e7a940d470bf061cbf (patch)
tree169ca792304f74a7b1548b5bad798b39649cd4c5
parent330142e68cac0116babbf2fd64fc9ff0fde132c0 (diff)
downloadnitrocli-8cf63c6790192c30c81294e7a940d470bf061cbf.tar.gz
nitrocli-8cf63c6790192c30c81294e7a940d470bf061cbf.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 search the directories listed in the PATH environment variable for a file that starts with "nitrocli-", followed by the extension name. This file is then executed. It is assumed that the extension recognizes (or at least not prohibits) the following arguments: --nitrocli (providing the path to the nitrocli binary), --model (with the model passed to the main program), and --verbosity (the verbosity level).
-rw-r--r--CHANGELOG.md2
-rw-r--r--Cargo.lock62
-rw-r--r--Cargo.toml3
-rw-r--r--doc/nitrocli.153
-rw-r--r--doc/nitrocli.1.pdfbin44064 -> 47754 bytes
-rw-r--r--src/args.rs4
-rw-r--r--src/commands.rs103
-rw-r--r--src/main.rs62
-rw-r--r--src/redefine.rs11
-rw-r--r--src/tests/extension_var_test.py61
-rw-r--r--src/tests/extensions.rs43
-rw-r--r--src/tests/mod.rs10
-rw-r--r--src/tests/run.rs115
13 files changed, 508 insertions, 21 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f754250..2496bd6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,8 @@
Unreleased
----------
- Added support for the Librem Key
+- Added support for user provided extensions through lookup via the
+ `PATH` environment variable
- Added support for configuration files
- Added support for configuration files that can be used to set
default values for some arguments
diff --git a/Cargo.lock b/Cargo.lock
index 4948736..506ffec 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -217,6 +217,7 @@ dependencies = [
"regex",
"serde",
"structopt",
+ "tempfile",
"termion",
"toml",
]
@@ -275,6 +276,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
[[package]]
+name = "ppv-lite86"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c36fa947111f5c62a733b652544dd0016a43ce89619538a8ef92724a6f501a20"
+
+[[package]]
name = "proc-macro-error"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -328,6 +335,29 @@ dependencies = [
]
[[package]]
+name = "rand"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+dependencies = [
+ "getrandom",
+ "libc",
+ "rand_chacha",
+ "rand_core",
+ "rand_hc",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
name = "rand_core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -337,6 +367,15 @@ dependencies = [
]
[[package]]
+name = "rand_hc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
name = "redox_syscall"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -381,6 +420,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe5bd57d1d7414c6b5ed48563a2c855d995ff777729dcd91c369ec7fea395ae"
[[package]]
+name = "remove_dir_all"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
name = "rust-argon2"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -459,6 +507,20 @@ dependencies = [
]
[[package]]
+name = "tempfile"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "rand",
+ "redox_syscall",
+ "remove_dir_all",
+ "winapi",
+]
+
+[[package]]
name = "termion"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 4fbed9d..1af4f2f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,3 +78,6 @@ version = "0.1"
[dev-dependencies.regex]
version = "1"
+
+[dev-dependencies.tempfile]
+version = "3.1"
diff --git a/doc/nitrocli.1 b/doc/nitrocli.1
index 61764dd..832c90d 100644
--- a/doc/nitrocli.1
+++ b/doc/nitrocli.1
@@ -318,6 +318,18 @@ 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. Those executables can be
+invoked as regular subcommands (without the need of the prefix; e.g., an
+extension with the name "nitrocli-otp-cache" could be invoked as "nitrocli
+otp-cache").
+.P
+More information on how to write extensions can be found in the Extensions
+section below.
+
.SH CONFIG FILE
\fBnitrocli\fR tries to read the configuration file at
\fB${XDG_CONFIG_HOME}/nitrocli/config.toml\fR (or
@@ -406,6 +418,47 @@ for the \fBuser\fR type.
.B NITROCLI_PASSWORD
A password used by commands that require one (e.g., \fBhidden open\fR).
+.SH EXTENSIONS
+\fBnitrocli\fR supports user-provided extensions that are executable files whose
+filename starts with "nitrocli-" and that are discoverable through lookup via
+the \fBPATH\fR environment variable.
+
+The program conveys basic configuration information to any extension being
+started this way. Specifically, it will set each environment variable as
+described in the Configuration subsection of the Environment section above, if
+the corresponding \fBnitrocli\fR program configuration was set. In addition, the
+following variable will be set unconditionally:
+.TP
+.B NITROCLI_BINARY
+The absolute path to the \fBnitrocli\fR binary through which the extension was
+invoked. This path may be used to recursively invoke \fBnitrocli\fR to implement
+certain functionality.
+
+.P
+All other variables present in the environment will be passed through to the
+extension verbatim.
+.P
+Newer versions of the program reserve the right to set additional environment
+variables inside the "NITROCLI_" namespace. As such, extensions are advised to
+not define custom variables with this prefix. However, "NITROCLI_EXT_" is
+provided specifically for this purpose. To further avoid conflicts between
+extensions, it is recommended that this prefix be followed by the extension's
+name (uppercased).
+
+.P
+Extensions may optionally read or write persistent data of various forms.
+Similar to the main program, extensions should follow the XDG Base Directory
+Specification as a guideline where to store such data. More specifically, the
+following conventions should be followed:
+
+For configuration data, \fB${XDG_CONFIG_HOME}/\fIextension/\fR is the preferred
+directory, where \fIextension\fR is the full extension name, including the
+"nitrocli-" prefix. The recommended configuration format is TOML. If only a
+single configuration file is used, \fBconfig.toml\fR is the recommended name.
+
+Similarly, regular data should reside in \fB${XDG_DATA_HOME}/\fIextension/\fR
+and cached data be stored in \fB${XDG_CACHE_HOME}/\fIextension/\fR.
+
.SH FILES
.TP
.B ${XDG_CONFIG_HOME}/nitrocli/config.toml
diff --git a/doc/nitrocli.1.pdf b/doc/nitrocli.1.pdf
index 840449e..8971d96 100644
--- a/doc/nitrocli.1.pdf
+++ b/doc/nitrocli.1.pdf
Binary files differ
diff --git a/src/args.rs b/src/args.rs
index 6cf37c8..a8f43e1 100644
--- a/src/args.rs
+++ b/src/args.rs
@@ -4,6 +4,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
use std::convert;
+use std::ffi;
/// Provides access to a Nitrokey device
#[derive(Debug, structopt::StructOpt)]
@@ -107,6 +108,9 @@ Command! {
Status => crate::commands::status,
/// Interacts with the device's unencrypted volume
Unencrypted(UnencryptedArgs) => |ctx, args: UnencryptedArgs| args.subcmd.execute(ctx),
+ /// An extension and its arguments.
+ #[structopt(external_subcommand)]
+ Extension(Vec<ffi::OsString>) => crate::commands::extension,
]
}
diff --git a/src/commands.rs b/src/commands.rs
index 092b006..549ebec 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -3,11 +3,17 @@
// Copyright (C) 2018-2020 The Nitrocli Developers
// SPDX-License-Identifier: GPL-3.0-or-later
+use std::borrow;
use std::convert::TryFrom as _;
+use std::env;
+use std::ffi;
use std::fmt;
+use std::io;
use std::mem;
use std::ops;
use std::ops::Deref as _;
+use std::path;
+use std::process;
use std::thread;
use std::time;
use std::u8;
@@ -1081,6 +1087,103 @@ pub fn pws_status(ctx: &mut Context<'_>, all: bool) -> anyhow::Result<()> {
})
}
+/// Resolve an extension provided by name to an actual path.
+///
+/// Extensions are (executable) files that have the "nitrocli-" prefix
+/// and are discoverable via the `PATH` environment variable.
+pub(crate) fn resolve_extension(
+ path_var: &ffi::OsStr,
+ ext_name: &ffi::OsStr,
+) -> anyhow::Result<path::PathBuf> {
+ let mut bin_name = ffi::OsString::from("nitrocli-");
+ bin_name.push(ext_name);
+
+ for dir in env::split_paths(path_var) {
+ let mut bin_path = dir.clone();
+ bin_path.push(&bin_name);
+ // 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 resolve to an
+ // extension 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 bin_path.is_file() {
+ return Ok(bin_path);
+ }
+ }
+
+ let err = if let Some(name) = bin_name.to_str() {
+ format!("Extension {} not found", name).into()
+ } else {
+ borrow::Cow::from("Extension not found")
+ };
+ Err(io::Error::new(io::ErrorKind::NotFound, err).into())
+}
+
+/// Run an extension.
+pub fn extension(ctx: &mut Context<'_>, args: Vec<ffi::OsString>) -> anyhow::Result<()> {
+ // Note that while `Command` would actually honor PATH by itself, we
+ // do not want that behavior because it would circumvent the execution
+ // context we use for testing. As such, we need to do our own search.
+ let mut args = args.into_iter();
+ let ext_name = args.next().context("No extension specified")?;
+ let path_var = ctx.path.as_ref().context("PATH variable not present")?;
+ let ext_path = resolve_extension(&path_var, &ext_name)?;
+
+ // 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(&ext_path);
+
+ if let Some(model) = ctx.config.model {
+ let _ = cmd.env(crate::NITROCLI_MODEL, model.to_string());
+ }
+
+ if let Some(usb_path) = &ctx.config.usb_path {
+ let _ = cmd.env(crate::NITROCLI_USB_PATH, usb_path);
+ }
+
+ // TODO: We may want to take this path from the command execution
+ // context.
+ let binary = env::current_exe().context("Failed to retrieve path to nitrocli binary")?;
+ let serial_numbers = ctx
+ .config
+ .serial_numbers
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>()
+ .join(",");
+
+ let out = cmd
+ .env(crate::NITROCLI_BINARY, binary)
+ .env(crate::NITROCLI_VERBOSITY, ctx.config.verbosity.to_string())
+ .env(crate::NITROCLI_NO_CACHE, ctx.config.no_cache.to_string())
+ .env(crate::NITROCLI_SERIAL_NUMBERS, serial_numbers)
+ .args(args)
+ .output()
+ .with_context(|| format!("Failed to execute extension {}", ext_path.display()))?;
+ ctx.stdout.write_all(&out.stdout)?;
+ ctx.stderr.write_all(&out.stderr)?;
+
+ if out.status.success() {
+ Ok(())
+ } else if let Some(rc) = out.status.code() {
+ Err(anyhow::Error::new(crate::DirectExitError(rc)))
+ } else {
+ Err(anyhow::Error::new(crate::DirectExitError(1)))
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/main.rs b/src/main.rs
index c0c7da5..e7a7d2f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -62,9 +62,19 @@ mod pinentry;
mod tests;
use std::env;
+use std::error;
use std::ffi;
+use std::fmt;
use std::io;
use std::process;
+use std::str;
+
+const NITROCLI_BINARY: &str = "NITROCLI_BINARY";
+const NITROCLI_MODEL: &str = "NITROCLI_MODEL";
+const NITROCLI_USB_PATH: &str = "NITROCLI_USB_PATH";
+const NITROCLI_VERBOSITY: &str = "NITROCLI_VERBOSITY";
+const NITROCLI_NO_CACHE: &str = "NITROCLI_NO_CACHE";
+const NITROCLI_SERIAL_NUMBERS: &str = "NITROCLI_SERIAL_NUMBERS";
const NITROCLI_ADMIN_PIN: &str = "NITROCLI_ADMIN_PIN";
const NITROCLI_USER_PIN: &str = "NITROCLI_USER_PIN";
@@ -72,6 +82,28 @@ const NITROCLI_NEW_ADMIN_PIN: &str = "NITROCLI_NEW_ADMIN_PIN";
const NITROCLI_NEW_USER_PIN: &str = "NITROCLI_NEW_USER_PIN";
const NITROCLI_PASSWORD: &str = "NITROCLI_PASSWORD";
+/// A special error type that indicates the desire to exit directly,
+/// without additional error reporting.
+///
+/// This error is mostly used by the extension support code so that we
+/// are able to mirror the extension's exit code while preserving our
+/// context logic and the fairly isolated testing it enables.
+struct DirectExitError(i32);
+
+impl fmt::Debug for DirectExitError {
+ fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
+ unreachable!()
+ }
+}
+
+impl fmt::Display for DirectExitError {
+ fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
+ unreachable!()
+ }
+}
+
+impl error::Error for DirectExitError {}
+
/// Parse the command-line arguments and execute the selected command.
fn handle_arguments(ctx: &mut Context<'_>, args: Vec<String>) -> anyhow::Result<()> {
use structopt::StructOpt;
@@ -101,6 +133,8 @@ pub struct Context<'io> {
pub stderr: &'io mut dyn io::Write,
/// Whether `stdout` is a TTY.
pub is_tty: bool,
+ /// 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.
@@ -135,6 +169,10 @@ impl<'io> Context<'io> {
stdout,
stderr,
is_tty,
+ // 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.
+ 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),
@@ -145,16 +183,21 @@ impl<'io> Context<'io> {
}
}
-fn run<'ctx, 'io: 'ctx>(ctx: &'ctx mut Context<'io>, args: Vec<String>) -> i32 {
- match handle_arguments(ctx, args) {
- Ok(()) => 0,
- Err(err) => {
- let _ = eprintln!(ctx, "{:?}", err);
- 1
- }
+fn evaluate_err(err: anyhow::Error, stderr: &mut dyn io::Write) -> i32 {
+ if let Some(err) = err.root_cause().downcast_ref::<DirectExitError>() {
+ err.0
+ } else {
+ let _ = writeln!(stderr, "{:?}", err);
+ 1
}
}
+fn run<'ctx, 'io: 'ctx>(ctx: &'ctx mut Context<'io>, args: Vec<String>) -> i32 {
+ handle_arguments(ctx, args)
+ .map(|()| 0)
+ .unwrap_or_else(|err| evaluate_err(err, ctx.stderr))
+}
+
fn main() {
use std::io::Write;
@@ -169,10 +212,7 @@ fn main() {
run(ctx, args)
}
- Err(err) => {
- let _ = writeln!(stderr, "{:?}", err);
- 1
- }
+ Err(err) => evaluate_err(err, &mut stderr),
};
// We exit the process the hard way below. The problem is that because
diff --git a/src/redefine.rs b/src/redefine.rs
index 10fb631..fe15765 100644
--- a/src/redefine.rs
+++ b/src/redefine.rs
@@ -1,6 +1,6 @@
// redefine.rs
-// Copyright (C) 2019 The Nitrocli Developers
+// Copyright (C) 2019-2020 The Nitrocli Developers
// SPDX-License-Identifier: GPL-3.0-or-later
// A replacement of the standard println!() macro that requires an
@@ -19,12 +19,3 @@ macro_rules! print {
write!($ctx.stdout, $($arg)*)
};
}
-
-macro_rules! eprintln {
- ($ctx:expr) => {
- writeln!($ctx.stderr, "")
- };
- ($ctx:expr, $($arg:tt)*) => {
- writeln!($ctx.stderr, $($arg)*)
- };
-}
diff --git a/src/tests/extension_var_test.py b/src/tests/extension_var_test.py
new file mode 100644
index 0000000..af7ec84
--- /dev/null
+++ b/src/tests/extension_var_test.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2020 The Nitrocli Developers
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from argparse import (
+ ArgumentParser,
+)
+from enum import (
+ Enum,
+)
+from os import (
+ environ,
+)
+from sys import (
+ argv,
+ exit,
+)
+
+
+class Action(Enum):
+ """An action to perform."""
+ BINARY = "binary"
+ MODEL = "model"
+ NO_CACHE = "no-cache"
+ SERIAL_NUMBERS = "serial-numbers"
+ USB_PATH = "usb-path"
+ 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:])
+ # We create a "reverse" mapping from string to variant (e.g., model ->
+ # MODEL).
+ options = {v.value: k for k, v in Action.__members__.items()}
+ try:
+ var = options[namespace.what]
+ print(environ[f"NITROCLI_{var}"])
+ except KeyError:
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ exit(main(argv))
diff --git a/src/tests/extensions.rs b/src/tests/extensions.rs
new file mode 100644
index 0000000..a295949
--- /dev/null
+++ b/src/tests/extensions.rs
@@ -0,0 +1,43 @@
+// extensions.rs
+
+// Copyright (C) 2020 The Nitrocli Developers
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+use std::env;
+use std::fs;
+
+use super::*;
+
+#[test]
+fn resolve_extensions() -> anyhow::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()])?;
+ assert_eq!(
+ crate::commands::resolve_extension(&path, ffi::OsStr::new("ext1"))?,
+ ext1_path
+ );
+ assert_eq!(
+ crate::commands::resolve_extension(&path, ffi::OsStr::new("ext2"))?,
+ ext2_path
+ );
+ assert_eq!(
+ crate::commands::resolve_extension(&path, ffi::OsStr::new("super-1337-extensions111one"))?,
+ ext3_path
+ );
+
+ let err = crate::commands::resolve_extension(&ffi::OsStr::new(""), ffi::OsStr::new("ext1"))
+ .unwrap_err();
+ assert_eq!(err.to_string(), "Extension nitrocli-ext1 not found");
+ }
+ Ok(())
+}
diff --git a/src/tests/mod.rs b/src/tests/mod.rs
index 65983bb..5e47520 100644
--- a/src/tests/mod.rs
+++ b/src/tests/mod.rs
@@ -9,6 +9,7 @@ use nitrokey_test::test as test_device;
mod config;
mod encrypted;
+mod extensions;
mod fill;
mod hidden;
mod list;
@@ -23,6 +24,7 @@ mod unencrypted;
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>,
@@ -34,6 +36,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,
@@ -54,6 +57,12 @@ impl Nitrocli {
self
}
+ /// Set the `PATH` used for looking up extensions.
+ fn path(mut self, path: impl Into<ffi::OsString>) -> Self {
+ self.path = Some(path.into());
+ self
+ }
+
pub fn admin_pin(mut self, pin: impl Into<ffi::OsString>) -> Self {
self.admin_pin = Some(pin.into());
self
@@ -102,6 +111,7 @@ impl Nitrocli {
stdout: &mut stdout,
stderr: &mut stderr,
is_tty: false,
+ 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/src/tests/run.rs b/src/tests/run.rs
index 33191d3..e4bbb28 100644
--- a/src/tests/run.rs
+++ b/src/tests/run.rs
@@ -5,8 +5,12 @@
use std::collections;
use std::convert;
+use std::convert::TryFrom as _;
use std::convert::TryInto as _;
+use std::fs;
+use std::io::Write;
use std::ops;
+use std::os::unix::fs::OpenOptionsExt;
use std::path;
use super::*;
@@ -277,3 +281,114 @@ fn connect_usb_path_model_wrong_serial(_model: nitrokey::Model) -> anyhow::Resul
}
Ok(())
}
+
+#[test]
+fn extension() -> anyhow::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::new().path(path).handle(&["ext"])?;
+ assert_eq!(out, "success\n");
+ Ok(())
+}
+
+#[test]
+fn extension_failure() -> anyhow::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 mut ncli = Nitrocli::new().path(path);
+
+ let err = ncli.handle(&["ext"]).unwrap_err();
+ // The extension is responsible for printing any error messages.
+ // Nitrocli is expected not to mess with them, including adding
+ // additional information.
+ if let Some(crate::DirectExitError(rc)) = err.downcast_ref::<crate::DirectExitError>() {
+ assert_eq!(*rc, 42)
+ } else {
+ panic!("encountered unexpected error: {:#}", err)
+ }
+
+ let (rc, out, err) = ncli.run(&["ext"]);
+ assert_eq!(rc, 42);
+ assert_eq!(out, b"");
+ assert_eq!(err, b"");
+ Ok(())
+}
+
+#[test_device]
+fn extension_arguments(model: nitrokey::Model) -> anyhow::Result<()> {
+ fn test<F>(model: nitrokey::Model, what: &str, args: &[&str], check: F) -> anyhow::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_var_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::new().model(model).path(path).handle(&args)?;
+
+ assert!(check(&out), out);
+ Ok(())
+ }
+
+ test(model, "binary", &[], |out| {
+ path::Path::new(out)
+ .file_stem()
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .trim()
+ .contains("nitrocli")
+ })?;
+ test(model, "model", &[], |out| {
+ out == args::DeviceModel::try_from(model).unwrap().to_string() + "\n"
+ })?;
+ test(model, "no-cache", &[], |out| out == "true\n")?;
+ test(model, "serial-numbers", &[], |out| out == "\n")?;
+ test(model, "verbosity", &[], |out| out == "0\n")?;
+ test(model, "verbosity", &["-v"], |out| out == "1\n")?;
+ test(model, "verbosity", &["-v", "--verbose"], |out| out == "2\n")?;
+
+ // NITROCLI_USB_PATH should not be set, so the program errors out.
+ let _ = test(model, "usb-path", &[], |out| out == "\n").unwrap_err();
+ Ok(())
+}