diff --git a/src/cli/pki/authorize.rs b/src/cli/pki/authorize.rs index 44038ca1..9d5086bc 100644 --- a/src/cli/pki/authorize.rs +++ b/src/cli/pki/authorize.rs @@ -15,12 +15,9 @@ use crate::cli::types::Expiration; use crate::cli::types::FileOrStdin; use crate::cli::types::FileOrStdout; use crate::cli::types::TrustAmount; -use crate::cli::types::cert_designator::CertFileArgs; -use crate::cli::types::cert_designator::CertPrefix; -use crate::cli::types::cert_designator::NoPrefix; -use crate::cli::types::cert_designator::OneValue; -use crate::cli::types::cert_designator::OptionalValue; -use crate::cli::types::cert_designator::UserIDEmailArgs; +use crate::cli::types::UserIDDesignators; +use crate::cli::types::cert_designator; +use crate::cli::types::userid_designator; use crate::cli::examples::*; @@ -113,17 +110,15 @@ pub struct Command { pub certifier_file: Option, #[command(flatten)] - pub cert: CertDesignators, + pub cert: CertDesignators< + cert_designator::CertFileArgs, + cert_designator::CertPrefix, + cert_designator::OneValue>, #[command(flatten)] - pub userids: CertDesignators, - #[clap( - long, - help = "Add the given user ID if it doesn't exist.", - long_help = - "Add the given user ID if it doesn't exist in the certificate.", - )] - pub add_userid: bool, + pub userids: UserIDDesignators< + userid_designator::MaybeSelfSignedUserIDEmailArgs, + userid_designator::OptionalValue>, #[clap( long = "amount", diff --git a/src/cli/pki/certify.rs b/src/cli/pki/certify.rs index 394b91fb..d67a3cbd 100644 --- a/src/cli/pki/certify.rs +++ b/src/cli/pki/certify.rs @@ -15,11 +15,9 @@ use crate::cli::types::Expiration; use crate::cli::types::FileOrStdin; use crate::cli::types::FileOrStdout; use crate::cli::types::TrustAmount; -use crate::cli::types::cert_designator::CertFileArgs; -use crate::cli::types::cert_designator::CertPrefix; -use crate::cli::types::cert_designator::NoPrefix; -use crate::cli::types::cert_designator::OneValue; -use crate::cli::types::cert_designator::UserIDEmailArgs; +use crate::cli::types::UserIDDesignators; +use crate::cli::types::cert_designator; +use crate::cli::types::userid_designator; use crate::cli::examples::*; @@ -106,17 +104,14 @@ pub struct Command { pub certifier_file: Option, #[command(flatten)] - pub cert: CertDesignators, + pub cert: CertDesignators< + cert_designator::CertFileArgs, + cert_designator::CertPrefix, + cert_designator::OneValue>, #[command(flatten)] - pub userids: CertDesignators, - #[clap( - long, - help = "Add the given user ID if it doesn't exist.", - long_help = - "Add the given user ID if it doesn't exist in the certificate.", - )] - pub add_userid: bool, + pub userids: UserIDDesignators< + userid_designator::MaybeSelfSignedUserIDEmailArgs>, #[clap( long = "amount", diff --git a/src/cli/types.rs b/src/cli/types.rs index 3ec473d5..b4755d9f 100644 --- a/src/cli/types.rs +++ b/src/cli/types.rs @@ -37,6 +37,8 @@ use crate::cli::SECONDS_IN_YEAR; pub mod cert_designator; pub use cert_designator::CertDesignators; pub mod paths; +pub mod userid_designator; +pub use userid_designator::UserIDDesignators; /// A trait to provide const &str for clap annotations for custom structs pub trait ClapData { diff --git a/src/cli/types/cert_designator.rs b/src/cli/types/cert_designator.rs index 3cfa964b..9cc58c5b 100644 --- a/src/cli/types/cert_designator.rs +++ b/src/cli/types/cert_designator.rs @@ -94,11 +94,6 @@ pub type CertUserIDEmailFileArgs as std::ops::BitOr>::Output as std::ops::BitOr>::Output; -/// Enables --userid, and --email (i.e., not --cert, --file, --domain, -/// or --grep). -pub type UserIDEmailArgs - = >::Output; - /// Enables --cert, and --file (i.e., not --userid, --email, --domain, /// or --grep). pub type CertFileArgs = >::Output; diff --git a/src/cli/types/userid_designator.rs b/src/cli/types/userid_designator.rs new file mode 100644 index 00000000..c0b99eb1 --- /dev/null +++ b/src/cli/types/userid_designator.rs @@ -0,0 +1,504 @@ +use anyhow::Context; +use anyhow::Result; + +use typenum::Unsigned; + +use sequoia_openpgp as openpgp; +use openpgp::packet::UserID; + +/// Adds a `--userid` argument. +pub type UserIDArg = typenum::U1; + +/// Adds a `--email` argument. +pub type EmailArg = typenum::U2; + +/// Adds a `--add-userid` argument. +pub type AddUserIDArg = typenum::U4; + +/// Enables --userid, --email, and --add-userid. +pub type MaybeSelfSignedUserIDEmailArgs + = <>::Output + as std::ops::BitOr>::Output; + +/// Argument parser options. + +/// Normally it is possible to designate multiple certificates. This +/// errors out if there is more than one value. +pub type OneValue = typenum::U1; + +/// Normally a certificate designator is required, and errors out if +/// there isn't at least one value. This makes the cert designator +/// completely optional. +pub type OptionalValue = typenum::U2; + +/// A user ID designator. +#[derive(Debug)] +pub enum UserIDDesignator { + /// A user ID. + UserID(String), + + /// An email address. + Email(String), +} + +#[allow(dead_code)] +impl UserIDDesignator { + /// Returns the argument's name, e.g., `--userid`. + pub fn argument_name(&self) -> &str + { + use UserIDDesignator::*; + match self { + UserID(_userid) => "--userid", + Email(_email) => "--email", + } + } + + /// Returns the argument's name and value, e.g., `--cert-file + /// file`. + pub fn argument(&self) -> String + { + let argument_name = self.argument_name(); + + use UserIDDesignator::*; + match self { + UserID(userid) => format!("{} {:?}", argument_name, userid), + Email(email) => format!("{} {:?}", argument_name, email), + } + } +} + +/// A data structure that can be flattened into a clap `Command`, and +/// adds arguments to address user IDs. +/// +/// Depending on `Arguments`, it adds zero or more arguments to the +/// subcommand. If `UserIDArg` is selected, for instance, then a +/// `--userid` argument is added. +/// +/// `Options` are the set of options to the argument parser. By +/// default, at least one user ID designator must be specified. +pub struct UserIDDesignators +{ + /// The set of certificate designators. + pub designators: Vec, + + pub add_userid: Option, + + arguments: std::marker::PhantomData<(Arguments, Options)>, +} + +impl std::fmt::Debug + for UserIDDesignators +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UserIDDesignators") + .field("designators", &self.designators) + .finish() + } +} + +#[allow(dead_code)] +impl UserIDDesignators { + /// Like `Vec::push`. + pub fn push(&mut self, designator: UserIDDesignator) { + self.designators.push(designator) + } + + /// Like `Vec::is_empty`. + pub fn is_empty(&mut self) -> bool { + self.designators.is_empty() + } + + /// Iterates over the user ID designators. + pub fn iter(&self) -> impl Iterator { + self.designators.iter() + } + + /// Returns whether the add user ID flag was set. + /// + /// If the flag was not enabled, returns `None`. + pub fn add_userid(&self) -> Option { + self.add_userid + } +} + +impl clap::Args + for UserIDDesignators +where + Arguments: typenum::Unsigned, + Options: typenum::Unsigned, +{ + fn augment_args(mut cmd: clap::Command) -> clap::Command + { + let arguments = Arguments::to_usize(); + let userid_arg = (arguments & UserIDArg::to_usize()) > 0; + let email_arg = (arguments & EmailArg::to_usize()) > 0; + let add_userid_arg = (arguments & AddUserIDArg::to_usize()) > 0; + + let options = Options::to_usize(); + let one_value = (options & OneValue::to_usize()) > 0; + let optional_value = (options & OptionalValue::to_usize()) > 0; + + let group = format!("userid-designator-{:X}-{:X}", + arguments, + options); + let mut arg_group = clap::ArgGroup::new(&group); + if one_value { + arg_group = arg_group.multiple(false); + } else { + arg_group = arg_group.multiple(true); + } + + if optional_value { + arg_group = arg_group.required(false); + } else { + arg_group = arg_group.required(true); + } + + let action = if one_value { + clap::ArgAction::Set + } else { + clap::ArgAction::Append + }; + + fn parse_as_email(s: &str) -> Result { + let userid = UserID::from(format!("<{}>", s)); + match userid.email_normalized() { + Ok(Some(email)) => { + Ok(email) + } + Ok(None) => { + Err(anyhow::anyhow!( + "{:?} is not a valid email address", s)) + } + Err(err) => { + Err(err).context(format!( + "{:?} is not a valid email address", s)) + } + } + } + + if userid_arg { + let full_name = "userid"; + cmd = cmd.arg( + clap::Arg::new(&full_name) + .long(&full_name) + .value_name("USERID") + .action(action.clone()) + .help("Uses the specified user ID")); + arg_group = arg_group.arg(full_name); + } + + if email_arg { + let full_name = "email"; + cmd = cmd.arg( + clap::Arg::new(&full_name) + .long(&full_name) + .value_name("EMAIL") + .value_parser(parse_as_email) + .action(action.clone()) + .help("Uses the specified email address")); + arg_group = arg_group.arg(full_name); + } + + if add_userid_arg { + let full_name = "add-userid"; + cmd = cmd.arg( + clap::Arg::new(&full_name) + .long(&full_name) + .requires(&group) + .action(clap::ArgAction::SetTrue) + .help("\ +Uses the given user ID even if it isn't a self-signed user ID") + .long_help("\ +Uses the given user ID even if it isn't a self-signed user ID. + +Because certifying a user ID that is not self-signed is often a \ +mistake, you need to use this option to explicitly opt in. That said, \ +certifying a user ID that is not self-signed is useful. For instance, \ +you can associate an alternate email address with a certificate, or \ +you can add a petname, i.e., a memorable, personal name like \ +\"mom\".")); + } + + cmd = cmd.group(arg_group); + + cmd + } + + fn augment_args_for_update(cmd: clap::Command) -> clap::Command + { + Self::augment_args(cmd) + } +} + +impl clap::FromArgMatches + for UserIDDesignators +where + Arguments: typenum::Unsigned, + Options: typenum::Unsigned, +{ + fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) + -> Result<(), clap::Error> + { + // eprintln!("matches: {:#?}", matches); + + let arguments = Arguments::to_usize(); + let userid_arg = (arguments & UserIDArg::to_usize()) > 0; + let email_arg = (arguments & EmailArg::to_usize()) > 0; + let add_userid_arg = (arguments & AddUserIDArg::to_usize()) > 0; + + let mut designators = Vec::new(); + + if let Some(Some(userids)) + = matches.try_get_many::("userid") + .ok().filter(|_| userid_arg) + { + for userid in userids.cloned() { + designators.push( + UserIDDesignator::UserID(userid)); + } + } + + if let Some(Some(emails)) + = matches.try_get_many::("email") + .ok().filter(|_| email_arg) + { + for email in emails.cloned() { + designators.push(UserIDDesignator::Email(email)); + } + } + + self.add_userid = if add_userid_arg { + if matches.get_flag("add-userid") { + Some(true) + } else { + Some(false) + } + } else { + None + }; + + self.designators = designators; + Ok(()) + } + + fn from_arg_matches(matches: &clap::ArgMatches) + -> Result + { + let mut designators = Self { + designators: Vec::new(), + arguments: std::marker::PhantomData, + add_userid: None, + }; + + // The way we use clap, this is never called. + designators.update_from_arg_matches(matches)?; + Ok(designators) + } +} + +#[cfg(test)] +mod test { + use super::*; + + // Check that flattening UserIDDesignators works as expected. + #[test] + fn userid_designators() { + use clap::Parser; + use clap::CommandFactory; + use clap::FromArgMatches; + + macro_rules! check { + ($t:ty, + $userid:expr, $email:expr, $add_userid:expr) => + {{ + #[derive(Parser, Debug)] + #[clap(name = "prog")] + struct CLI { + #[command(flatten)] + pub userids: UserIDDesignators<$t>, + } + + let command = CLI::command(); + + // Check if --userid is recognized. + let m = command.clone().try_get_matches_from(vec![ + "prog", "--userid", "alice", "--userid", "bob", + ]); + if $userid { + let m = m.expect("valid arguments"); + let c = CLI::from_arg_matches(&m).expect("ok"); + assert_eq!(c.userids.designators.len(), 2); + + if $add_userid { + assert_eq!(c.userids.add_userid(), Some(false)); + } else { + assert_eq!(c.userids.add_userid(), None); + } + } else { + assert!(m.is_err()); + } + + + // Check if --email is recognized. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--email", "alice@example.org", + "--email", "bob@example.org", + ]); + if $email { + let m = m.expect("valid arguments"); + let c = CLI::from_arg_matches(&m).expect("ok"); + assert_eq!(c.userids.designators.len(), 2); + + if $add_userid { + assert_eq!(c.userids.add_userid(), Some(false)); + } else { + assert_eq!(c.userids.add_userid(), None); + } + } else { + assert!(m.is_err()); + } + + // Either --email is unknown, or the --email's value + // is invalid. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--email", "alice@invalid@example.org", + ]); + assert!(m.is_err()); + + // Check if --add-userid is recognized. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--userid", "alice", + "--add-userid" + ]); + if $userid && $add_userid { + let m = m.expect("valid arguments"); + let c = CLI::from_arg_matches(&m).expect("ok"); + assert_eq!(c.userids.designators.len(), 1); + assert_eq!(c.userids.add_userid(), Some(true)); + } else { + assert!(m.is_err()); + } + }} + } + + // No Args. + check!(typenum::U0,false, false, false); + check!(UserIDArg, true, false, false); + check!(EmailArg, false, true, false); + check!(MaybeSelfSignedUserIDEmailArgs, true, true, true); + } + + #[test] + fn userid_designators_one() { + use clap::Parser; + use clap::CommandFactory; + use clap::FromArgMatches; + + #[derive(Parser, Debug)] + #[clap(name = "prog")] + struct CLI { + #[command(flatten)] + pub userids: UserIDDesignators, + } + + let command = CLI::command(); + + // Check if --userid is recognized. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--userid", "userid", + ]); + let m = m.expect("valid arguments"); + let c = CLI::from_arg_matches(&m).expect("ok"); + assert_eq!(c.userids.designators.len(), 1); + + // Make sure that we can't give it twice. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--userid", "alice", + "--userid", "bob", + ]); + assert!(m.is_err()); + + // Make sure that we can't give it zero times. + let m = command.clone().try_get_matches_from(vec![ + "prog", + ]); + assert!(m.is_err()); + + // Mixing is also not allowed. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--userid", "carol", + "--email", "localpart@example.org", + ]); + assert!(m.is_err()); + } + + #[test] + fn userid_designators_optional() { + use clap::Parser; + use clap::CommandFactory; + use clap::FromArgMatches; + + #[derive(Parser, Debug)] + #[clap(name = "prog")] + struct CLI { + #[command(flatten)] + pub userids: UserIDDesignators, + } + + let command = CLI::command(); + + // Check if --userid is recognized. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--userid", "alice", + ]); + let m = m.expect("valid arguments"); + let c = CLI::from_arg_matches(&m).expect("ok"); + assert_eq!(c.userids.designators.len(), 1); + + // Make sure that we can give it twice. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--userid", "alice", + "--userid", "bob", + ]); + let m = m.expect("valid arguments"); + let c = CLI::from_arg_matches(&m).expect("ok"); + assert_eq!(c.userids.designators.len(), 2); + + // Make sure that we can give it zero times. + let m = command.clone().try_get_matches_from(vec![ + "prog", + ]); + let m = m.expect("valid arguments"); + let c = CLI::from_arg_matches(&m).expect("ok"); + assert_eq!(c.userids.designators.len(), 0); + + // Make sure mixing is allowed. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--userid", "alice", + "--email", "localpart@example.org", + ]); + let m = m.expect("valid arguments"); + let c = CLI::from_arg_matches(&m).expect("ok"); + assert_eq!(c.userids.designators.len(), 2); + + // Make sure we can only provide --add-userid if a designator + // is also specified. + let m = command.clone().try_get_matches_from(vec![ + "prog", + "--add-userid", + ]); + assert!(m.is_err()); + } +} diff --git a/src/commands/pki/authorize.rs b/src/commands/pki/authorize.rs index 82711201..c155a090 100644 --- a/src/commands/pki/authorize.rs +++ b/src/commands/pki/authorize.rs @@ -6,7 +6,7 @@ use openpgp::types::KeyFlags; use crate::Sq; use crate::cli::pki::authorize; use crate::cli::types::FileStdinOrKeyHandle; -use crate::cli::types::cert_designator::CertDesignator; +use crate::cli::types::userid_designator::UserIDDesignator; use crate::commands::FileOrStdout; use crate::parse_notations; @@ -45,13 +45,13 @@ pub fn authorize(sq: Sq, mut c: authorize::Command) for designator in c.userids.iter() { match designator { - CertDesignator::UserID(userid) => { + UserIDDesignator::UserID(userid) => { let userid = UserID::from(&userid[..]); // If --add-userid is specified, we use the user ID as // is. Otherwise, we make sure there is a matching // self-signed user ID. - if c.add_userid { + if c.userids.add_userid().unwrap_or(false) { userids.push(userid.clone()); } else if let Some(_) = vc.userids() .find(|ua| { @@ -65,7 +65,7 @@ pub fn authorize(sq: Sq, mut c: authorize::Command) missing = true; } } - CertDesignator::Email(email) => { + UserIDDesignator::Email(email) => { // Validate the email address. let userid = match UserID::from_address(None, None, email) { Ok(userid) => userid, @@ -107,7 +107,7 @@ pub fn authorize(sq: Sq, mut c: authorize::Command) } if ! found { - if c.add_userid { + if c.userids.add_userid().unwrap_or(false) { // Add the bare email address. userids.push(userid); } else { @@ -118,7 +118,6 @@ pub fn authorize(sq: Sq, mut c: authorize::Command) } } } - _ => unreachable!("enforced by clap"), } } @@ -148,7 +147,7 @@ pub fn authorize(sq: Sq, mut c: authorize::Command) &certifier, &cert, &userids[..], - c.add_userid, + c.userids.add_userid().unwrap_or(false), true, // User supplied user IDs. &[(c.amount, c.expiration)], c.depth, diff --git a/src/commands/pki/certify.rs b/src/commands/pki/certify.rs index dbf212e5..d51fb0db 100644 --- a/src/commands/pki/certify.rs +++ b/src/commands/pki/certify.rs @@ -6,7 +6,7 @@ use openpgp::types::KeyFlags; use crate::Sq; use crate::cli::pki::certify; use crate::cli::types::FileStdinOrKeyHandle; -use crate::cli::types::cert_designator::CertDesignator; +use crate::cli::types::userid_designator::UserIDDesignator; use crate::commands::FileOrStdout; use crate::parse_notations; @@ -45,13 +45,13 @@ pub fn certify(sq: Sq, mut c: certify::Command) for designator in c.userids.iter() { match designator { - CertDesignator::UserID(userid) => { + UserIDDesignator::UserID(userid) => { let userid = UserID::from(&userid[..]); // If --add-userid is specified, we use the user ID as // is. Otherwise, we make sure there is a matching // self-signed user ID. - if c.add_userid { + if c.userids.add_userid().unwrap_or(false) { userids.push(userid.clone()); } else if let Some(_) = vc.userids() .find(|ua| { @@ -65,7 +65,7 @@ pub fn certify(sq: Sq, mut c: certify::Command) missing = true; } } - CertDesignator::Email(email) => { + UserIDDesignator::Email(email) => { // Validate the email address. let userid = match UserID::from_address(None, None, email) { Ok(userid) => userid, @@ -107,7 +107,7 @@ pub fn certify(sq: Sq, mut c: certify::Command) } if ! found { - if c.add_userid { + if c.userids.add_userid().unwrap_or(false) { // Add the bare email address. userids.push(userid); } else { @@ -118,7 +118,6 @@ pub fn certify(sq: Sq, mut c: certify::Command) } } } - _ => unreachable!("enforced by clap"), } } @@ -151,7 +150,7 @@ pub fn certify(sq: Sq, mut c: certify::Command) &certifier, &cert, &userids[..], - c.add_userid, + c.userids.add_userid().unwrap_or(false), true, // User supplied user IDs. &[(c.amount, c.expiration)], 0,