Skip to content

Commit

Permalink
Introduce support for user-provided extensions
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
d-e-s-o committed Aug 26, 2020
1 parent 3e78774 commit 153f146
Show file tree
Hide file tree
Showing 11 changed files with 422 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
Unreleased
----------
- 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
Expand Down
62 changes: 62 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,6 @@ version = "0.1"

[dev-dependencies.regex]
version = "1"

[dev-dependencies.tempfile]
version = "3.1"
26 changes: 26 additions & 0 deletions doc/nitrocli.1
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,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 CONFIG FILE
\fBnitrocli\fR tries to read the configuration file at
\fB${XDG_CONFIG_HOME}/nitrocli/config.toml\fR (or
Expand Down
5 changes: 5 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// Copyright (C) 2020 The Nitrocli Developers
// SPDX-License-Identifier: GPL-3.0-or-later

use std::ffi;

/// Provides access to a Nitrokey device
#[derive(Debug, structopt::StructOpt)]
#[structopt(name = "nitrocli")]
Expand Down Expand Up @@ -94,6 +96,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,
]
}

Expand Down
105 changes: 105 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
// 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::Deref as _;
use std::path;
use std::process;
use std::thread;
use std::time;
use std::u8;
Expand Down Expand Up @@ -1031,6 +1037,105 @@ 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);

// 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.
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 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 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()
.ok_or_else(|| anyhow::anyhow!("No extension specified"))?;
let path_var = ctx
.path
.as_ref()
.ok_or_else(|| anyhow::anyhow!("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);

// 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 model = ctx
.config
.model
.map(|model| model.to_string())
.unwrap_or_else(String::new);
let verbosity = ctx.config.verbosity.to_string();

let out = cmd
.env(crate::NITROCLI_BINARY, binary)
.env(crate::NITROCLI_MODEL, model)
.env(crate::NITROCLI_VERBOSITY, verbosity)
.args(args)
.output()?;
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() {
anyhow::bail!(
"Extension {} failed with error status {}",
ext_path.display(),
rc
)
} else {
anyhow::bail!("Extension {} indicated a failure", ext_path.display())
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ use std::env;
use std::ffi;
use std::io;
use std::process;
use std::str;

const NITROCLI_BINARY: &str = "NITROCLI_BINARY";
const NITROCLI_MODEL: &str = "NITROCLI_MODEL";
const NITROCLI_VERBOSITY: &str = "NITROCLI_VERBOSITY";
const NITROCLI_ADMIN_PIN: &str = "NITROCLI_ADMIN_PIN";
const NITROCLI_USER_PIN: &str = "NITROCLI_USER_PIN";
const NITROCLI_NEW_ADMIN_PIN: &str = "NITROCLI_NEW_ADMIN_PIN";
Expand Down Expand Up @@ -98,6 +102,8 @@ pub struct Context<'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.
Expand Down Expand Up @@ -126,6 +132,7 @@ impl<'io> Context<'io> {
Context {
stdout,
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),
Expand Down
58 changes: 58 additions & 0 deletions src/tests/extension_model_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env python

# 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."""
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(environ["NITROCLI_BINARY"])
elif namespace.what == Action.MODEL.value:
print(environ["NITROCLI_MODEL"])
elif namespace.what == Action.VERBOSITY.value:
print(environ["NITROCLI_VERBOSITY"])
else:
return 1

return 0


if __name__ == "__main__":
exit(main(argv))
Loading

0 comments on commit 153f146

Please sign in to comment.