diff --git a/Cargo.lock b/Cargo.lock index 92ffebbc..5369a69f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3458,6 +3458,7 @@ dependencies = [ name = "sequoia-sq" version = "0.39.0" dependencies = [ + "aho-corasick", "anyhow", "assert_cmd", "buffered-reader", @@ -3496,6 +3497,7 @@ dependencies = [ "textwrap", "thiserror", "tokio", + "toml_edit", "typenum", ] @@ -4083,6 +4085,23 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -4669,6 +4688,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index d5e9a19b..9c606d45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ gitlab = { repository = "sequoia-pgp/sequoia-sq" } maintenance = { status = "actively-developed" } [dependencies] +aho-corasick = "1" buffered-reader = { version = "1.3.1", default-features = false, features = ["compression"] } dirs = "5" fs_extra = "1" @@ -36,7 +37,7 @@ sequoia-directories = "0.1" sequoia-openpgp = { version = "1.18", default-features = false, features = ["compression"] } sequoia-autocrypt = { version = "0.25", default-features = false } sequoia-net = { version = "0.28", default-features = false } -sequoia-policy-config = ">= 0.6, <0.8" +sequoia-policy-config = ">= 0.7, <0.8" anyhow = "1.0.18" chrono = "0.4.10" clap = { version = "4", features = ["derive", "env", "string", "wrap_help"] } @@ -52,6 +53,8 @@ sequoia-wot = { version = "0.13.2", default-features = false } tempfile = "3.1" thiserror = "1" tokio = { version = "1.13.1" } +toml_edit = { version = "0.22", default-features = false, features = ["display", "parse"] } +regex = "1" rpassword = "7.0" serde = { version = "1.0.137", features = ["derive"] } terminal_size = ">=0.2.6, <0.5" diff --git a/NEWS b/NEWS index 91890593..544a2c98 100644 --- a/NEWS +++ b/NEWS @@ -121,6 +121,9 @@ removed: if a secret key is provided as file input, it will be emitted. - The argument `sq key subkey export --cert-file` has been removed. + - `sq` now reads a configuration file that can be used to tweak a + number of defaults, like the cipher suite to generate new keys, + the set of key servers to query, and the cryptographic policy. * Changes in 0.39.0 ** Notable changes diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 00000000..6d9be767 --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,105 @@ +//! Command-line parser for `sq config`. + +use std::{ + collections::BTreeMap, + sync::OnceLock, +}; + +use clap::{ + Parser, + Subcommand, +}; + +use sequoia_directories::Home; + +pub mod get; +pub mod set; +pub mod template; + +/// Computes the path to the config file even if argument parsing +/// failed. +/// +/// This happens notably if `--help` is given. +pub fn find_home() -> Option { + let args = std::env::args().collect::>(); + + for (i, arg) in args.iter().enumerate() { + if arg == "--" { + break; + } + + if arg.starts_with("--home=") { + return Home::new(Some(arg["--home=".len()..].into())).ok(); + } + + if arg == "--home" { + if let Some(home) = args.get(i + 1) { + return Home::new(Some(home.into())).ok(); + } + } + } + + Home::new(None).ok() +} + +/// Values read from the config file to be included in help messages. +pub type Augmentations = BTreeMap<&'static str, String>; + +/// Includes values from the config file in help messages. +pub fn augment_help(key: &'static str, text: &str) -> String { + if let Some(a) = AUGMENTATIONS.get().and_then(|a| a.get(key)) { + format!("{}\n\n[config: {}] (overrides default)", text, a) + } else { + text.into() + } +} + +/// Includes values from the config file in help messages. +pub fn set_augmentations(augmentations: Augmentations) { + AUGMENTATIONS.set(augmentations) + .expect("augmentations must only be set once"); +} + +/// Values read from the config file to be included in help messages. +static AUGMENTATIONS: OnceLock = OnceLock::new(); + +#[derive(Debug, Parser)] +#[clap( + name = "config", + about = "Get configuration options", + long_about = format!("\ +Get configuration options + +This subcommand can be used to inspect the configuration \ +file{}, and to create a template that can be edited to your liking. +", + sequoia_directories::Home::default() + .map(|home| { + let p = home.config_dir(sequoia_directories::Component::Sq); + let p = p.join("config.toml"); + let p = p.display().to_string(); + if let Some(home) = dirs::home_dir() { + let home = home.display().to_string(); + if let Some(rest) = p.strip_prefix(&home) { + return format!(" (default location: $HOME{})", + rest); + } + } + p + }) + .unwrap_or("".to_string())), + subcommand_required = true, + arg_required_else_help = true, + disable_help_subcommand = true, +)] +pub struct Command { + #[clap(subcommand)] + pub subcommand: Subcommands, +} + +#[derive(Debug, Subcommand)] +#[non_exhaustive] +pub enum Subcommands { + Get(get::Command), + Template(template::Command), +} diff --git a/src/cli/config/get.rs b/src/cli/config/get.rs new file mode 100644 index 00000000..98cc7c79 --- /dev/null +++ b/src/cli/config/get.rs @@ -0,0 +1,45 @@ +//! Command-line parser for `sq config get`. + +use clap::Args; + +use crate::cli::examples::*; + +#[derive(Debug, Args)] +#[clap( + name = "get", + about = "Get configuration options", + long_about = "\ +Get configuration options + +Retrieves the configuration with the given key. Use `sq config get` \ +to see all available options and their values.", + after_help = GET_EXAMPLES, +)] +pub struct Command { + #[clap( + value_name = "NAME", + help = "Get the value of the configuration NAME", + )] + pub name: Option, +} + +const GET_EXAMPLES: Actions = Actions { + actions: &[ + Action::Example(Example { + comment: "\ +List all configuration options.", + command: &[ + "sq", "config", "get", + ], + }), + + Action::Example(Example { + comment: "\ +Get the default cipher suite for key generation.", + command: &[ + "sq", "config", "get", "key.generate.cipher-suite", + ], + }), + ] +}; +test_examples!(sq_config_get, GET_EXAMPLES); diff --git a/src/cli/config/set.rs b/src/cli/config/set.rs new file mode 100644 index 00000000..f8a112a4 --- /dev/null +++ b/src/cli/config/set.rs @@ -0,0 +1,90 @@ +//! Command-line parser for `sq config`. + +use clap::Args; + +use crate::cli::examples::*; + +// XXX: We don't currently expose the set command. +#[derive(Debug, Args)] +#[clap( + name = "set", + about = "Set configuration options", + long_about = "\ +Set configuration options + +Changes the configuration with the given key. Use `sq config get` \ +to see all existing options and their values. +", + after_help = SET_EXAMPLES, +)] +// XXX: value and delete should be in an argument group, but doing +// that messes up the usage: +// +// Usage: sq config set +// +// Note how VALUE comes first. I believe this is tracked upstream as +// https://github.com/clap-rs/clap/issues/1794 +// +// For now, we do the validation in the command handler. +// +//#[clap(group(ArgGroup::new("action").args(&["value", "delete"]).required(true)))] +pub struct Command { + #[clap( + value_name = "NAME", + help = "Set the value of the configuration NAME", + )] + pub name: String, + + #[clap( + value_name = "VALUE", + help = "New value for the configuration item", + )] + pub value: Option, + + #[clap( + long = "delete", + help = "Delete the configuration item", + conflicts_with = "value", + )] + pub delete: bool, + + #[clap( + long = "add", + help = "Add an item to a list of items", + conflicts_with = "delete", + )] + pub add: bool, +} + +const SET_EXAMPLES: Actions = Actions { + actions: &[ + Action::Example(Example { + comment: "\ +Set the default cipher suite for key generation.", + command: &[ + "sq", "config", "set", "key.generate.cipher-suite", + "rsa3k", + ], + }), + + Action::Example(Example { + comment: "\ +Delete the default cipher suite for key generation.", + command: &[ + "sq", "config", "set", "key.generate.cipher-suite", + "--delete", + ], + }), + + Action::Example(Example { + comment: "\ +Add a default key server for network queries.", + command: &[ + "sq", "config", "set", "network.keyservers", + "--add", "hkps://keys.example.org", + ], + }), + ] +}; +// XXX: We don't currently expose the set command. +//test_examples!(sq_config_set, SET_EXAMPLES); diff --git a/src/cli/config/template.rs b/src/cli/config/template.rs new file mode 100644 index 00000000..5f36a032 --- /dev/null +++ b/src/cli/config/template.rs @@ -0,0 +1,42 @@ +//! Command-line parser for `sq config template`. + +use clap::Args; + +use crate::cli::{ + examples::*, + types::{ClapData, FileOrStdout}, +}; + +#[derive(Debug, Args)] +#[clap( + name = "template", + about = "Write a template configuration file", + long_about = "\ +Write a template configuration file + +Writes a template containing the default values to the given file or stdout. \ +This can be used as a starting point to tweak the configuration.", + after_help = TEMPLATE_EXAMPLES, +)] +pub struct Command { + #[clap( + long, + value_name = FileOrStdout::VALUE_NAME, + default_value_t = FileOrStdout::default(), + help = FileOrStdout::HELP_OPTIONAL, + )] + pub output: FileOrStdout, +} + +const TEMPLATE_EXAMPLES: Actions = Actions { + actions: &[ + Action::Example(Example { + comment: "\ +Write a template configuration.", + command: &[ + "sq", "config", "template", + ], + }), + ] +}; +test_examples!(sq_config_template, TEMPLATE_EXAMPLES); diff --git a/src/cli/key/generate.rs b/src/cli/key/generate.rs index e035d9cb..461f8822 100644 --- a/src/cli/key/generate.rs +++ b/src/cli/key/generate.rs @@ -7,6 +7,7 @@ use openpgp::packet::UserID; use crate::cli::KEY_VALIDITY_DURATION; use crate::cli::KEY_VALIDITY_IN_YEARS; +use crate::cli::config; use crate::cli::types::ClapData; use crate::cli::types::EncryptPurpose; use crate::cli::types::Expiration; @@ -134,11 +135,17 @@ Canonical user IDs are of the form `Name (Comment) \ long = "cipher-suite", value_name = "CIPHER-SUITE", default_value_t = Default::default(), - help = "Select the cryptographic algorithms for the key", + help = config::augment_help( + "key.generate.cipher-suite", + "Select the cryptographic algorithms for the key"), value_enum, )] pub cipher_suite: CipherSuite, + /// Workaround for https://github.com/clap-rs/clap/issues/3846 + #[clap(skip)] + pub cipher_suite_source: Option, + #[clap( long = "new-password-file", value_name = "PASSWORD_FILE", diff --git a/src/cli/key/subkey/add.rs b/src/cli/key/subkey/add.rs index 6a6436b1..fc398744 100644 --- a/src/cli/key/subkey/add.rs +++ b/src/cli/key/subkey/add.rs @@ -9,6 +9,7 @@ use examples::Actions; use examples::Example; use examples::Setup; +use crate::cli::config; use crate::cli::key::CipherSuite; use crate::cli::types::CertDesignators; use crate::cli::types::ClapData; @@ -90,11 +91,17 @@ pub struct Command { long, value_name = "CIPHER-SUITE", default_value_t = CipherSuite::Cv25519, - help = "Select the cryptographic algorithms for the subkey", + help = config::augment_help( + "key.generate.cipher-suite", + "Select the cryptographic algorithms for the subkey"), value_enum, )] pub cipher_suite: CipherSuite, + /// Workaround for https://github.com/clap-rs/clap/issues/3846 + #[clap(skip)] + pub cipher_suite_source: Option, + #[command(flatten)] pub expiration: ExpirationArg, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c87d40ac..b5fd99a3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -93,6 +93,7 @@ use openpgp::Fingerprint; pub mod examples; pub mod cert; +pub mod config; pub mod decrypt; pub mod download; pub mod encrypt; @@ -565,5 +566,6 @@ pub enum SqSubcommands { Keyring(keyring::Command), Packet(packet::Command), + Config(config::Command), Version(version::Command), } diff --git a/src/cli/network/keyserver.rs b/src/cli/network/keyserver.rs index 2b70cd21..c2a116ef 100644 --- a/src/cli/network/keyserver.rs +++ b/src/cli/network/keyserver.rs @@ -1,5 +1,6 @@ use clap::{Args, Parser, Subcommand}; +use crate::cli::config; use crate::cli::examples::*; use crate::cli::types::ClapData; use crate::cli::types::FileOrCertStore; @@ -38,7 +39,9 @@ pub struct Command { // that they are sorted to the bottom. display_order = 800, value_name = "URI", - help = "Set the key server to use. Can be given multiple times.", + help = config::augment_help( + "network.keyserver.servers", + "Set a key server to use. Can be given multiple times."), )] pub servers: Vec, #[clap(subcommand)] diff --git a/src/cli/network/search.rs b/src/cli/network/search.rs index ab518b7a..fde4ee11 100644 --- a/src/cli/network/search.rs +++ b/src/cli/network/search.rs @@ -1,5 +1,6 @@ use clap::Parser; +use crate::cli::config; use crate::cli::examples::Action; use crate::cli::examples::Actions; use crate::cli::examples::Example; @@ -50,10 +51,16 @@ pub struct Command { long = "server", default_values_t = DEFAULT_KEYSERVERS.iter().map(ToString::to_string), value_name = "URI", - help = "Set the key server to use. Can be given multiple times.", + help = config::augment_help( + "network.keyserver.servers", + "Set a key server to use. Can be given multiple times."), )] pub servers: Vec, + /// Workaround for https://github.com/clap-rs/clap/issues/3846 + #[clap(skip)] + pub servers_source: Option, + #[clap( help = FileOrCertStore::HELP_OPTIONAL, long, diff --git a/src/commands.rs b/src/commands.rs index 6f867899..db03d093 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,5 +1,7 @@ use std::time::SystemTime; +use clap::ArgMatches; + use sequoia_openpgp as openpgp; use openpgp::cert::prelude::*; use openpgp::{Cert, Result}; @@ -16,6 +18,7 @@ use crate::cli::{SqCommand, SqSubcommands}; pub mod autocrypt; pub mod cert; +pub mod config; pub mod decrypt; pub mod download; pub mod encrypt; @@ -30,8 +33,9 @@ pub mod verify; pub mod version; /// Dispatches the top-level subcommand. -pub fn dispatch(sq: Sq, command: SqCommand) -> Result<()> +pub fn dispatch(sq: Sq, command: SqCommand, matches: &ArgMatches) -> Result<()> { + let matches = matches.subcommand().unwrap().1; match command.subcommand { SqSubcommands::Encrypt(command) => encrypt::dispatch(sq, command), @@ -50,18 +54,21 @@ pub fn dispatch(sq: Sq, command: SqCommand) -> Result<()> SqSubcommands::Cert(command) => cert::dispatch(sq, command), SqSubcommands::Key(command) => - key::dispatch(sq, command), + key::dispatch(sq, command, matches), SqSubcommands::Pki(command) => pki::dispatch(sq, command), SqSubcommands::Network(command) => - network::dispatch(sq, command), + network::dispatch(sq, command, matches), SqSubcommands::Keyring(command) => keyring::dispatch(sq, command), SqSubcommands::Packet(command) => packet::dispatch(sq, command), + SqSubcommands::Config(command) => + config::dispatch(sq, command), + SqSubcommands::Version(command) => version::dispatch(sq, command), } diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 00000000..f7b15105 --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,213 @@ +//! Configuration model inspection and manipulation. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; + +use toml_edit::{ + Item, + Value, +}; + +use crate::toml_edit_tree::{ + Error, + Node, + Path, + PathComponent, + TraversalError, +}; + +use crate::{ + Sq, + cli::config, + config::ConfigFile, +}; + +pub fn dispatch(sq: Sq, cmd: config::Command) + -> Result<()> +{ + match cmd.subcommand { + config::Subcommands::Get(c) => get(sq, c), + config::Subcommands::Template(c) => template(sq, c), + } +} + +/// Implements `sq config get`. +fn get(sq: Sq, cmd: config::get::Command) -> Result<()> { + let path = if let Some(name) = &cmd.name { + name.parse()? + } else { + Path::empty() + }; + + // We do two lookups, first in the configuration file, then in the + // default configuration, and collate the results. + let mut acc = Default::default(); + + // First, look in the configuration. + let config = sq.config_file.augment_with_policy(&sq.policy)?; + let r0 = Node::traverse(&*config.as_item() as _, &path) + .map_err(Into::into) + .and_then( + |node| collect(&mut acc, path.clone(), node, &|_, _| true)); + + // Then, look in the default configuration. But, we are careful + // to filter out anything overridden in the actual configuration. + let default = ConfigFile::default_config(sq.home.as_ref())?; + let r1 = Node::traverse(&*default.as_item() as _, &path) + .map_err(Into::into) + .and_then( + |node| collect(&mut acc, path, node, &|p, n| { + (n.as_atomic_value().is_some() || n.as_array().is_some()) + ^ config.as_item().traverse(p).is_ok() + })); + + // One of the lookups must be successful. + r0.or(r1)?; + + // Display sorted and deduplicated results. + for (k, v) in acc { + eprintln!("{} = {}", k, v); + } + + Ok(()) +} + +/// Collects all nodes below `node` at `path` matching `filter` into +/// `acc`. +pub fn collect(acc: &mut BTreeMap, + mut path: Path, node: &dyn Node, filter: &F) + -> Result +where + F: for<'a> Fn(&'a Path, &'a dyn Node) -> bool, +{ + if ! (filter)(&path, node) { + return Ok(path); + } + + if let Some(v) = node.as_atomic_value() { + acc.insert(path.to_string(), + v.clone().decorated("", "").to_string()); + } else { + for (k, v) in node.iter() { + path.push(k); + path = collect(acc, path, v, filter)?; + path.pop(); + } + } + Ok(path) +} + +/// Implements `sq config set`. +/// +/// XXX: Currently, we don't expose this due to problems with +/// toml-edit. Notably, toml-edit doesn't handle comments well, +/// attaching them as decor to nodes in the document tree. +/// Manipulating the document tree may delete or otherwise disturb the +/// comments in a way that badly distorts the semantics. +#[allow(dead_code)] +fn set(mut sq: Sq, cmd: config::set::Command) -> Result<()> { + let mut path: Path = cmd.name.parse()?; + if path.is_empty() { + return Err(anyhow::anyhow!("NAME must not be empty")); + }; + let last = path.pop().expect("path is not empty"); + + // XXX: Workaround for clap bug, see src/cli/config.rs. + if cmd.value.is_none() && ! cmd.delete { + return Err(anyhow::anyhow!("Either VALUE or --delete must be given")); + } + + let mut config = std::mem::take(&mut sq.config_file); + let doc = config.as_item_mut(); + if let Some(value) = &cmd.value { + let value: Value = value.parse().unwrap_or_else(|_| value.into()); + + // Like Node::traverse_mut, but we also create intermediate + // nodes on demand. + let mut node: &mut dyn Node = doc as _; + + for (i, pc) in path.iter().cloned().enumerate() { + let type_name = node.type_name(); + if let Err(TraversalError::KeyNotFound(_, _)) = + node.get_mut(&pc) + .map_err(|e| e.with_context(&path, i, type_name)) + { + match path.get(i + 1).unwrap_or(&last) { + PathComponent::Symbol(_) => { + // Prefer to insert a non-inline table if + // possible. + if let Some(t) = node.as_table_mut() { + t.insert(pc.as_symbol()?, + Item::Table(Default::default())); + } else { + node.set(&pc, + Value::InlineTable(Default::default()))?; + } + }, + PathComponent::Index(_) => + node.set(&pc, Value::Array(Default::default()))?, + }; + } + + node = node.get_mut(&pc) + .map_err(|e| e.with_context(&path, i, type_name))?; + } + + if cmd.add { + if let Err(Error::KeyNotFound(_)) = node.get_mut(&last) { + // The node doesn't exist, see if it exists in the + // default configuration. + let default = ConfigFile::default_config(sq.home.as_ref())?; + let v = Node::traverse(&*default.as_item() as _, &path).ok() + .and_then(|n| n.get(&last).ok()) + .and_then(|n| n.as_array()) + .map(|a| Value::Array(a.clone())) + .unwrap_or(Value::Array(Default::default())); + + node.set(&last, v)?; + } + + let type_name = node.type_name(); + node = node.get_mut(&last) + .map_err(|e| e.with_context(&path, path.len(), type_name))?; + + let type_name = node.type_name(); + path.push(last); + if let Some(a) = node.as_array_mut() { + a.push(value); + } else { + return Err(anyhow::anyhow!("Tried to add an element to {}, \ + but this is a {} not an array", + path, type_name)); + } + } else { + node.set(&last, value)?; + } + } else { + assert!(cmd.delete); + let node = Node::traverse_mut(&mut *doc as _, &path)?; + node.remove(&last)?; + } + + // Verify the configuration. + config.verify() + .with_context(|| format!("Failed to {} {:?}", + if cmd.delete { "delete" } else { "set" }, + cmd.name))?; + + // The updated config verified, now persist it. + config.persist( + sq.home.as_ref().ok_or(anyhow::anyhow!("No home directory given"))?)?; + + Ok(()) +} + +/// Implements `sq config template`. +fn template(sq: Sq, cmd: config::template::Command) -> Result<()> { + let mut sink = cmd.output.create_safe(&sq)?; + ConfigFile::default_template(sq.home.as_ref())? + .dump(&mut sink)?; + + Ok(()) +} diff --git a/src/commands/key.rs b/src/commands/key.rs index 5bf5c242..dd3d5778 100644 --- a/src/commands/key.rs +++ b/src/commands/key.rs @@ -1,3 +1,5 @@ +use clap::ArgMatches; + use sequoia_openpgp as openpgp; use openpgp::Result; @@ -20,12 +22,17 @@ use revoke::certificate_revoke; mod subkey; pub mod userid; -pub fn dispatch(sq: Sq, command: cli::key::Command) -> Result<()> +pub fn dispatch(sq: Sq, command: cli::key::Command, matches: &ArgMatches) + -> Result<()> { + let matches = matches.subcommand().unwrap().1; use cli::key::Subcommands::*; match command.subcommand { List(c) => list(sq, c)?, - Generate(c) => generate(sq, c)?, + Generate(mut c) => { + c.cipher_suite_source = matches.value_source("cipher_suite"); + generate(sq, c)? + }, Import(c) => import(sq, c)?, Export(c) => export::dispatch(sq, c)?, Delete(c) => delete::dispatch(sq, c)?, @@ -33,7 +40,7 @@ pub fn dispatch(sq: Sq, command: cli::key::Command) -> Result<()> Expire(c) => expire::dispatch(sq, c)?, Userid(c) => userid::dispatch(sq, c)?, Revoke(c) => certificate_revoke(sq, c)?, - Subkey(c) => subkey::dispatch(sq, c)?, + Subkey(c) => subkey::dispatch(sq, c, matches)?, Approvals(c) => approvals::dispatch(sq, c)?, } Ok(()) diff --git a/src/commands/key/generate.rs b/src/commands/key/generate.rs index 1f82b991..f23d36bd 100644 --- a/src/commands/key/generate.rs +++ b/src/commands/key/generate.rs @@ -93,8 +93,8 @@ pub fn generate( // Cipher Suite builder = builder.set_cipher_suite( - command.cipher_suite.as_ciphersuite() - ); + sq.config.cipher_suite(&command.cipher_suite, + command.cipher_suite_source)); // Primary key capabilities. builder = builder.set_primary_key_flags( diff --git a/src/commands/key/subkey.rs b/src/commands/key/subkey.rs index a2adbc9d..759b3c1b 100644 --- a/src/commands/key/subkey.rs +++ b/src/commands/key/subkey.rs @@ -1,3 +1,7 @@ +//! Dispatches `sq key subkey`. + +use clap::ArgMatches; + use crate::Result; use crate::Sq; use crate::cli::key::subkey::Command; @@ -10,9 +14,13 @@ mod export; mod password; mod revoke; -pub fn dispatch(sq: Sq, command: Command) -> Result<()> { +pub fn dispatch(sq: Sq, command: Command, matches: &ArgMatches) -> Result<()> { + let matches = matches.subcommand().unwrap().1; match command { - Command::Add(c) => add::dispatch(sq, c)?, + Command::Add(mut c) => { + c.cipher_suite_source = matches.value_source("cipher_suite"); + add::dispatch(sq, c)? + }, Command::Export(c) => export::dispatch(sq, c)?, Command::Delete(c) => delete::dispatch(sq, c)?, Command::Password(c) => password::dispatch(sq, c)?, diff --git a/src/commands/key/subkey/add.rs b/src/commands/key/subkey/add.rs index d730ca4a..249572e7 100644 --- a/src/commands/key/subkey/add.rs +++ b/src/commands/key/subkey/add.rs @@ -67,7 +67,9 @@ pub fn dispatch(sq: Sq, command: Command) -> Result<()> let new_cert = KeyBuilder::new(keyflags) .set_creation_time(sq.time) - .set_cipher_suite(command.cipher_suite.as_ciphersuite()) + .set_cipher_suite( + sq.config.cipher_suite(&command.cipher_suite, + command.cipher_suite_source)) .set_password(password) .subkey(valid_cert)? .set_key_validity_period(validity)? diff --git a/src/commands/network.rs b/src/commands/network.rs index a0b2f772..11630b90 100644 --- a/src/commands/network.rs +++ b/src/commands/network.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Context; +use clap::ArgMatches; use indicatif::ProgressBar; use tokio::task::JoinSet; @@ -75,16 +76,19 @@ pub const CONNECT_TIMEOUT: Duration = Duration::new(5, 0); /// How long to wait for each individual http request. pub const REQUEST_TIMEOUT: Duration = Duration::new(5, 0); -pub fn dispatch(sq: Sq, c: cli::network::Command) +pub fn dispatch(sq: Sq, c: cli::network::Command, matches: &ArgMatches) -> Result<()> { + let matches = matches.subcommand().unwrap().1; use cli::network::Subcommands; match c.subcommand { - Subcommands::Search(command) => - dispatch_search(sq, command), + Subcommands::Search(mut command) => { + command.servers_source = matches.value_source("servers"); + dispatch_search(sq, command) + }, Subcommands::Keyserver(command) => - dispatch_keyserver(sq, command), + dispatch_keyserver(sq, command, matches), Subcommands::Wkd(command) => dispatch_wkd(sq, command), @@ -888,12 +892,15 @@ pub fn dispatch_search(mut sq: Sq, c: cli::network::search::Command) sq.cert_store_or_else()?; } - let default_servers = default_keyservers_p(&c.servers); + let default_servers = + matches!(c.servers_source.unwrap(), + clap::parser::ValueSource::DefaultValue); + let http_client = http_client()?; - let servers = c.servers.iter().map( - |uri| KeyServer::with_client(uri, http_client.clone()) - .with_context(|| format!("Malformed keyserver URI: {}", uri)) - .map(Arc::new)) + let servers = sq.config.key_servers(&c.servers, c.servers_source) + .map(|uri| KeyServer::with_client(uri, http_client.clone()) + .with_context(|| format!("Malformed keyserver URI: {}", uri)) + .map(Arc::new)) .collect::>>()?; let mut seen_emails = HashSet::new(); @@ -1054,30 +1061,22 @@ pub fn dispatch_search(mut sq: Sq, c: cli::network::search::Command) Ok(()) } -/// Figures out whether the given set of key servers is the default -/// set. -fn default_keyservers_p(servers: &[String]) -> bool { - // XXX: This could be nicer, maybe with a custom clap parser - // that encodes it in the type. For now we live with the - // false positive if someone explicitly provides the same set - // of servers. - use crate::cli::network::keyserver::DEFAULT_KEYSERVERS; - servers.len() == DEFAULT_KEYSERVERS.len() - && servers.iter().zip(DEFAULT_KEYSERVERS.iter()) - .all(|(a, b)| a == b) -} - -pub fn dispatch_keyserver(mut sq: Sq, - c: cli::network::keyserver::Command) - -> Result<()> +pub fn dispatch_keyserver( + mut sq: Sq, + c: cli::network::keyserver::Command, + matches: &ArgMatches, +) -> Result<()> { make_qprintln!(sq.quiet); - let default_servers = default_keyservers_p(&c.servers); - let servers = c.servers.iter().map( - |uri| KeyServer::with_client(uri, http_client()?) - .with_context(|| format!("Malformed keyserver URI: {}", uri)) - .map(Arc::new)) + let servers_source = matches.value_source("servers").unwrap(); + let default_servers = + matches!(servers_source, clap::parser::ValueSource::DefaultValue); + + let servers = sq.config.key_servers(&c.servers, Some(servers_source)) + .map(|uri| KeyServer::with_client(uri, http_client()?) + .with_context(|| format!("Malformed keyserver URI: {}", uri)) + .map(Arc::new)) .collect::>>()?; let rt = tokio::runtime::Runtime::new()?; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..4a0f803f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,697 @@ +//! Configuration model and file parsing. + +use std::{ + collections::HashSet, + fs, + io, + path::PathBuf, + time::SystemTime, +}; + +use aho_corasick::AhoCorasick; +use anyhow::Context; +use clap::{ValueEnum, parser::ValueSource}; + +use toml_edit::{ + DocumentMut, + Item, + Table, + Value, +}; + +use sequoia_openpgp::policy::StandardPolicy; +use sequoia_net::reqwest::Url; +use sequoia_directories::{Component, Home}; +use sequoia_policy_config::ConfiguredStandardPolicy; + +use crate::{ + Result, + cli, + cli::config::Augmentations, +}; + +/// Represents configuration at runtime. +/// +/// This struct is manipulated when parsing the configuration file. +/// It is available as `Sq::config`, with suitable accessors that +/// handle the precedence of the various sources. +pub struct Config { + policy_path: Option, + policy_inline: Option>, + cipher_suite: Option, + key_servers: Option>, +} + +impl Default for Config { + fn default() -> Self { + Config { + policy_path: None, + policy_inline: None, + cipher_suite: None, + key_servers: None, + } + } +} + +impl Config { + /// Returns the cryptographic policy. + /// + /// We read in the default policy configuration, the configuration + /// referenced in the configuration file, and the inline policy. + pub fn policy(&mut self, at: SystemTime) + -> Result> + { + let mut policy = ConfiguredStandardPolicy::at(at); + + policy.parse_default_config()?; + + if let Some(p) = &self.policy_path { + if ! policy.parse_config_file(p)? { + return Err(anyhow::anyhow!( + "referenced policy file {:?} does not exist", p)); + } + } + + if let Some(p) = &self.policy_inline { + policy.parse_bytes(p)?; + } + + Ok(policy.build()) + } + + /// Returns the cipher suite for generating new keys. + /// + /// Handles the precedence of the various sources: + /// + /// - If the flag is given, use the given value. + /// - If the command line flag is not given, then + /// - use the value from the configuration file (if any), + /// - or use the default value. + pub fn cipher_suite(&self, cli: &cli::key::CipherSuite, + source: Option) + -> sequoia_openpgp::cert::CipherSuite + { + match source.expect("set by the cli parser") { + ValueSource::DefaultValue => + self.cipher_suite.unwrap_or_else( + || cli.as_ciphersuite()), + _ => cli.as_ciphersuite(), + } + } + + /// Returns the key servers to query or publish. + /// + /// Handles the precedence of the various sources: + /// + /// - If the flag is given, use the given value. + /// - If the command line flag is not given, then + /// - use the value from the configuration file (if any), + /// - or use the default value. + pub fn key_servers<'s>(&'s self, cli: &'s Vec, + source: Option) + -> impl Iterator + 's + { + match source.expect("set by the cli parser") { + ValueSource::DefaultValue => + self.key_servers.as_ref() + .map(|s| Box::new(s.iter().map(|s| s.as_str())) + as Box>) + .unwrap_or_else(|| Box::new(cli.iter().map(|s| s.as_str())) + as Box>), + _ => Box::new(cli.iter().map(|s| s.as_str())) + as Box>, + } + } +} + +/// Holds the document tree of the configuration file. +#[derive(Debug, Default)] +pub struct ConfigFile { + doc: DocumentMut, +} + +impl ConfigFile { + /// A template for the configuration containing the default + /// values. + const TEMPLATE: &'static str = "\ +# Configuration template for sq + + +[key.generate] +#cipher-suite = + +[network] +#keyservers = + +[policy] +#path = + +# The policy can be inlined, either alternatively, or additionally, +# like so: + + +"; + + /// Patterns to match on in `Self::DEFAULT` to be replaced with + /// the default values. + const TEMPLATE_PATTERNS: &'static [&'static str] = &[ + "", + "", + "", + "", + "", + "", + ]; + + /// Returns a configuration template with the defaults. + fn config_template(path: Option) -> Result { + let ac = AhoCorasick::new(Self::TEMPLATE_PATTERNS)?; + + let mut p = ConfiguredStandardPolicy::new(); + p.parse_default_config()?; + + let mut default_policy_inline = Vec::new(); + p.dump(&mut default_policy_inline, + sequoia_policy_config::DumpDefault::Template)?; + let default_policy_inline = + regex::Regex::new(r"(?m)^\[")?.replace_all( + std::str::from_utf8(&default_policy_inline)?, "[policy."); + + Ok(ac.replace_all(Self::TEMPLATE, &[ + &env!("CARGO_PKG_VERSION").to_string(), + &if let Some(path) = path { + format!( + "\n\ + # To use it, edit it to your liking and write it to\n\ + # {}", + &path.display()) + } else { + "".into() + }, + &format!("{:?}", cli::key::CipherSuite::default(). + to_possible_value().unwrap().get_name()), + &format!("{:?}", cli::network::keyserver::DEFAULT_KEYSERVERS), + &format!("{:?}", ConfiguredStandardPolicy::CONFIG_FILE), + &default_policy_inline.to_string(), + ])) + } + + /// Returns the default configuration in template form. + /// + /// All the configuration options with their defaults are + /// commented out. + pub fn default_template(home: Option<&Home>) -> Result { + let template = Self::config_template(home.map(Self::file_name))?; + let doc: DocumentMut = template.parse() + .context("Parsing default configuration failed")?; + Ok(Self { + doc, + }) + } + + /// Returns the default configuration. + pub fn default_config(home: Option<&Home>) -> Result { + let template = Self::config_template(home.map(Self::file_name))?; + + // Enable all defaults by commenting-in. + let r = regex::Regex::new(r"(?m)^#([^ ])")?; + let defaults = r.replace_all(&template, "$1"); + + let doc: DocumentMut = defaults.parse() + .context("Parsing default configuration failed")?; + Ok(Self { + doc, + }) + } + + /// Returns the path of the config file. + pub fn file_name(home: &Home) -> PathBuf { + home.config_dir(Component::Sq).join("config.toml") + } + + /// Reads and validates the configuration file. + pub fn read(&mut self, home: &Home) + -> Result + { + let mut config = Config::default(); + self.read_internal(home, Some(&mut config), None)?; + Ok(config) + } + + /// Reads and validates the configuration file. + pub fn read_and_augment(&mut self, home: &Home) -> Result + { + let mut augmentations = Augmentations::default(); + self.read_internal(home, None, Some(&mut augmentations))?; + Ok(augmentations) + } + + /// Reads and validates the configuration file, and optionally + /// applies them to the given configuration, and optionally + /// supplies augmentations for the help texts in the command line + /// parser. + fn read_internal(&mut self, home: &Home, mut config: Option<&mut Config>, + mut cli: Option<&mut Augmentations>) + -> Result<()> + { + let path = Self::file_name(home); + let raw = match fs::read_to_string(&path) { + Ok(r) => r, + Err(e) if e.kind() == io::ErrorKind::NotFound => + Self::config_template(Some(path.clone()))?, + Err(e) => return Err(anyhow::Error::from(e).context( + format!("Reading configuration file {} failed", + path.display()))), + }; + + let doc: DocumentMut = raw.parse() + .with_context(|| format!("Parsing configuration file {} failed", + path.display()))?; + + apply_schema(&mut config, &mut cli, None, doc.iter(), TOP_LEVEL_SCHEMA) + .with_context(|| format!("Parsing configuration file {} failed", + path.display()))?; + self.doc = doc; + + Ok(()) + } + + /// Writes the configuration to the disk. + pub fn persist(&self, home: &Home) -> Result<()> { + let path = Self::file_name(home); + let dir = path.parent().unwrap(); + + fs::create_dir_all(dir)?; + + let mut t = + tempfile::NamedTempFile::new_in(dir)?; + self.dump(&mut t)?; + t.persist(path)?; + + Ok(()) + } + + /// Writes the configuration to the given writer. + pub fn dump(&self, sink: &mut dyn io::Write) -> Result<()> { + write!(sink, "{}", self.doc.to_string())?; + Ok(()) + } + + /// Verifies the configuration. + pub fn verify(&self) -> Result<()> { + let mut config = Default::default(); + apply_schema(&mut Some(&mut config), &mut None, None, self.doc.iter(), + TOP_LEVEL_SCHEMA)?; + config.policy(SystemTime::now())?; + Ok(()) + } + + /// Augments the configuration with the given policy. + /// + /// XXX: Due to the way doc.remove works, it will leave misleading + /// comments behind. Therefore, the resulting configuration is + /// not suitable for dumping, but may only be used for + /// commands::config::get. + pub fn augment_with_policy(&self, p: &StandardPolicy) -> Result { + use std::io::Write; + let mut raw = Vec::new(); + + // First, start with our configuration, and drop most of the + // policy with the exception of the path. + let p = ConfiguredStandardPolicy::from_policy(p.clone()); + let mut doc = self.doc.clone(); + doc.remove("policy"); + + use crate::toml_edit_tree::Node; + let policy_path: crate::toml_edit_tree::Path + = "policy.path".parse().unwrap(); + if let Ok(p) = self.as_item().traverse(&policy_path) { + let p = + p.as_atomic_value().unwrap().as_str().unwrap().to_string(); + doc.as_table_mut().insert("policy", Item::Table( + [("path", Value::from(p))] + .into_iter().collect())); + } + + write!(&mut raw, "{}", doc.to_string())?; + + // Then, augment the configuration with the effective policy. + let mut default_policy_inline = Vec::new(); + p.dump(&mut default_policy_inline, + sequoia_policy_config::DumpDefault::Template)?; + let default_policy_inline = + regex::Regex::new(r"(?m)^\[")?.replace_all( + std::str::from_utf8(&default_policy_inline)?, "[policy."); + + write!(&mut raw, "{}", default_policy_inline)?; + + // Now, parse the resulting configuration. + let doc: DocumentMut = std::str::from_utf8(&raw)?.parse()?; + + // Double check that it is well-formed. + apply_schema(&mut None, &mut None, None, doc.iter(), TOP_LEVEL_SCHEMA)?; + + Ok(Self { + doc, + }) + } + + /// Returns the document tree. + pub fn as_item(&self) -> &Item { + self.doc.as_item() + } + + /// Returns the mutable document tree. + pub fn as_item_mut(&mut self) -> &mut Item { + self.doc.as_item_mut() + } +} + +/// Validates a configuration section using a schema, and optionally +/// applies changes to the configuration and CLI augmentations. +/// +/// Returns an error if a key is unknown. +/// +/// known_keys better be lowercase. +fn apply_schema<'toml>(config: &mut Option<&mut Config>, + cli: &mut Option<&mut Augmentations>, + path: Option<&str>, + section: toml_edit::Iter<'toml>, + schema: Schema) -> Result<()> { + let section = section.collect::>(); + let known_keys: Vec<_> = + schema.iter().map(|(key, _)| *key).collect(); + + // Schema keys better be lowercase. + debug_assert!(known_keys.iter().all(|&s| &s.to_lowercase() == s), + "keys in schema must be lowercase"); + + // Schema keys better be sorted. + debug_assert!(known_keys.windows(2).all(|v| v[0] <= v[1]), + "keys in schema must be sorted"); + // XXX: once [].is_sorted is stabilized: + // debug_assert!(known_keys.is_sorted(), "keys in schema must be sorted"); + + let prefix = if let Some(path) = path { + format!("{}.", path) + } else { + "".to_string() + }; + + let keys: HashSet<&str> = section + .iter().map(|(key, _value)| *key) + .collect(); + + // The set of allowed keys are the known keys, plus + // "ignore_invalid", and the value of "ignore_invalid". + let mut allowed_keys: Vec<&str> = known_keys.to_vec(); + if let Some(ignore) = section.iter() + .find_map(|(k, v)| (*k == "ignore_invalid").then_some(*v)) + { + allowed_keys.push("ignore_invalid"); + match ignore { + Item::Value(Value::String(k)) => + allowed_keys.push(k.value().as_str()), + Item::Value(Value::Array(ks)) => { + for k in ks { + if let Value::String(k) = k { + allowed_keys.push(k.value().as_str()); + } else { + Err(Error::ParseError(format!( + "'{}ignore_invalid' takes a string \ + or an array of strings", + prefix)))? + } + } + } + _ => { + return Err(Error::ParseError(format!( + "Invalid value for '{}ignore_invalid': {}, \ + expected a string or an array of strings", + prefix, ignore)).into()); + } + } + } + + // Now check if there are any unknown sections. + let unknown_keys = keys + .difference(&allowed_keys.into_iter().collect()) + .map(|s| *s) + .collect::>(); + if ! unknown_keys.is_empty() { + return Err(Error::ParseError(format!( + "{} has unknown keys: {}, valid keys are: {}", + if let Some(path) = path { + path + } else { + "top-level section" + }, + unknown_keys.join(", "), + // We don't include the keys listed in ignore_invalid. + known_keys.join(", "))).into()); + } + + // Now validate the values. + for (key, value) in §ion { + if let Ok(i) = schema.binary_search_by_key(key, |(k, _)| k) { + let apply = schema[i].1; + (apply)(config, cli, &format!("{}{}", prefix, key), value) + .with_context(|| format!("Error validating {:?}", key))?; + } + } + + Ok(()) +} + +/// Errors used in this module. +/// +/// Note: This enum cannot be exhaustively matched to allow future +/// extensions. +#[non_exhaustive] +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// Parse error + #[error("Parse error: {0}")] + ParseError(String), + + /// A Relative Path was provided where an absolute path was expected. + #[error("Relative path not allowed: {0}")] + RelativePathError(PathBuf), + + /// An algorithm is not known to this crate. + #[error("Unknown algorithm: {0}")] + UnknownAlgorithm(String), + + #[error("Configuration item {0:?} is not a {1} but a {2}")] + BadType(String, &'static str, &'static str), +} + +impl Error { + /// Returns an `Error::BadType` given an item. + fn bad_item_type(path: &str, i: &Item, want_type: &'static str) + -> anyhow::Error + { + Error::BadType(path.into(), want_type, i.type_name()).into() + } + + /// Returns an `Error::BadType` given a value. + fn bad_value_type(path: &str, v: &Value, want_type: &'static str) + -> anyhow::Error + { + Error::BadType(path.into(), want_type, v.type_name()).into() + } +} + +/// A function that validates a node in the configuration tree with +/// the given path, and optionally makes changes to the configuration +/// and CLI augmentations. +type Applicator = fn(&mut Option<&mut Config>, &mut Option<&mut Augmentations>, + &str, &Item) + -> Result<()>; + +/// Ignores a node. +fn apply_nop(_: &mut Option<&mut Config>, _: &mut Option<&mut Augmentations>, + _: &str, _: &Item) + -> Result<()> +{ + Ok(()) +} + +/// A [`Schema`] maps keys to [`Applicator`]s. +type Schema = &'static [(&'static str, Applicator)]; + +/// Schema for the toplevel. +const TOP_LEVEL_SCHEMA: Schema = &[ + ("key", apply_key), + ("network", apply_network), + ("policy", apply_policy), +]; + +/// Schema for the `key` section. +const KEY_SCHEMA: Schema = &[ + ("generate", apply_key_generate), +]; + +/// Validates the `key` section. +fn apply_key(config: &mut Option<&mut Config>, cli: &mut Option<&mut Augmentations>, + path: &str, item: &Item) + -> Result<()> +{ + let section = item.as_table_like() + .ok_or_else(|| Error::bad_item_type(path, item, "table"))?; + apply_schema(config, cli, Some(path), section.iter(), KEY_SCHEMA)?; + Ok(()) +} + +/// Schema for the `key.generate` section. +const KEY_GENERATE_SCHEMA: Schema = &[ + ("cipher-suite", apply_key_generate_cipher_suite), +]; + +/// Validates the `key.generate` section. +fn apply_key_generate(config: &mut Option<&mut Config>, + cli: &mut Option<&mut Augmentations>, + path: &str, item: &Item) + -> Result<()> +{ + let section = item.as_table_like() + .ok_or_else(|| Error::bad_item_type(path, item, "table"))?; + apply_schema(config, cli, Some(path), section.iter(), KEY_GENERATE_SCHEMA)?; + Ok(()) +} + +/// Validates the `key.generate.cipher-suite` value. +fn apply_key_generate_cipher_suite(config: &mut Option<&mut Config>, + cli: &mut Option<&mut Augmentations>, + path: &str, item: &Item) + -> Result<()> +{ + let s = item.as_str() + .ok_or_else(|| Error::bad_item_type(path, item, "string"))?; + let v = cli::key::CipherSuite::from_str(s, true) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + if let Some(config) = config { + config.cipher_suite = Some(v.as_ciphersuite()); + } + + if let Some(cli) = cli { + cli.insert( + "key.generate.cipher-suite", + v.to_possible_value().expect("just validated").get_name().into()); + } + + Ok(()) +} + +/// Schema for the `network` section. +const NETWORK_SCHEMA: Schema = &[ + ("keyservers", apply_network_keyservers), +]; + +/// Validates the `network` section. +fn apply_network(config: &mut Option<&mut Config>, cli: &mut Option<&mut Augmentations>, + path: &str, item: &Item) + -> Result<()> +{ + let section = item.as_table_like() + .ok_or_else(|| Error::bad_item_type(path, item, "table"))?; + apply_schema(config, cli, Some(path), section.iter(), NETWORK_SCHEMA)?; + Ok(()) +} + +/// Validates the `network.keyservers` value. +fn apply_network_keyservers(config: &mut Option<&mut Config>, + cli: &mut Option<&mut Augmentations>, + path: &str, item: &Item) + -> Result<()> +{ + let list = item.as_array() + .ok_or_else(|| Error::bad_item_type(path, item, "array"))?; + + let mut servers_str = Vec::new(); + let mut servers_url = Vec::new(); + for (i, server) in list.iter().enumerate() { + let server_str = server.as_str() + .ok_or_else(|| Error::bad_value_type(&format!("{}.{}", path, i), + server, "string"))?; + + let url = Url::parse(server_str)?; + let s = url.scheme(); + match s { + "hkp" => (), + "hkps" => (), + _ => return Err(anyhow::anyhow!( + "must be a hkp:// or hkps:// URL: {}", url)), + } + + servers_str.push(server_str); + servers_url.push(url); + } + + if let Some(cli) = cli { + cli.insert("network.keyserver.servers", servers_str.join(" ")); + } + + if let Some(config) = config { + config.key_servers = Some(servers_url); + } + + Ok(()) +} + +/// Schema for the `policy` section. +const POLICY_SCHEMA: Schema = &[ + ("aead_algorithms", apply_nop), + ("asymmetric_algorithms", apply_nop), + ("hash_algorithms", apply_nop), + ("packets", apply_nop), + ("path", apply_policy_path), + ("symmetric_algorithms", apply_nop), +]; + +/// Validates the `policy` section. +fn apply_policy(config: &mut Option<&mut Config>, + cli: &mut Option<&mut Augmentations>, + path: &str, item: &Item) + -> Result<()> +{ + let section = item.as_table_like() + .ok_or_else(|| Error::bad_item_type(path, item, "table"))?; + apply_schema(config, cli, Some(path), section.iter(), POLICY_SCHEMA)?; + + if let Some(config) = config { + // Extract the inline policy. + + // XXX: This doesn't work because toml_edit bug + // https://github.com/toml-rs/toml/issues/785 + // + //let table = section.iter().collect::(); + // + // Instead, we have to use a workaround: + let mut table = Table::new(); + section.iter().for_each(|(k, v)| { table.insert(k, v.clone()); }); + + let mut inline = DocumentMut::from(table); + inline.remove("path"); + config.policy_inline = Some(inline.to_string().into_bytes()); + } + + Ok(()) +} + +/// Validates the `policy.path` value. +fn apply_policy_path(config: &mut Option<&mut Config>, + _: &mut Option<&mut Augmentations>, + path: &str, item: &Item) + -> Result<()> +{ + let path = item.as_str() + .ok_or_else(|| Error::bad_item_type(path, item, "string"))?; + + if let Some(config) = config { + config.policy_path = Some(path.into()); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 23d78ff1..aa35e5c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,10 @@ use openpgp::cert::prelude::*; use clap::FromArgMatches; +// XXX: This could be its own crate, or preferably integrated into +// toml_edit. +mod toml_edit_tree; + #[macro_use] mod macros; #[macro_use] mod log; @@ -44,6 +48,7 @@ use cli::types::Version; use cli::types::paths::StateDirectory; mod commands; +pub mod config; pub mod output; pub use output::Model; @@ -195,9 +200,13 @@ fn real_main() -> Result<()> { let matches = match matches { Ok(matches) => matches, - Err(err) => { + Err(mut err) => { // Warning: hack ahead! // + // We want to hide global options in the help output for + // subcommands, and we want to include values from the + // configuration file in the help output. + // // If we are showing the help output, we only want to // display the global options at the top-level; for // subcommands we hide the global options to not overwhelm @@ -219,12 +228,24 @@ fn real_main() -> Result<()> { if err.kind() == ErrorKind::DisplayHelp || err.kind() == ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand { + // We want to try to parse the configuration file. To + // that end, we first need to find the path to it. + let mut config = config::ConfigFile::default(); + if let Some(augmentations) = cli::config::find_home().and_then( + |home| config.read_and_augment(&home).ok()) + { + cli::config::set_augmentations(augmentations); + } + let output = err.render(); let output = if output == cli.render_long_help() { Some(cli::build(false).render_long_help()) } else if output == cli.render_help() { Some(cli::build(false).render_help()) } else { + // Redo the parse so that the help message will + // include any augmentations. + err = cli::build(true).try_get_matches().unwrap_err(); None }; @@ -257,6 +278,32 @@ fn real_main() -> Result<()> { }; let c = cli::SqCommand::from_arg_matches(&matches)?; + let home = match &c.home { + Some(StateDirectory::Absolute(p)) => + Some(sequoia_directories::Home::new(p.clone())?), + None | Some(StateDirectory::Default) => + Some(sequoia_directories::Home::default() + .ok_or(anyhow::anyhow!("no default SEQUOIA_HOME \ + on this platform"))? + .clone()), + Some(StateDirectory::None) => None, + }; + + // Parse the configuration file. + let mut config_file = config::ConfigFile::default_config(home.as_ref())?; + let mut config = if let Some(home) = &home { + // Sanity check `cli::config::find_home`. + debug_assert_eq!(home.location(), + cli::config::find_home().unwrap().location()); + + config_file.read(home) + .with_context(|| format!( + "while reading configuration file {}", + config::ConfigFile::file_name(home).display()))? + } else { + Default::default() + }; + let time_is_now = c.time.is_none(); let time: SystemTime = if let Some(t) = c.time.as_ref() { t.to_system_time(std::time::SystemTime::now())? @@ -270,10 +317,7 @@ fn real_main() -> Result<()> { time.clone() }; - let mut policy - = sequoia_policy_config::ConfiguredStandardPolicy::at(policy_as_of); - policy.parse_default_config()?; - let mut policy = policy.build(); + let mut policy = config.policy(policy_as_of)?; let known_notations_store = c.known_notation.clone(); let known_notations = known_notations_store @@ -293,6 +337,8 @@ fn real_main() -> Result<()> { #[allow(deprecated)] let sq = Sq { + config_file, + config, verbose: c.verbose, quiet: c.quiet, overwrite: c.overwrite, @@ -301,16 +347,7 @@ fn real_main() -> Result<()> { time_is_now, policy_as_of, policy: &policy, - home: match &c.home { - Some(StateDirectory::Absolute(p)) => - Some(sequoia_directories::Home::new(p.clone())?), - None | Some(StateDirectory::Default) => - Some(sequoia_directories::Home::default() - .ok_or(anyhow::anyhow!("no default SEQUOIA_HOME \ - on this platform"))? - .clone()), - Some(StateDirectory::None) => None, - }, + home, cert_store_path: c.cert_store.clone(), keyrings: c.keyring.clone(), keyring_tsks: Default::default(), @@ -322,7 +359,7 @@ fn real_main() -> Result<()> { password_cache: password_cache.into(), }; - match commands::dispatch(sq, c) { + match commands::dispatch(sq, c, &matches) { Ok(()) => Ok(()), Err(err) => { use clap::error::ErrorFormatter; diff --git a/src/sq.rs b/src/sq.rs index c1b76286..412a581e 100644 --- a/src/sq.rs +++ b/src/sq.rs @@ -92,6 +92,9 @@ type WotStore<'store, 'rstore> pub struct Sq<'store, 'rstore> where 'store: 'rstore { + pub config_file: crate::config::ConfigFile, + pub config: crate::config::Config, + pub verbose: bool, pub quiet: bool, diff --git a/src/toml_edit_tree.rs b/src/toml_edit_tree.rs new file mode 100644 index 00000000..3bc64b09 --- /dev/null +++ b/src/toml_edit_tree.rs @@ -0,0 +1,638 @@ +//! Provides a homogeneous interface to toml_edit's various types, and +//! a traversal interface. +//! +//! toml_edit provides an inhomogeneous interface through `Item`, +//! `Table`, `Value`, `Array`, and various others, which makes +//! traversing the document tree and working with the nodes very +//! difficult. +//! +//! This module introduces a trait providing a homogeneous abstraction +//! over the various types. Then, it provides a convenient traversal +//! interface. + +use std::{ + borrow::Cow, + fmt, +}; + +use toml_edit::{ + Array, + Item, + Table, + Value, +}; + +/// Represents a path in a document tree. +#[derive(Debug, Clone)] +pub struct Path { + components: Vec, +} + +impl fmt::Display for Path { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, c) in self.components.iter().enumerate() { + if i > 0 { + f.write_str(".")?; + } + + write!(f, "{}", c)?; + } + + Ok(()) + } +} + +impl std::str::FromStr for Path { + type Err = PathError; + + fn from_str(s: &str) -> PathResult { + Ok(Path { + components: s.split(".").map(PathComponent::from_str) + .collect::>>()?, + }) + } +} + +impl Path { + /// Returns the number of path components. + pub fn len(&self) -> usize { + self.components.len() + } + + /// Returns whether the path is empty. + pub fn is_empty(&self) -> bool { + self.components.is_empty() + } + + /// Returns an empty path. + pub fn empty() -> Path { + Path { + components: Vec::new(), + } + } + + /// Returns the `idx`th path component, if any. + pub fn get(&self, idx: usize) -> Option<&PathComponent> { + self.components.get(idx) + } + + /// Iterates over the path's components. + pub fn iter(&self) -> impl Iterator { + self.components.iter() + } + + /// Appends a path component. + pub fn push(&mut self, component: PathComponent) { + self.components.push(component); + } + + /// Removes and returns the last path component, if any. + pub fn pop(&mut self) -> Option { + self.components.pop() + } +} + +/// A path component. +#[derive(Debug, Clone)] +pub enum PathComponent { + /// A key name in a map. + Symbol(String), + + /// An index into an array. + Index(usize), +} + +impl fmt::Display for PathComponent { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + PathComponent::Symbol(s) => write!(f, "{}", s), + PathComponent::Index(i) => write!(f, "{}", i), + } + } +} + +impl From for PathComponent { + fn from(v: usize) -> Self { + PathComponent::Index(v) + } +} + +impl From<&str> for PathComponent { + fn from(v: &str) -> Self { + PathComponent::Symbol(v.into()) + } +} + +impl std::str::FromStr for PathComponent { + type Err = PathError; + + fn from_str(s: &str) -> PathResult { + if s.contains('.') { + return Err(PathError::InvalidPathComponent( + "must not contain a period (\".\")")); + } + + if let Ok(i) = s.parse::() { + Ok(PathComponent::Index(i)) + } else { + Ok(PathComponent::Symbol(s.into())) + } + } +} + +impl PathComponent { + /// Returns an symbolic key, or returns an error. + pub fn as_symbol(&self) -> Result<&str> { + match self { + PathComponent::Symbol(s) => Ok(s), + PathComponent::Index(i) => Err(Error::BadSymbol(*i)), + } + } + + /// Returns an index, or returns an error. + pub fn as_index(&self) -> Result { + match self { + PathComponent::Symbol(s) => Err(Error::BadIndex(s.to_string())), + PathComponent::Index(i) => Ok(*i), + } + } +} + +/// Result specialization for conversions to path components. +pub type PathResult = std::result::Result; + +/// Errors converting to path components. +#[derive(thiserror::Error, Debug)] +pub enum PathError { + #[error("invalid path component: {0}")] + InvalidPathComponent(&'static str), +} + +/// A unified interface to `toml_edit`. +pub trait Node: fmt::Debug { + /// Returns the node's type name. + fn type_name(&self) -> &'static str; + + /// Returns the node as array, if possible. + fn as_array(&self) -> Option<&Array>; + + /// Returns the node as mutable array, if possible. + fn as_array_mut(&mut self) -> Option<&mut Array>; + + /// Returns the node as atomic value, if possible. + fn as_atomic_value(&self) -> Option<&Value>; + + /// Returns the node as mutable table, if possible. + fn as_table_mut(&mut self) -> Option<&mut Table>; + + /// Returns a reference to the child denoted by `key`. + fn get(&self, key: &PathComponent) -> Result<&dyn Node>; + + /// Returns a mutable reference to the child denoted by `key`. + fn get_mut(&mut self, key: &PathComponent) -> Result<&mut dyn Node>; + + /// Sets the child denoted by `key` to the given value. + fn set(&mut self, key: &PathComponent, value: Value) -> Result<()>; + + /// Removes the child denoted by `key`. + fn remove(&mut self, key: &PathComponent) -> Result<()>; + + /// Iterates over the children of this node. + fn iter<'i>(&'i self) -> Box<(dyn Iterator + 'i)>; + + /// Returns a reference to the node that is reached by traversing + /// `path` from the current node. + fn traverse(&self, path: &Path) -> TraversalResult<&dyn Node> + where + Self: Sized, + { + let mut node: &dyn Node = self as _; + for (i, pc) in path.iter().cloned().enumerate() { + let type_name = node.type_name(); + node = node.get(&pc) + .map_err(|e| e.with_context(path, i, type_name))?; + } + + Ok(node) + } + + /// Returns a mutable reference to the node that is reached by + /// traversing `path` from the current node. + fn traverse_mut(&mut self, path: &Path) -> TraversalResult<&mut dyn Node> + where + Self: Sized, + { + let mut node: &mut dyn Node = self as _; + for (i, pc) in path.iter().cloned().enumerate() { + let type_name = node.type_name(); + node = node.get_mut(&pc) + .map_err(|e| e.with_context(path, i, type_name))?; + } + + Ok(node) + } +} + +impl Node for Item { + fn type_name(&self) -> &'static str { + self.type_name() + } + + fn as_array(&self) -> Option<&Array> { + match self { + Item::Value(v) => v.as_array(), + | Item::None + | Item::Table(_) + | Item::ArrayOfTables(_) => None, + } + } + + fn as_array_mut(&mut self) -> Option<&mut Array> { + match self { + Item::Value(v) => v.as_array_mut(), + | Item::None + | Item::Table(_) + | Item::ArrayOfTables(_) => None, + } + } + + fn as_atomic_value(&self) -> Option<&Value> { + match self { + Item::Value(v) => v.as_atomic_value(), + | Item::None + | Item::Table(_) + | Item::ArrayOfTables(_) => None, + } + } + + fn as_table_mut(&mut self) -> Option<&mut Table> { + match self { + Item::Table(t) => Some(t), + | Item::None + | Item::Value(_) + | Item::ArrayOfTables(_) => None, + } + } + + fn get(&self, key: &PathComponent) -> Result<&dyn Node> { + match self { + Item::None => Err(Error::LookupError("none")), + Item::Value(v) => v.get(key), + Item::Table(t) => Node::get(t, key), + Item::ArrayOfTables(a) => { + let i = key.as_index()?; + Ok(a.get(i).ok_or_else(|| Error::OutOfBounds(i, a.len()))?) + }, + } + } + + fn get_mut(&mut self, key: &PathComponent) -> Result<&mut dyn Node> { + match self { + Item::None => Err(Error::LookupError("none")), + Item::Value(v) => v.get_mut(key), + Item::Table(t) => Node::get_mut(t, key), + Item::ArrayOfTables(a) => { + let i = key.as_index()?; + let l = a.len(); + Ok(a.get_mut(i).ok_or_else(|| Error::OutOfBounds(i, l))?) + }, + } + } + + fn set(&mut self, key: &PathComponent, value: Value) -> Result<()> { + match self { + Item::None => Err(Error::InsertionError("none")), + Item::Value(v) => v.set(key, value), + Item::Table(t) => { + t.insert(key.as_symbol()?, Item::Value(value)); + Ok(()) + }, + Item::ArrayOfTables(_) => { + // XXX: ArrayOfTabels::insert does not exist. + Err(Error::InsertionError(self.type_name())) + }, + } + } + + fn remove(&mut self, key: &PathComponent) -> Result<()> { + match self { + Item::None => Err(Error::RemovalError("none")), + Item::Value(v) => v.remove(key), + Item::Table(t) => { + let s = key.as_symbol()?; + t.remove(s).ok_or(Error::KeyNotFound(s.into()))?; + Ok(()) + }, + Item::ArrayOfTables(a) => { + let i = key.as_index()?; + let l = a.len(); + if i >= l { + return Err(Error::OutOfBounds(i, l)); + } + a.remove(i); + Ok(()) + }, + } + } + + fn iter<'i>(&'i self) -> Box<(dyn Iterator + 'i)> { + match self { + Item::None => Box::new(std::iter::empty()), + Item::Value(v) => v.iter(), + Item::Table(t) => Node::iter(t), + Item::ArrayOfTables(a) => + Box::new(a.iter().enumerate().map(|(k, v)| (k.into(), &*v as &dyn Node))), + } + } +} + +impl Node for Table { + fn type_name(&self) -> &'static str { + "table" + } + + fn as_array(&self) -> Option<&Array> { + None + } + + fn as_array_mut(&mut self) -> Option<&mut Array> { + None + } + + fn as_atomic_value(&self) -> Option<&Value> { + None + } + + fn as_table_mut(&mut self) -> Option<&mut Table> { + Some(self) + } + + fn get(&self, key: &PathComponent) -> Result<&dyn Node> { + let s = key.as_symbol()?; + Ok(self.get(s).ok_or_else(|| Error::KeyNotFound(s.into()))?) + } + + fn get_mut(&mut self, key: &PathComponent) -> Result<&mut dyn Node> { + let s = key.as_symbol()?; + Ok(self.get_mut(s).ok_or_else(|| Error::KeyNotFound(s.into()))?) + } + + fn set(&mut self, key: &PathComponent, value: Value) -> Result<()> { + let s = key.as_symbol()?; + self.insert(s, Item::Value(value)); + Ok(()) + } + + fn remove(&mut self, key: &PathComponent) -> Result<()> { + let s = key.as_symbol()?; + self.remove(s).ok_or(Error::KeyNotFound(s.into()))?; + Ok(()) + } + + fn iter<'i>(&'i self) -> Box<(dyn Iterator + 'i)> { + Box::new(self.iter().map(|(k, v)| (k.into(), &*v as &dyn Node))) + } +} + +impl Node for Value { + fn type_name(&self) -> &'static str { + self.type_name() + } + + fn as_array(&self) -> Option<&Array> { + match self { + Value::Array(a) => Some(a), + | Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) + | Value::InlineTable(_) => + None, + } + } + + fn as_array_mut(&mut self) -> Option<&mut Array> { + match self { + Value::Array(a) => Some(a), + | Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) + | Value::InlineTable(_) => + None, + } + } + + fn as_atomic_value(&self) -> Option<&Value> { + match self { + | Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) => + Some(self), + | Value::Array(_) + | Value::InlineTable(_) => + None, + } + } + + fn as_table_mut(&mut self) -> Option<&mut Table> { + None + } + + fn get(&self, key: &PathComponent) -> Result<&dyn Node> { + match self { + | Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) => + Err(Error::LookupError(self.type_name())), + Value::Array(a) => { + let i = key.as_index()?; + Ok(a.get(i).ok_or_else(|| Error::OutOfBounds(i, a.len()))?) + }, + Value::InlineTable(t) => { + let s = key.as_symbol()?; + Ok(t.get(s).ok_or_else(|| Error::KeyNotFound(s.into()))?) + }, + } + } + + fn get_mut(&mut self, key: &PathComponent) -> Result<&mut dyn Node> { + match self { + | Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) => + Err(Error::LookupError(self.type_name())), + Value::Array(a) => { + let i = key.as_index()?; + let l = a.len(); + Ok(a.get_mut(i).ok_or_else(|| Error::OutOfBounds(i, l))?) + }, + Value::InlineTable(t) => { + let s = key.as_symbol()?; + Ok(t.get_mut(s).ok_or_else(|| Error::KeyNotFound(s.into()))?) + }, + } + } + + fn set(&mut self, key: &PathComponent, value: Value) -> Result<()> { + match self { + | Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) => + Err(Error::InsertionError(self.type_name())), + Value::Array(a) => { + let i = key.as_index()?; + let l = a.len(); + if i >= l { + return Err(Error::OutOfBounds(i, l)); + } + a.replace(i, value); + Ok(()) + }, + Value::InlineTable(t) => { + t.insert(key.as_symbol()?, value); + Ok(()) + }, + } + } + + fn remove(&mut self, key: &PathComponent) -> Result<()> { + match self { + | Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) => + Err(Error::RemovalError(self.type_name())), + Value::Array(a) => { + let i = key.as_index()?; + let l = a.len(); + if i >= l { + return Err(Error::OutOfBounds(i, l)); + } + a.remove(i); + Ok(()) + }, + Value::InlineTable(t) => { + let s = key.as_symbol()?; + t.remove(s).ok_or(Error::KeyNotFound(s.into()))?; + Ok(()) + }, + } + } + + fn iter<'i>(&'i self) -> Box<(dyn Iterator + 'i)> { + match self { + | Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) => + Box::new(std::iter::empty()), + Value::Array(a) => + Box::new(a.iter().enumerate().map(|(k, v)| (k.into(), &*v as &dyn Node))), + Value::InlineTable(t) => + Box::new(t.iter().map(|(k, v)| (k.into(), &*v as &dyn Node))), + } + } +} + +/// Result specialization for this module. +pub type Result = std::result::Result; + +/// Errors for this module. +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("key {0:?} not found")] + KeyNotFound(String), + + #[error("index {0} is out of bounds")] + OutOfBounds(usize, usize), + + #[error("cannot index into a {0}")] + LookupError(&'static str), + + #[error("cannot insert into a {0}")] + InsertionError(&'static str), + + #[error("cannot remove items from a {0}")] + RemovalError(&'static str), + + #[error("{0:?} is not a numeric index")] + BadIndex(String), + + #[error("{0:?} is not a valid symbol")] + BadSymbol(usize), +} + +impl Error { + /// Adds context to the error, yielding a [`TraversalError`]. + pub fn with_context(self, path: &Path, i: usize, type_name: &'static str) + -> TraversalError + { + let p = Path { + components: path.components[..i].iter().cloned().collect(), + }; + match self { + Error::KeyNotFound(k) => TraversalError::KeyNotFound(p, k), + Error::OutOfBounds(i, l) => TraversalError::OutOfBounds(p, i, l), + Error::LookupError(t) => match &path.components[i] { + PathComponent::Symbol(s) => + TraversalError::KeyLookupBadType(p, s.into(), t), + PathComponent::Index(i) => + TraversalError::IndexLookupBadType(p, *i, t) + }, + Error::BadIndex(s) => + TraversalError::KeyLookupBadType(p, s, type_name), + Error::BadSymbol(i) => + TraversalError::IndexLookupBadType(p, i, type_name), + Error::InsertionError(_) | Error::RemovalError(_) => + unreachable!("not applicable for traversals"), + } + } +} + +/// Result specialization for traversal errors. +pub type TraversalResult = std::result::Result; + +/// Errors traversing the document tree. +#[derive(thiserror::Error, Debug)] +pub enum TraversalError { + #[error("Tried to look up {1:?}{}, \ + but it does not exist", Self::fmt_path("in", &.0))] + KeyNotFound(Path, String), + + #[error("Tried to get the item at index {1}{}, \ + but there are only {2} items", Self::fmt_path("from", &.0))] + OutOfBounds(Path, usize, usize), + + #[error("Tried to look up {1:?}{}, \ + but the latter is a {2}, not a table", Self::fmt_path("in", &.0))] + KeyLookupBadType(Path, String, &'static str), + + #[error("Tried to get the item at index {1}{}, \ + but the latter is a {2}, not an array", Self::fmt_path("from", &.0))] + IndexLookupBadType(Path, usize, &'static str), +} + +impl TraversalError { + /// Formats a position in the document tree for use in error + /// messages. + fn fmt_path(preposition: &'static str, p: &Path) -> Cow<'static, str> { + if p.is_empty() { + "".into() + } else { + format!(" {} {}", preposition, p).into() + } + } +}