From 984f9da93efc846ac3ba3eedf615ec43b2b3d0c5 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 13 Jul 2026 13:59:01 +0800 Subject: [PATCH 01/27] feat(identity): simplify command and output surface --- genmeta-identity/src/cli.rs | 142 +- genmeta-identity/src/cli/flow.rs | 13 +- genmeta-identity/src/cli/flow/apply.rs | 12 +- genmeta-identity/src/cli/flow/create.rs | 2268 ----------------- .../src/cli/flow/default_identity.rs | 401 +-- genmeta-identity/src/cli/flow/output.rs | 210 +- genmeta-identity/src/cli/flow/registration.rs | 384 +++ genmeta-identity/src/cli/flow/renew.rs | 104 +- genmeta/src/main.rs | 21 +- 9 files changed, 627 insertions(+), 2928 deletions(-) delete mode 100644 genmeta-identity/src/cli/flow/create.rs create mode 100644 genmeta-identity/src/cli/flow/registration.rs diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index e9c0838..52d5826 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -309,27 +309,6 @@ async fn login_with_email( } } -/// Create a new identity -#[derive(Parser, Debug, Clone)] -pub struct Create { - #[arg(value_name = "IDENTITY")] - pub name: Option, - #[arg(long)] - pub kind: Option, - #[arg(long)] - pub replace_local: bool, - #[arg(long)] - pub device_name: Option, - #[arg(short, long)] - pub email: Option, - #[arg(long, conflicts_with = "verify_code")] - pub send_code: bool, - #[arg(long, value_name = "VERIFY_CODE", hide = true)] - pub verify_code: Option, - #[arg(long, value_enum)] - pub auth: Option, -} - /// Apply identity #[derive(Parser, Debug, Clone)] pub struct Apply { @@ -356,8 +335,6 @@ pub struct Apply { pub struct Renew { #[arg(value_name = "IDENTITY")] pub name: Option, - #[arg(long = "default", conflicts_with = "name")] - pub use_default: bool, #[arg(long)] pub device_name: Option, #[arg(short, long)] @@ -373,8 +350,10 @@ pub struct Renew { /// Set default identity #[derive(Parser, Debug, Clone)] pub struct Default { - #[arg(value_name = "IDENTITY")] + #[arg(value_name = "IDENTITY", conflicts_with = "verbose")] pub name: Option, + #[arg(short, long)] + pub verbose: bool, #[arg(long)] pub allow_nonready: bool, } @@ -416,13 +395,13 @@ impl List { if inventory.groups.is_empty() { flow::transcript::print_line("No identities found here"); } else { - flow::transcript::print_block(&flow::output::render_inventory( - &inventory, - std::io::stdout().is_terminal(), - )); - if self.verbose { - tracing::debug!("verbose identity list details are not implemented yet"); - } + let ansi = std::io::stdout().is_terminal(); + let rendered = if self.verbose { + flow::output::render_verbose_inventory(&inventory, ansi) + } else { + flow::output::render_inventory(&inventory, ansi) + }; + flow::transcript::print_block(&rendered); } Ok(()) } @@ -503,7 +482,6 @@ impl Cli { #[derive(Parser, Debug, Clone)] #[command(about, disable_help_flag = true, disable_version_flag = true)] pub enum Options { - Create(Create), Apply(Apply), Renew(Renew), Default(Default), @@ -514,10 +492,7 @@ pub enum Options { impl Options { pub fn writes_home(&self) -> bool { - matches!( - self, - Self::Create(_) | Self::Apply(_) | Self::Renew(_) | Self::Default(_) - ) + matches!(self, Self::Apply(_) | Self::Renew(_) | Self::Default(_)) } pub async fn run( @@ -527,9 +502,6 @@ impl Options { cert_server: &CertServer, ) -> Result<(), Error> { match self { - Options::Create(cmd) => { - flow::run_create(cmd, dhttp_home, home_scope, cert_server).await - } Options::Apply(cmd) => flow::run_apply(cmd, dhttp_home, home_scope, cert_server).await, Options::Renew(cmd) => flow::run_renew(cmd, dhttp_home, home_scope, cert_server).await, Options::Default(cmd) => { @@ -615,7 +587,7 @@ mod tests { use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use super::{ - Cli, Create, Default, Info, Options, cert_server_base_url, + Apply, Cli, Default, Info, Options, cert_server_base_url, certificate_chain_key_from_identity, }; use crate::CERT_SERVER_BASE_URL; @@ -668,10 +640,10 @@ mod tests { } #[test] - fn create_rejects_auth_auto() { + fn apply_rejects_auth_auto() { let error = Options::try_parse_from([ "genmeta", - "create", + "apply", "alice.smith", "--kind", "primary", @@ -687,14 +659,14 @@ mod tests { #[test] fn verify_code_is_hidden_from_help_but_still_parses() { - let mut command = Create::command(); + let mut command = Apply::command(); let help = command.render_long_help().to_string(); assert!(!help.contains("--verify-code"), "{help}"); assert!( Options::try_parse_from([ "genmeta", - "create", + "apply", "alice.smith", "--kind", "primary", @@ -717,6 +689,27 @@ mod tests { ); } + #[test] + fn create_subcommand_is_removed() { + let error = Options::try_parse_from(["genmeta", "create", "alice.smith"]) + .expect_err("create must no longer parse"); + assert!(error.to_string().contains("unrecognized subcommand")); + } + + #[test] + fn renew_uses_optional_positional_identity_without_default_flag() { + assert!(Options::try_parse_from(["genmeta", "renew"]).is_ok()); + assert!(Options::try_parse_from(["genmeta", "renew", "alice.smith"]).is_ok()); + assert!(Options::try_parse_from(["genmeta", "renew", "--default"]).is_err()); + } + + #[test] + fn default_verbose_is_query_only() { + assert!(Options::try_parse_from(["genmeta", "default", "-v"]).is_ok()); + assert!(Options::try_parse_from(["genmeta", "default", "--verbose"]).is_ok()); + assert!(Options::try_parse_from(["genmeta", "default", "alice.smith", "-v"]).is_err()); + } + #[test] fn helper_style_read_and_write_subcommands_are_rejected() { for command in ["read", "write"] { @@ -737,48 +730,44 @@ mod tests { } #[test] - fn create_and_apply_reject_sequence_flag() { - for command in ["create", "apply"] { - let error = Options::try_parse_from([ + fn apply_rejects_sequence_flag() { + let error = Options::try_parse_from([ + "genmeta", + "apply", + "alice.smith", + "--kind", + "primary", + "--sequence", + "1", + ]) + .unwrap_err(); + let rendered = error.to_string(); + assert!(rendered.contains("--sequence"), "{rendered}"); + } + + #[test] + fn apply_accepts_replace_local_flag() { + assert!( + Options::try_parse_from([ "genmeta", - command, + "apply", "alice.smith", "--kind", "primary", - "--sequence", - "1", + "--replace-local", ]) - .unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("--sequence"), "{rendered}"); - } - } - - #[test] - fn create_and_apply_accept_replace_local_flag() { - for command in ["create", "apply"] { - assert!( - Options::try_parse_from([ - "genmeta", - command, - "alice.smith", - "--kind", - "primary", - "--replace-local", - ]) - .is_ok() - ); - } + .is_ok() + ); } #[test] - fn apply_rejects_default_flag_while_renew_keeps_it() { + fn apply_and_renew_reject_default_flag() { let apply_error = Options::try_parse_from(["genmeta", "apply", "--default", "--kind", "primary"]) .unwrap_err(); assert!(apply_error.to_string().contains("--default")); - assert!(Options::try_parse_from(["genmeta", "renew", "--default"]).is_ok()); + assert!(Options::try_parse_from(["genmeta", "renew", "--default"]).is_err()); } #[test] @@ -786,7 +775,7 @@ mod tests { assert!( Options::try_parse_from([ "genmeta", - "create", + "apply", "alice.smith", "--kind", "primary", @@ -801,7 +790,7 @@ mod tests { let error = Options::try_parse_from([ "genmeta", - "create", + "apply", "alice.smith", "--kind", "primary", @@ -863,6 +852,7 @@ mod tests { let dhttp_home = DhttpHome::new(home_path); let command = Default { name: Some("alice.smith".to_string()), + verbose: false, allow_nonready: false, }; @@ -888,6 +878,7 @@ mod tests { let command = Default { name: Some("alice.smith".to_string()), + verbose: false, allow_nonready: true, }; @@ -920,7 +911,6 @@ mod tests { #[test] fn write_commands_are_marked_for_global_warning() { for argv in [ - ["genmeta", "create", "alice.smith", "--kind", "primary"].as_slice(), ["genmeta", "apply", "alice.smith", "--kind", "primary"].as_slice(), ["genmeta", "renew", "alice.smith"].as_slice(), ["genmeta", "default", "alice.smith"].as_slice(), diff --git a/genmeta-identity/src/cli/flow.rs b/genmeta-identity/src/cli/flow.rs index 271b2c7..deb71b4 100644 --- a/genmeta-identity/src/cli/flow.rs +++ b/genmeta-identity/src/cli/flow.rs @@ -1,6 +1,5 @@ pub(crate) mod apply; pub(crate) mod approval; -pub(crate) mod create; pub(crate) mod default_identity; pub(crate) mod device; pub(crate) mod email; @@ -10,6 +9,7 @@ pub(crate) mod local; pub(crate) mod output; pub(crate) mod progress; pub(crate) mod recovery; +pub(crate) mod registration; pub(crate) mod renew; pub(crate) mod target; pub(crate) mod transcript; @@ -19,18 +19,9 @@ use dhttp::home::{DhttpHome, HomeScope}; use crate::{ cert_server::CertServer, - cli::{Apply, Create, Default, Error, Info, List, Renew}, + cli::{Apply, Default, Error, Info, List, Renew}, }; -pub(crate) async fn run_create( - command: &Create, - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, -) -> Result<(), Error> { - create::run(command, dhttp_home, home_scope, cert_server).await -} - pub(crate) async fn run_apply( command: &Apply, dhttp_home: &DhttpHome, diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index a2a8966..35e8375 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -349,7 +349,7 @@ fn rewrite_apply_email_issue_error( } fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { - super::create::is_subdomain_quota_exceeded(error) + super::registration::is_subdomain_quota_exceeded(error) } fn rewrite_apply_registration_error(target: &IdentityTarget, error: Error) -> Error { @@ -736,7 +736,7 @@ async fn ensure_identity_exists_after_apply_login( match target.level() { IdentityLevel::Identity => { if interactive { - super::create::ensure_identity_exists_with_token_interactively( + super::registration::ensure_identity_exists_with_token_interactively( cert_server, target, access_token, @@ -744,7 +744,7 @@ async fn ensure_identity_exists_after_apply_login( ) .await } else { - super::create::ensure_identity_exists_with_token( + super::registration::ensure_identity_exists_with_token( cert_server, target, access_token, @@ -761,7 +761,7 @@ async fn ensure_identity_exists_after_apply_login( "sub-identity target is missing its direct child label", )?; let created = if interactive { - super::create::create_sub_identity_with_token_interactively( + super::registration::create_sub_identity_with_token_interactively( cert_server, target, access_token, @@ -770,7 +770,7 @@ async fn ensure_identity_exists_after_apply_login( ) .await? } else { - let created = super::create::create_sub_identity_with_token( + let created = super::registration::create_sub_identity_with_token( cert_server, target, access_token, @@ -778,7 +778,7 @@ async fn ensure_identity_exists_after_apply_login( label, ) .await?; - super::create::ensure_non_interactive_sub_identity_checkout_not_required( + super::registration::ensure_non_interactive_sub_identity_checkout_not_required( target, &created, )?; created diff --git a/genmeta-identity/src/cli/flow/create.rs b/genmeta-identity/src/cli/flow/create.rs deleted file mode 100644 index a15b613..0000000 --- a/genmeta-identity/src/cli/flow/create.rs +++ /dev/null @@ -1,2268 +0,0 @@ -use std::io::IsTerminal; - -use dhttp::home::{DhttpHome, HomeScope}; -use snafu::{FromString, OptionExt, whatever}; -use tracing::Instrument; - -use super::{ - approval, - kind::IdentityKind, - local::{self, LocalIdentityStatus, LocalIdentitySummary}, - target::{IdentityLevel, IdentityTarget}, -}; -use crate::{ - auth::AuthMethod, - cert_server::{ - CertServer, CreateDomainResponse, CreateSubdomainAttempt, CreateSubdomainResponse, - InvoiceDetail, SubdomainQuotaQuote, - }, - cli::{ - self, Create, Error, - prompt::{self, InquireResultExt}, - }, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CreateApprovalPlan { - parent_identity: Option, - auth: AuthMethod, - helper_action: Option, -} - -#[derive(Debug)] -enum CompleteRootIdentityCreateInteractivelyError { - Verification { source: crate::cert_server::Error }, - Flow { source: Error }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum CreateEmailAction { - SwitchVerificationMethod, - ChangeCertificateKind, - ChangeIdentityName, -} - -impl CreateEmailAction { - fn label(&self) -> String { - match self { - Self::SwitchVerificationMethod => { - "Switch verification method (go back to verification method selection)".to_string() - } - Self::ChangeCertificateKind => { - "Change certificate kind (go back to identity kind)".to_string() - } - Self::ChangeIdentityName => { - "Change identity name (go back to identity name)".to_string() - } - } - } -} - -fn create_email_actions(can_switch_verification_method: bool) -> Vec { - let mut actions = Vec::new(); - if can_switch_verification_method { - actions.push(CreateEmailAction::SwitchVerificationMethod); - } - actions.push(CreateEmailAction::ChangeCertificateKind); - actions.push(CreateEmailAction::ChangeIdentityName); - actions -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum CreateVerifyCodeAction { - ResendVerificationCode, - ChangeEmail, - SwitchVerificationMethod, - ChangeCertificateKind, - ChangeIdentityName, - ReturnToCreate { target_name: String }, -} - -impl CreateVerifyCodeAction { - fn label(&self) -> String { - match self { - Self::ResendVerificationCode => "Resend verification code".to_string(), - Self::ChangeEmail => "Send code to another email (go back to email)".to_string(), - Self::SwitchVerificationMethod => { - "Switch verification method (go back to verification method selection)".to_string() - } - Self::ChangeCertificateKind => { - "Change certificate kind (go back to identity kind)".to_string() - } - Self::ChangeIdentityName => { - "Change identity name (go back to identity name)".to_string() - } - Self::ReturnToCreate { target_name } => { - format!("Return to create {target_name}") - } - } - } -} - -fn create_verify_code_actions(return_target_name: Option<&str>) -> Vec { - let mut actions = vec![ - CreateVerifyCodeAction::ResendVerificationCode, - CreateVerifyCodeAction::ChangeEmail, - CreateVerifyCodeAction::SwitchVerificationMethod, - CreateVerifyCodeAction::ChangeCertificateKind, - CreateVerifyCodeAction::ChangeIdentityName, - ]; - if let Some(target_name) = return_target_name { - actions.push(CreateVerifyCodeAction::ReturnToCreate { - target_name: target_name.to_string(), - }); - } - actions -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CreateApprovalMenuAction { - ChangeCertificateKind, - ChangeIdentityName, -} - -impl CreateApprovalMenuAction { - fn label(self) -> String { - match self { - Self::ChangeCertificateKind => { - "Change certificate kind (go back to identity kind)".to_string() - } - Self::ChangeIdentityName => { - "Change identity name (go back to identity name)".to_string() - } - } - } -} - -#[derive(Debug, Clone)] -struct InteractiveCreateState { - target: Option, - target_opening_required: bool, - kind: Option, - kind_prompt_required: bool, - approval_plan: Option, - email: Option, - email_prompt_required: bool, - verify_code: Option, - verification_code_sent_to: Option, -} - -impl InteractiveCreateState { - fn from_command(command: &Create) -> Result { - Ok(Self { - target: command - .name - .as_deref() - .map(IdentityTarget::parse) - .transpose()?, - target_opening_required: command.name.is_none(), - kind: command - .kind - .as_deref() - .map(str::parse::) - .transpose()?, - kind_prompt_required: command.kind.is_none(), - approval_plan: None, - email: command.email.clone(), - email_prompt_required: command.email.is_none(), - verify_code: command.verify_code.clone(), - verification_code_sent_to: None, - }) - } - - fn reset_after_target_change(&mut self) { - self.kind = None; - self.kind_prompt_required = true; - self.reset_after_kind_change(); - self.target_opening_required = true; - } - - fn reset_after_kind_change(&mut self) { - self.kind_prompt_required = false; - self.approval_plan = None; - self.reset_after_approval_change(); - } - - fn reset_after_approval_change(&mut self) { - self.email = None; - self.email_prompt_required = true; - self.reset_after_email_change(); - } - - fn reset_after_email_change(&mut self) { - self.verify_code = None; - self.verification_code_sent_to = None; - } - - fn revisit_target_prompt(&mut self) { - self.kind = None; - self.kind_prompt_required = true; - self.approval_plan = None; - self.reset_after_approval_change(); - self.target_opening_required = true; - } - - fn revisit_kind_prompt(&mut self) { - self.approval_plan = None; - self.reset_after_approval_change(); - self.kind_prompt_required = true; - } - - fn revisit_email_prompt(&mut self) { - self.reset_after_email_change(); - self.email_prompt_required = true; - } -} - -fn apply_verification_recovery( - state: &mut InteractiveCreateState, - recovery: &crate::cli::flow::recovery::VerificationRecovery, -) -> bool { - match recovery { - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { message } => { - crate::cli::flow::transcript::print_line(message); - true - } - crate::cli::flow::recovery::VerificationRecovery::BackToEmail { message } => { - crate::cli::flow::transcript::print_line(message); - state.revisit_email_prompt(); - true - } - crate::cli::flow::recovery::VerificationRecovery::Abort => false, - } -} - -fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { - error.is_api_code("domain_not_found") -} - -fn is_domain_conflict(error: &crate::cert_server::Error) -> bool { - error.is_api_code("domain_conflict") -} - -pub(crate) fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { - error.is_api_code("subdomain_quota_exceeded") -} - -fn build_create_approval_options( - candidate: Option, -) -> Vec { - approval::build_options_for_candidate("Verify with email", candidate) -} - -fn create_plan_from_selection( - options: &[approval::ApprovalMenuOption], - selected: &str, - parent_identity: Option<&str>, -) -> Result { - let option = options - .iter() - .find(|option| option.label() == selected) - .whatever_context::<_, Error>("selected approval path is unavailable")?; - - match option { - approval::ApprovalMenuOption::Email { .. } => Ok(CreateApprovalPlan { - parent_identity: parent_identity.map(str::to_string), - auth: AuthMethod::Email, - helper_action: None, - }), - approval::ApprovalMenuOption::DirectLocal(local) => Ok(CreateApprovalPlan { - parent_identity: Some(local.auth_domain.clone()), - auth: AuthMethod::Identity, - helper_action: None, - }), - approval::ApprovalMenuOption::Helper(helper) => Ok(CreateApprovalPlan { - parent_identity: Some(helper.auth_domain.clone()), - auth: AuthMethod::Identity, - helper_action: Some(helper.action.clone()), - }), - } -} - -fn create_candidate_from_summary( - summary: &LocalIdentitySummary, -) -> approval::LocalApprovalCandidate { - let short_name = summary.target.short_name().to_string(); - let auth_domain = summary.target.full_name(); - match &summary.status { - LocalIdentityStatus::Ready { .. } => { - approval::LocalApprovalCandidate::ready(short_name, auth_domain) - } - LocalIdentityStatus::Expired { .. } => { - approval::LocalApprovalCandidate::expired(short_name, auth_domain, true, true) - } - LocalIdentityStatus::Incomplete { detail } => { - approval::LocalApprovalCandidate::incomplete(short_name, auth_domain, detail.clone()) - } - LocalIdentityStatus::Invalid { detail } => { - approval::LocalApprovalCandidate::invalid(short_name, auth_domain, detail.clone()) - } - } -} - -fn resolve_non_interactive_approval_plan( - target: &IdentityTarget, - requested_auth: Option, - ready_parent_identity: Option<&str>, -) -> Result { - match target.level() { - IdentityLevel::Identity => match requested_auth { - Some(AuthMethod::Identity) => whatever!( - "creating {} requires email verification; rerun without --auth or use --auth email instead of --auth identity", - target.short_name() - ), - Some(AuthMethod::Email) | None => Ok(CreateApprovalPlan { - parent_identity: None, - auth: AuthMethod::Email, - helper_action: None, - }), - }, - IdentityLevel::SubIdentity => { - let parent_identity = target - .parent() - .expect("BUG: sub-identity should always expose its parent") - .as_partial() - .to_string(); - match requested_auth { - Some(AuthMethod::Identity) => { - let Some(ready_parent_identity) = ready_parent_identity else { - whatever!( - "creating {} with --auth identity requires a ready parent identity saved here", - target.short_name() - ); - }; - Ok(CreateApprovalPlan { - parent_identity: Some(ready_parent_identity.to_string()), - auth: AuthMethod::Identity, - helper_action: None, - }) - } - Some(AuthMethod::Email) => Ok(CreateApprovalPlan { - parent_identity: Some(parent_identity), - auth: AuthMethod::Email, - helper_action: None, - }), - None => { - if ready_parent_identity.is_some() { - whatever!( - "creating {} non-interactively requires choosing an approval path; rerun with --auth email or --auth identity", - target.short_name() - ); - } - Ok(CreateApprovalPlan { - parent_identity: Some(parent_identity), - auth: AuthMethod::Email, - helper_action: None, - }) - } - } - } - } -} - -async fn resolve_approval_plan( - target: &IdentityTarget, - requested_auth: Option, - ready_parent_identity: Option<&str>, - is_interactive: bool, -) -> Result { - if !is_interactive { - return resolve_non_interactive_approval_plan( - target, - requested_auth, - ready_parent_identity, - ); - } - - match requested_auth { - Some(auth) => { - resolve_non_interactive_approval_plan(target, Some(auth), ready_parent_identity) - } - None => match target.level() { - IdentityLevel::Identity => Ok(CreateApprovalPlan { - parent_identity: None, - auth: AuthMethod::Email, - helper_action: None, - }), - IdentityLevel::SubIdentity => { - let parent = target - .parent() - .whatever_context::<_, Error>( - "sub-identity target is missing its parent identity", - )? - .into_owned(); - let candidate = if let Some(parent_identity) = ready_parent_identity { - Some(approval::LocalApprovalCandidate::ready( - parent.as_partial(), - parent_identity, - )) - } else { - Some(approval::LocalApprovalCandidate::missing( - parent.as_partial(), - parent.as_full(), - )) - }; - let options = build_create_approval_options(candidate); - let labels = options - .iter() - .map(approval::ApprovalMenuOption::label) - .collect::>(); - let message = format!("Choose how to verify creating {}:", target.short_name()); - let selected = prompt::prompt_select_string(&message, labels) - .await - .require_interactive("--auth")?; - create_plan_from_selection(&options, &selected, Some(parent.as_full())) - } - }, - } -} - -pub(crate) fn ensure_non_interactive_root_checkout_not_required( - target: &IdentityTarget, - response: &CreateDomainResponse, -) -> Result<(), Error> { - match crate::checkout::classify_checkout(response) { - crate::checkout::CheckoutState::Completed => Ok(()), - crate::checkout::CheckoutState::Pending - | crate::checkout::CheckoutState::Expired - | crate::checkout::CheckoutState::Cancelled => whatever!( - "creating {} requires interactive checkout; rerun this command in an interactive terminal to complete payment", - target.short_name() - ), - } -} - -pub(crate) fn ensure_non_interactive_sub_identity_checkout_not_required( - target: &IdentityTarget, - response: &CreateSubdomainResponse, -) -> Result<(), Error> { - if response.invoice.is_some() { - whatever!( - "creating {} exceeded the sub-identity quota and requires interactive checkout; rerun this command in an interactive terminal to expand the parent identity quota", - target.short_name() - ); - } - Ok(()) -} - -fn create_identity_name_opening() -> &'static str { - "Create a new identity here.\n\nThis will create a new identity or sub-identity, complete the required verification,\nand save it here.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo create a sub-identity, add one more name before it:\n phone.alice.smith" -} - -async fn prompt_create_email_action( - can_switch_verification_method: bool, -) -> Result { - let actions = create_email_actions(can_switch_verification_method); - let labels = actions - .iter() - .map(CreateEmailAction::label) - .collect::>(); - let selected = prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected create email action is unavailable") -} - -async fn prompt_create_verify_code_action( - return_target_name: Option<&str>, -) -> Result { - let actions = create_verify_code_actions(return_target_name); - let labels = actions - .iter() - .map(CreateVerifyCodeAction::label) - .collect::>(); - let selected = prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected verification-code action is unavailable") -} - -async fn prompt_create_approval_menu_action() -> Result { - let actions = [ - CreateApprovalMenuAction::ChangeCertificateKind, - CreateApprovalMenuAction::ChangeIdentityName, - ]; - let labels = actions - .iter() - .map(|action| action.label()) - .collect::>(); - let selected = prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected create approval action is unavailable") -} - -pub(crate) async fn prompt_restart_checkout(message: &str) -> Result { - let message = message.to_string(); - Ok( - prompt::sync(move || inquire::Confirm::new(&message).with_default(true).prompt()) - .await - .require_interactive("interactive input")?, - ) -} - -fn can_switch_verification_method(target: &IdentityTarget) -> bool { - target.level() == IdentityLevel::SubIdentity -} - -pub(crate) fn print_root_checkout_instructions( - target: &IdentityTarget, - response: &CreateDomainResponse, -) { - if let Some(block) = - root_checkout_instruction_block(target, response, std::io::stderr().is_terminal()) - { - crate::cli::flow::transcript::print_err_block(&block); - } -} - -fn root_checkout_summary(target: &IdentityTarget) -> String { - format!("Payment is required to create {}.", target.short_name()) -} - -fn root_checkout_instruction_block( - target: &IdentityTarget, - response: &CreateDomainResponse, - include_qr: bool, -) -> Option { - response.payment_entry.as_ref().map(|payment_entry| { - crate::checkout::checkout_instruction_block( - &root_checkout_summary(target), - &payment_entry.url, - include_qr, - ) - }) -} - -fn print_subdomain_checkout_instructions( - target: &IdentityTarget, - invoice: &InvoiceDetail, - quote: &SubdomainQuotaQuote, -) { - crate::cli::flow::transcript::print_err_block(&subdomain_checkout_instruction_block( - target, - invoice, - quote, - std::io::stderr().is_terminal(), - )); -} - -fn subdomain_checkout_summary(target: &IdentityTarget, quote: &SubdomainQuotaQuote) -> String { - format!( - "Creating {} exceeded the sub-identity quota.\n\nAmount due now to expand and continue: {} {}", - target.short_name(), - quote.currency, - format_minor_amount(quote.due), - ) -} - -fn subdomain_checkout_instruction_block( - target: &IdentityTarget, - invoice: &InvoiceDetail, - quote: &SubdomainQuotaQuote, - include_qr: bool, -) -> String { - crate::checkout::checkout_instruction_block( - &subdomain_checkout_summary(target, quote), - &invoice.url, - include_qr, - ) -} - -fn format_minor_amount(amount: i64) -> String { - let major = amount / 100; - let cents = amount.abs() % 100; - format!("{major}.{cents:02}") -} - -async fn resolve_target(command: &Create) -> Result { - match command.name.as_deref() { - Some(identity) => Ok(IdentityTarget::parse(identity)?), - None => { - let identity = prompt::prompt_identity_name(create_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - Ok(IdentityTarget::parse(&identity)?) - } - } -} - -async fn resolve_kind(command: &Create) -> Result { - match command.kind.as_deref() { - Some(kind) => Ok(kind.parse::()?), - None => Ok(prompt::prompt_kind() - .await - .require_interactive("--kind")? - .parse::()?), - } -} - -async fn resolve_email(command: &Create) -> Result { - match command.email.clone() { - Some(email) => Ok(email), - None => Ok(prompt::prompt_email() - .await - .require_interactive("--email")?), - } -} - -async fn resolve_verify_code( - cert_server: &CertServer, - email: &str, - provided_verify_code: Option, -) -> Result { - match provided_verify_code { - Some(code) => Ok(code), - None => { - super::progress::run_with_spinner( - "Sending verification code...", - cert_server.send_email_verification(email), - ) - .await?; - Ok(prompt::prompt_verify_code() - .await - .require_interactive("--verify-code")?) - } - } -} - -async fn ensure_parent_identity_ready( - dhttp_home: &DhttpHome, - target: &IdentityTarget, -) -> Result, Error> { - let parent = target - .parent() - .whatever_context::<_, Error>("sub-identity target is missing its parent identity")? - .into_owned(); - let summary = local::load_summary(dhttp_home, parent.borrow(), None).await?; - match summary.status { - LocalIdentityStatus::Ready { .. } => Ok(parent), - _ => whatever!( - "creating {} with --auth identity requires a ready parent identity saved here at {}", - target.short_name(), - summary.saved_at.display() - ), - } -} - -async fn resolve_ready_parent_identity( - dhttp_home: &DhttpHome, - target: &IdentityTarget, -) -> Result>, Error> { - if target.level() != IdentityLevel::SubIdentity { - return Ok(None); - } - - let Some(parent) = target.parent() else { - whatever!("sub-identity target is missing its parent identity"); - }; - if !dhttp_home - .identity_profile_exists_exactly(parent.clone()) - .await - { - return Ok(None); - } - - let summary = local::load_summary(dhttp_home, parent.clone(), None).await?; - if summary.status.is_ready() { - return Ok(Some(parent.into_owned())); - } - - Ok(None) -} - -async fn resolve_parent_candidate( - dhttp_home: &DhttpHome, - target: &IdentityTarget, -) -> Result, Error> { - if target.level() != IdentityLevel::SubIdentity { - return Ok(None); - } - - let parent = target - .parent() - .whatever_context::<_, Error>("sub-identity target is missing its parent identity")? - .into_owned(); - if !dhttp_home - .identity_profile_exists_exactly(parent.clone()) - .await - { - return Ok(Some(approval::LocalApprovalCandidate::missing( - parent.as_partial(), - parent.as_full(), - ))); - } - - let summary = local::load_summary(dhttp_home, parent.borrow(), None).await?; - Ok(Some(create_candidate_from_summary(&summary))) -} - -async fn run_helper_apply_parent( - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, - target: &IdentityTarget, - parent_identity: &str, - replace_local: bool, -) -> Result { - let short_parent_identity = IdentityTarget::parse(parent_identity) - .map(|target| target.short_name().to_string()) - .unwrap_or_else(|_| parent_identity.to_string()); - let verb = if replace_local { "re-apply" } else { "apply" }; - crate::cli::flow::transcript::print_block(&format!( - "This command needs {short_parent_identity} saved here first. - -To continue creating {}, it will {verb} {short_parent_identity} here, then return here and continue verification.", - target.short_name() - )); - let command = crate::cli::Apply { - name: Some(parent_identity.to_string()), - kind: None, - replace_local, - device_name: None, - email: None, - send_code: false, - verify_code: None, - auth: None, - }; - match super::apply::run_interactive( - &command, - dhttp_home, - home_scope, - cert_server, - Some(&format!("create {}", target.short_name())), - ) - .await? - { - super::apply::ApplyRunOutcome::Applied => Ok(true), - super::apply::ApplyRunOutcome::ReturnedToCaller => Ok(false), - } -} - -async fn run_helper_parent_action( - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, - target: &IdentityTarget, - parent_identity: &str, - action: approval::ApprovalHelperAction, -) -> Result { - match action { - approval::ApprovalHelperAction::Apply => { - run_helper_apply_parent( - dhttp_home, - home_scope, - cert_server, - target, - parent_identity, - false, - ) - .await - } - approval::ApprovalHelperAction::Reapply => { - run_helper_apply_parent( - dhttp_home, - home_scope, - cert_server, - target, - parent_identity, - true, - ) - .await - } - approval::ApprovalHelperAction::Renew => { - super::renew::run_helper_for_verification( - dhttp_home, - cert_server, - parent_identity, - &format!("create {}", target.short_name()), - ) - .await - } - } -} - -async fn complete_root_identity_create_interactively( - cert_server: &CertServer, - target: &IdentityTarget, - email: &str, - verify_code: &str, -) -> Result { - loop { - let created = match super::progress::run_with_spinner( - "Creating identity...", - cert_server.create_domain_with_email(target.full_name(), email, verify_code), - ) - .await - { - Ok(created) => created, - Err(source) => { - return Err(CompleteRootIdentityCreateInteractivelyError::Verification { source }); - } - }; - if created.payment_entry.is_none() { - return Ok(created); - } - - print_root_checkout_instructions(target, &created); - let completed = match crate::checkout::wait_for_checkout_completion( - cert_server, - &created - .payment_entry - .as_ref() - .expect("payment entry just checked") - .checkout_token, - ) - .await - { - Ok(completed) => completed, - Err(source) => { - return Err(CompleteRootIdentityCreateInteractivelyError::Flow { - source: Error::from(source), - }); - } - }; - match crate::checkout::classify_checkout(&completed) { - crate::checkout::CheckoutState::Completed => return Ok(created), - crate::checkout::CheckoutState::Expired => { - let restart = match prompt_restart_checkout( - "This checkout expired. Start a new checkout for this identity?", - ) - .await - { - Ok(restart) => restart, - Err(source) => { - return Err(CompleteRootIdentityCreateInteractivelyError::Flow { source }); - } - }; - if !restart { - return Err(CompleteRootIdentityCreateInteractivelyError::Flow { - source: Error::without_source("checkout was not completed".to_string()), - }); - } - } - crate::checkout::CheckoutState::Cancelled => { - let restart = match prompt_restart_checkout( - "This checkout was cancelled. Start a new checkout for this identity?", - ) - .await - { - Ok(restart) => restart, - Err(source) => { - return Err(CompleteRootIdentityCreateInteractivelyError::Flow { source }); - } - }; - if !restart { - return Err(CompleteRootIdentityCreateInteractivelyError::Flow { - source: Error::without_source("checkout was not completed".to_string()), - }); - } - } - crate::checkout::CheckoutState::Pending => { - return Err(CompleteRootIdentityCreateInteractivelyError::Flow { - source: Error::without_source( - "checkout did not reach a terminal state".to_string(), - ), - }); - } - } - } -} - -async fn wait_for_invoice_terminal( - cert_server: &CertServer, - access_token: &str, - invoice_no: &str, -) -> Result { - super::progress::run_with_spinner("Waiting for payment confirmation...", async { - loop { - let invoice = cert_server.get_invoice(access_token, invoice_no).await?; - match invoice.status.as_str() { - "paid" | "expired" | "cancelled" | "canceled" => return Ok(invoice), - _ => tokio::time::sleep(std::time::Duration::from_secs(3)).await, - } - } - }) - .await -} - -pub(crate) async fn ensure_root_identity_exists_with_token( - cert_server: &CertServer, - parent: &dhttp::name::DhttpName<'_>, - access_token: &str, -) -> Result<(), Error> { - let parent_target = IdentityTarget::parse(parent.as_partial())?; - ensure_identity_exists_with_token( - cert_server, - &parent_target, - access_token, - "Creating parent identity...", - ) - .await -} - -pub(crate) async fn ensure_identity_exists_with_token( - cert_server: &CertServer, - target: &IdentityTarget, - access_token: &str, - progress_message: &str, -) -> Result<(), Error> { - let created = match super::progress::run_with_spinner( - progress_message, - cert_server.create_domain_with_token(access_token, target.full_name()), - ) - .await - { - Ok(created) => created, - Err(error) if is_domain_conflict(&error) => return Ok(()), - Err(error) => return Err(Error::from(error)), - }; - - ensure_non_interactive_root_checkout_not_required(target, &created)?; - Ok(()) -} - -pub(crate) async fn ensure_root_identity_exists_with_token_interactively( - cert_server: &CertServer, - parent: &dhttp::name::DhttpName<'_>, - access_token: &str, -) -> Result<(), Error> { - let parent_target = IdentityTarget::parse(parent.as_partial())?; - ensure_identity_exists_with_token_interactively( - cert_server, - &parent_target, - access_token, - "Creating parent identity...", - ) - .await -} - -pub(crate) async fn ensure_identity_exists_with_token_interactively( - cert_server: &CertServer, - target: &IdentityTarget, - access_token: &str, - progress_message: &str, -) -> Result<(), Error> { - loop { - let created = match super::progress::run_with_spinner( - progress_message, - cert_server.create_domain_with_token(access_token, target.full_name()), - ) - .await - { - Ok(created) => created, - Err(error) if is_domain_conflict(&error) => return Ok(()), - Err(error) => return Err(Error::from(error)), - }; - - if created.payment_entry.is_none() { - return Ok(()); - } - - print_root_checkout_instructions(target, &created); - let completed = crate::checkout::wait_for_checkout_completion( - cert_server, - &created - .payment_entry - .as_ref() - .expect("payment entry just checked") - .checkout_token, - ) - .await?; - - match crate::checkout::classify_checkout(&completed) { - crate::checkout::CheckoutState::Completed => return Ok(()), - crate::checkout::CheckoutState::Expired => { - if !prompt_restart_checkout( - "This checkout expired. Start a new checkout for this identity?", - ) - .await? - { - whatever!("checkout was not completed"); - } - } - crate::checkout::CheckoutState::Cancelled => { - if !prompt_restart_checkout( - "This checkout was cancelled. Start a new checkout for this identity?", - ) - .await? - { - whatever!("checkout was not completed"); - } - } - crate::checkout::CheckoutState::Pending => { - whatever!("checkout did not reach a terminal state"); - } - } - } -} - -pub(crate) async fn create_sub_identity_with_token( - cert_server: &CertServer, - _target: &IdentityTarget, - access_token: &str, - parent: &dhttp::name::DhttpName<'_>, - label: &str, -) -> Result { - match super::progress::run_with_spinner( - "Creating sub-identity...", - cert_server.create_subdomain(access_token, parent.as_full(), label, None), - ) - .await - { - Ok(created) => Ok(created), - Err(error) if is_domain_not_found(&error) => { - ensure_root_identity_exists_with_token(cert_server, parent, access_token).await?; - super::progress::run_with_spinner( - "Creating sub-identity...", - cert_server.create_subdomain(access_token, parent.as_full(), label, None), - ) - .await - .map_err(Error::from) - } - Err(error) => Err(Error::from(error)), - } -} - -pub(crate) async fn create_sub_identity_with_token_interactively( - cert_server: &CertServer, - target: &IdentityTarget, - access_token: &str, - parent: &dhttp::name::DhttpName<'_>, - label: &str, -) -> Result { - loop { - match super::progress::run_with_spinner( - "Creating sub-identity...", - cert_server.create_subdomain_attempt(access_token, parent.as_full(), label, None), - ) - .await - { - Ok(CreateSubdomainAttempt::Created(response)) => return Ok(response), - Ok(CreateSubdomainAttempt::QuotaExceeded(quote)) => { - let continue_checkout = prompt_restart_checkout(&format!( - "Creating {} exceeded the sub-identity quota under {}. Expand quota and continue?", - target.short_name(), - parent.as_partial() - )) - .await?; - if !continue_checkout { - whatever!("checkout was not completed"); - } - - loop { - let invoice_response = match super::progress::run_with_spinner( - "Creating sub-identity...", - cert_server.create_subdomain_attempt( - access_token, - parent.as_full(), - label, - Some(quote.due), - ), - ) - .await? - { - CreateSubdomainAttempt::Created(response) => response, - CreateSubdomainAttempt::QuotaExceeded(_) => { - whatever!("subdomain quota expansion quote changed during checkout") - } - }; - let invoice_no = invoice_response - .invoice - .as_ref() - .map(|invoice| invoice.number.as_str()) - .whatever_context::<_, Error>( - "quota expansion checkout did not return an invoice number", - )?; - let invoice = super::progress::run_with_spinner( - "Loading payment details...", - cert_server.get_invoice(access_token, invoice_no), - ) - .await?; - print_subdomain_checkout_instructions(target, &invoice, "e); - let invoice = - wait_for_invoice_terminal(cert_server, access_token, invoice_no).await?; - match invoice.status.as_str() { - "paid" => break, - "expired" => { - if !prompt_restart_checkout( - "This checkout expired. Start a new checkout for this sub-identity slot?", - ) - .await? - { - whatever!("checkout was not completed"); - } - } - "cancelled" | "canceled" => { - if !prompt_restart_checkout( - "This checkout was cancelled. Start a new checkout for this sub-identity slot?", - ) - .await? - { - whatever!("checkout was not completed"); - } - } - _ => whatever!("invoice did not reach a terminal state"), - } - } - } - Err(error) if is_domain_not_found(&error) => { - ensure_root_identity_exists_with_token_interactively( - cert_server, - parent, - access_token, - ) - .await?; - crate::cli::flow::transcript::print_line( - "Parent identity was created. Continuing with sub-identity creation...", - ); - } - Err(error) => return Err(Error::from(error)), - } - } -} - -async fn run_interactive( - command: &Create, - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, -) -> Result<(), Error> { - let default_identity_when_command_started = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let mut state = InteractiveCreateState::from_command(command)?; - - loop { - if state.target.is_none() { - let identity = prompt::prompt_identity_name(create_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - state.target = Some(IdentityTarget::parse(&identity)?); - state.target_opening_required = false; - continue; - } - - let target = state - .target - .clone() - .whatever_context::<_, Error>("interactive create target is unavailable")?; - - if state.target_opening_required { - let identity = prompt::prompt_identity_name_with_default( - create_identity_name_opening(), - Some(target.short_name()), - ) - .await - .require_interactive("IDENTITY")?; - let parsed = IdentityTarget::parse(&identity)?; - if parsed != target { - state.target = Some(parsed); - state.reset_after_target_change(); - } - state.target_opening_required = false; - continue; - } - - if state.kind.is_none() || state.kind_prompt_required { - state.kind = Some( - prompt::prompt_kind_with_cursor(state.kind) - .await - .require_interactive("--kind")? - .parse::()?, - ); - state.kind_prompt_required = false; - continue; - } - - let ready_parent_identity = resolve_ready_parent_identity(dhttp_home, &target).await?; - let parent_candidate = resolve_parent_candidate(dhttp_home, &target).await?; - if state.approval_plan.is_none() { - if let Some(auth) = command.auth { - state.approval_plan = Some(resolve_non_interactive_approval_plan( - &target, - Some(auth), - ready_parent_identity.as_ref().map(|name| name.as_full()), - )?); - continue; - } - - match target.level() { - IdentityLevel::Identity => { - state.approval_plan = Some(CreateApprovalPlan { - parent_identity: None, - auth: AuthMethod::Email, - helper_action: None, - }); - } - IdentityLevel::SubIdentity => { - let parent_identity = target - .parent() - .whatever_context::<_, Error>( - "sub-identity target is missing its parent identity", - )? - .into_owned(); - let options = build_create_approval_options(parent_candidate.clone()); - let mut labels = options - .iter() - .map(approval::ApprovalMenuOption::label) - .collect::>(); - labels.push(prompt::MORE_OPTIONS_LABEL.to_string()); - let message = format!("Choose how to verify creating {}:", target.short_name()); - let selected = prompt::prompt_select_string(&message, labels) - .await - .require_interactive("--auth")?; - if selected == prompt::MORE_OPTIONS_LABEL { - match prompt_create_approval_menu_action().await? { - CreateApprovalMenuAction::ChangeCertificateKind => { - state.revisit_kind_prompt(); - } - CreateApprovalMenuAction::ChangeIdentityName => { - state.revisit_target_prompt(); - } - } - } else { - state.approval_plan = Some(create_plan_from_selection( - &options, - &selected, - Some(parent_identity.as_full()), - )?); - } - } - } - continue; - } - - let approval_plan = state - .approval_plan - .clone() - .whatever_context::<_, Error>("interactive create approval plan is unavailable")?; - if let Some(action) = approval_plan.helper_action.clone() { - let parent_identity = approval_plan - .parent_identity - .as_deref() - .whatever_context::<_, Error>("helper apply path is missing its parent identity")?; - if !run_helper_parent_action( - dhttp_home, - home_scope, - cert_server, - &target, - parent_identity, - action, - ) - .await? - { - state.approval_plan = None; - state.reset_after_approval_change(); - continue; - } - state.approval_plan = Some(CreateApprovalPlan { - parent_identity: Some(parent_identity.to_string()), - auth: AuthMethod::Identity, - helper_action: None, - }); - continue; - } - - if matches!(approval_plan.auth, AuthMethod::Email) - && (state.email.is_none() || state.email_prompt_required) - { - match prompt::prompt_email_with_more_options(state.email.as_deref()) - .await - .require_interactive("--email")? - { - prompt::TextPromptResult::Submitted(email) => { - state.email = Some(email); - state.email_prompt_required = false; - } - prompt::TextPromptResult::MoreOptions => { - match prompt_create_email_action(can_switch_verification_method(&target)) - .await? - { - CreateEmailAction::SwitchVerificationMethod => { - state.approval_plan = None; - state.reset_after_approval_change(); - } - CreateEmailAction::ChangeCertificateKind => { - state.revisit_kind_prompt(); - } - CreateEmailAction::ChangeIdentityName => { - state.revisit_target_prompt(); - } - } - } - } - continue; - } - - if matches!(approval_plan.auth, AuthMethod::Email) && state.verify_code.is_none() { - let email = state - .email - .clone() - .whatever_context::<_, Error>("interactive create email is unavailable")?; - if state.verification_code_sent_to.as_deref() != Some(email.as_str()) { - match super::progress::run_with_spinner( - "Sending verification code...", - cert_server.send_email_verification(&email), - ) - .await - { - Ok(_) => { - state.verification_code_sent_to = Some(email.clone()); - } - Err(error) => { - let recovery = crate::cli::flow::recovery::classify_resend_error(&error); - if matches!( - recovery, - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } - ) { - state.verification_code_sent_to = Some(email.clone()); - } - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - } - } - match prompt::prompt_verify_code_with_more_options(None) - .await - .require_interactive("--verify-code")? - { - prompt::TextPromptResult::Submitted(code) => { - state.verify_code = Some(code); - } - prompt::TextPromptResult::MoreOptions => { - match prompt_create_verify_code_action(None).await? { - CreateVerifyCodeAction::ResendVerificationCode => { - match super::progress::run_with_spinner( - "Sending verification code...", - cert_server.send_email_verification(&email), - ) - .await - { - Ok(_) => { - state.verification_code_sent_to = Some(email); - } - Err(error) => { - let recovery = - crate::cli::flow::recovery::classify_resend_error(&error); - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - } - } - CreateVerifyCodeAction::ChangeEmail => { - state.revisit_email_prompt(); - } - CreateVerifyCodeAction::SwitchVerificationMethod => { - state.approval_plan = None; - state.reset_after_approval_change(); - } - CreateVerifyCodeAction::ChangeCertificateKind => { - state.revisit_kind_prompt(); - } - CreateVerifyCodeAction::ChangeIdentityName => { - state.revisit_target_prompt(); - } - CreateVerifyCodeAction::ReturnToCreate { .. } => { - state.approval_plan = None; - state.reset_after_approval_change(); - } - } - } - } - continue; - } - - let local_identity_save = cli::ensure_replace_local_allowed( - dhttp_home, - target.dhttp_name(), - command.replace_local, - ) - .await?; - let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&target.dhttp_name())?; - let kind = state - .kind - .whatever_context::<_, Error>("interactive create kind is unavailable")?; - - match target.level() { - IdentityLevel::Identity => { - let email = state - .email - .clone() - .whatever_context::<_, Error>("interactive create email is unavailable")?; - let verify_code = state.verify_code.clone().whatever_context::<_, Error>( - "interactive create verification code is unavailable", - )?; - let created = match complete_root_identity_create_interactively( - cert_server, - &target, - &email, - &verify_code, - ) - .await - { - Ok(created) => created, - Err(CompleteRootIdentityCreateInteractivelyError::Verification { source }) => { - let recovery = - crate::cli::flow::recovery::classify_verify_submit_error(&source); - if matches!( - recovery, - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } - ) { - state.verify_code = None; - } - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(source)); - } - Err(CompleteRootIdentityCreateInteractivelyError::Flow { source }) => { - return Err(source); - } - }; - let access_token = if let Some(auth) = created.auth { - auth.access_token - } else { - match super::progress::run_with_spinner( - "Verifying with email...", - cert_server.login(&email, &verify_code), - ) - .await - { - Ok(login) => login.access_token, - Err(error) => { - let recovery = - crate::cli::flow::recovery::classify_verify_submit_error(&error); - if matches!( - recovery, - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } - ) { - state.verify_code = None; - } - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - } - }; - let cert = super::progress::run_with_spinner( - "Applying identity...", - cert_server.issue_cert( - &access_token, - target.full_name(), - kind.as_str(), - None, - &super::device::resolve_device_name( - command.device_name.as_deref(), - home_scope, - ), - &csr_pem, - ), - ) - .await?; - cli::save_identity( - dhttp_home, - &target.dhttp_name(), - key_pem.as_bytes(), - cert.cert_pem.as_bytes(), - ) - .instrument(super::progress::save_identity_span()) - .await?; - } - IdentityLevel::SubIdentity => { - let parent = target - .parent() - .whatever_context::<_, Error>( - "sub-identity target is missing its parent identity", - )? - .into_owned(); - let label = target.sub_identity_label().whatever_context::<_, Error>( - "sub-identity target is missing its direct child label", - )?; - match approval_plan.auth { - AuthMethod::Email => { - let email = state.email.clone().whatever_context::<_, Error>( - "interactive create email is unavailable", - )?; - let verify_code = state.verify_code.clone().whatever_context::<_, Error>( - "interactive create verification code is unavailable", - )?; - let access_token = match super::progress::run_with_spinner( - "Verifying with email...", - cert_server.login(&email, &verify_code), - ) - .await - { - Ok(login) => login.access_token, - Err(error) => { - let recovery = - crate::cli::flow::recovery::classify_verify_submit_error( - &error, - ); - if matches!( - recovery, - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { - .. - } - ) { - state.verify_code = None; - } - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - }; - create_sub_identity_with_token_interactively( - cert_server, - &target, - &access_token, - &parent, - label, - ) - .await?; - let cert = super::progress::run_with_spinner( - "Applying identity...", - cert_server.issue_cert( - &access_token, - target.full_name(), - kind.as_str(), - None, - &super::device::resolve_device_name( - command.device_name.as_deref(), - home_scope, - ), - &csr_pem, - ), - ) - .await?; - cli::save_identity( - dhttp_home, - &target.dhttp_name(), - key_pem.as_bytes(), - cert.cert_pem.as_bytes(), - ) - .instrument(super::progress::save_identity_span()) - .await?; - } - AuthMethod::Identity => { - let ready_parent = - match ensure_parent_identity_ready(dhttp_home, &target).await { - Ok(parent) => parent, - Err(error) => { - let rendered = error.to_string(); - if rendered.contains("ready local parent identity") { - state.approval_plan = None; - state.reset_after_approval_change(); - continue; - } - return Err(error); - } - }; - match super::progress::run_with_spinner( - "Creating sub-identity...", - cert_server.create_subdomain_with_identity( - ready_parent.as_full(), - parent.as_full(), - label, - None, - ), - ) - .await - { - Ok(_) => {} - Err(error) if is_subdomain_quota_exceeded(&error) => { - crate::cli::flow::transcript::print_block(&format!( - "Creating {} exceeded the sub-identity quota under {}.\nChoose email verification to expand quota and continue.", - target.short_name(), - parent.as_partial(), - )); - state.approval_plan = None; - state.reset_after_approval_change(); - continue; - } - Err(error) => return Err(Error::from(error)), - } - let cert = super::progress::run_with_spinner( - "Verifying with local identity...", - cert_server.issue_cert_with_identity( - ready_parent.as_full(), - target.full_name(), - kind.as_str(), - None, - &super::device::resolve_device_name( - command.device_name.as_deref(), - home_scope, - ), - &csr_pem, - ), - ) - .await?; - cli::save_identity( - dhttp_home, - &target.dhttp_name(), - key_pem.as_bytes(), - cert.cert_pem.as_bytes(), - ) - .instrument(super::progress::save_identity_span()) - .await?; - } - } - } - } - - let welcome = super::welcome::maybe_create_welcome_service( - dhttp_home, - target.dhttp_name(), - local_identity_save.created_new_identity(), - ) - .await?; - crate::cli::flow::epilogue::run_lifecycle_epilogue( - dhttp_home, - target.dhttp_name(), - default_identity_when_command_started.clone(), - std::io::stdin().is_terminal(), - super::output::SavedIdentityAction::Created, - welcome.as_ref(), - ) - .await?; - return Ok(()); - } -} - -pub(crate) async fn run_with_policy( - command: &Create, - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, -) -> Result<(), Error> { - let is_interactive = std::io::stdin().is_terminal(); - if is_interactive && !command.send_code { - return run_interactive(command, dhttp_home, home_scope, cert_server).await; - } - let default_identity_when_command_started = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let target = resolve_target(command).await?; - let kind = resolve_kind(command).await?; - let ready_parent_identity = resolve_ready_parent_identity(dhttp_home, &target).await?; - let approval_plan = resolve_approval_plan( - &target, - command.auth, - ready_parent_identity.as_ref().map(|name| name.as_full()), - is_interactive, - ) - .await?; - let device_name = - super::device::resolve_device_name(command.device_name.as_deref(), home_scope); - - if command.send_code { - if !matches!(command.auth, Some(AuthMethod::Email)) { - whatever!("--send-code requires --auth email"); - } - let email = resolve_email(command).await?; - super::progress::run_with_spinner( - "Sending verification code...", - cert_server.send_email_verification(&email), - ) - .await?; - return Ok(()); - } - - if let Some(action) = approval_plan.helper_action.clone() { - let parent_identity = approval_plan - .parent_identity - .as_deref() - .whatever_context::<_, Error>("helper apply path is missing its parent identity")?; - if !run_helper_parent_action( - dhttp_home, - home_scope, - cert_server, - &target, - parent_identity, - action, - ) - .await? - { - whatever!("create was cancelled"); - } - } - - let local_identity_save = - cli::ensure_replace_local_allowed(dhttp_home, target.dhttp_name(), command.replace_local) - .await?; - let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&target.dhttp_name())?; - - match target.level() { - IdentityLevel::Identity => { - let email = resolve_email(command).await?; - let verify_code = - resolve_verify_code(cert_server, &email, command.verify_code.clone()).await?; - let created = super::progress::run_with_spinner( - "Creating identity...", - cert_server.create_domain_with_email(target.full_name(), &email, &verify_code), - ) - .await?; - - if let Some(payment_entry) = created.payment_entry.as_ref() { - if !is_interactive { - ensure_non_interactive_root_checkout_not_required(&target, &created)?; - } - print_root_checkout_instructions(&target, &created); - let completed = crate::checkout::wait_for_checkout_completion( - cert_server, - &payment_entry.checkout_token, - ) - .await?; - match crate::checkout::classify_checkout(&completed) { - crate::checkout::CheckoutState::Completed => {} - crate::checkout::CheckoutState::Expired => { - whatever!("checkout expired before payment completed") - } - crate::checkout::CheckoutState::Cancelled => { - whatever!("checkout was cancelled") - } - crate::checkout::CheckoutState::Pending => { - whatever!("checkout did not reach a terminal state") - } - } - } - - let access_token = if let Some(auth) = created.auth { - auth.access_token - } else { - super::progress::run_with_spinner( - "Verifying with email...", - cert_server.login(&email, &verify_code), - ) - .await? - .access_token - }; - let cert = super::progress::run_with_spinner( - "Applying identity...", - cert_server.issue_cert( - &access_token, - target.full_name(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await?; - cli::save_identity( - dhttp_home, - &target.dhttp_name(), - key_pem.as_bytes(), - cert.cert_pem.as_bytes(), - ) - .instrument(super::progress::save_identity_span()) - .await?; - } - IdentityLevel::SubIdentity => { - let parent = target - .parent() - .whatever_context::<_, Error>("sub-identity target is missing its parent identity")? - .into_owned(); - let label = target.sub_identity_label().whatever_context::<_, Error>( - "sub-identity target is missing its direct child label", - )?; - match approval_plan.auth { - AuthMethod::Email => { - let email = resolve_email(command).await?; - let verify_code = - resolve_verify_code(cert_server, &email, command.verify_code.clone()) - .await?; - let access_token = super::progress::run_with_spinner( - "Verifying with email...", - cert_server.login(&email, &verify_code), - ) - .await? - .access_token; - let created = create_sub_identity_with_token( - cert_server, - &target, - &access_token, - &parent, - label, - ) - .await?; - ensure_non_interactive_sub_identity_checkout_not_required(&target, &created)?; - let cert = super::progress::run_with_spinner( - "Applying identity...", - cert_server.issue_cert( - &access_token, - target.full_name(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await?; - cli::save_identity( - dhttp_home, - &target.dhttp_name(), - key_pem.as_bytes(), - cert.cert_pem.as_bytes(), - ) - .instrument(super::progress::save_identity_span()) - .await?; - } - AuthMethod::Identity => { - let ready_parent = ensure_parent_identity_ready(dhttp_home, &target).await?; - let created = super::progress::run_with_spinner( - "Creating sub-identity...", - cert_server.create_subdomain_with_identity( - ready_parent.as_full(), - parent.as_full(), - label, - None, - ), - ) - .await?; - ensure_non_interactive_sub_identity_checkout_not_required(&target, &created)?; - let cert = super::progress::run_with_spinner( - "Verifying with local identity...", - cert_server.issue_cert_with_identity( - ready_parent.as_full(), - target.full_name(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await?; - cli::save_identity( - dhttp_home, - &target.dhttp_name(), - key_pem.as_bytes(), - cert.cert_pem.as_bytes(), - ) - .instrument(super::progress::save_identity_span()) - .await?; - } - } - } - } - - let welcome = super::welcome::maybe_create_welcome_service( - dhttp_home, - target.dhttp_name(), - local_identity_save.created_new_identity(), - ) - .await?; - crate::cli::flow::epilogue::run_lifecycle_epilogue( - dhttp_home, - target.dhttp_name(), - default_identity_when_command_started, - is_interactive, - super::output::SavedIdentityAction::Created, - welcome.as_ref(), - ) - .await -} - -pub(crate) async fn run( - command: &Create, - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, -) -> Result<(), Error> { - run_with_policy(command, dhttp_home, home_scope, cert_server).await -} - -#[cfg(test)] -mod tests { - use super::{ - CreateApprovalPlan, CreateEmailAction, CreateVerifyCodeAction, InteractiveCreateState, - build_create_approval_options, create_email_actions, create_verify_code_actions, - ensure_non_interactive_root_checkout_not_required, - ensure_non_interactive_sub_identity_checkout_not_required, - resolve_non_interactive_approval_plan, root_checkout_instruction_block, - subdomain_checkout_instruction_block, - }; - use crate::{ - auth::AuthMethod, - cert_server::{ - CreateDomainResponse, CreateSubdomainResponse, InvoiceDetail, SubdomainQuotaQuote, - }, - cli::{ - Create, - flow::{ - approval::{ApprovalMenuOption, LocalApprovalCandidate}, - target::IdentityTarget, - }, - }, - }; - - #[test] - fn stay_recovery_keeps_create_verify_state() { - let mut state = InteractiveCreateState::from_command(&Create { - name: Some("alice.smith".to_string()), - kind: Some("primary".to_string()), - replace_local: false, - device_name: None, - email: Some("alice@example.test".to_string()), - send_code: false, - verify_code: None, - auth: None, - }) - .unwrap(); - state.verify_code = Some("123456".to_string()); - state.verification_code_sent_to = Some("alice@example.test".to_string()); - - super::apply_verification_recovery( - &mut state, - &crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { - message: "retry later".to_string(), - }, - ); - - assert_eq!(state.verify_code.as_deref(), Some("123456")); - assert_eq!( - state.verification_code_sent_to.as_deref(), - Some("alice@example.test") - ); - } - - #[test] - fn back_to_email_recovery_reopens_email_prompt() { - let mut state = InteractiveCreateState::from_command(&Create { - name: Some("alice.smith".to_string()), - kind: Some("primary".to_string()), - replace_local: false, - device_name: None, - email: Some("alice@example.test".to_string()), - send_code: false, - verify_code: None, - auth: None, - }) - .unwrap(); - state.email_prompt_required = false; - - super::apply_verification_recovery( - &mut state, - &crate::cli::flow::recovery::VerificationRecovery::BackToEmail { - message: "start over".to_string(), - }, - ); - - assert!(state.email_prompt_required); - assert!(state.verify_code.is_none()); - } - - #[test] - fn root_identity_defaults_to_email_auth_non_interactively() { - let target = IdentityTarget::parse("alice.smith").unwrap(); - assert_eq!( - resolve_non_interactive_approval_plan(&target, None, None).unwrap(), - CreateApprovalPlan { - parent_identity: None, - auth: AuthMethod::Email, - helper_action: None, - } - ); - } - - #[test] - fn root_identity_rejects_identity_auth() { - let target = IdentityTarget::parse("alice.smith").unwrap(); - let error = - resolve_non_interactive_approval_plan(&target, Some(AuthMethod::Identity), None) - .unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("email verification"), "{rendered}"); - assert!(rendered.contains("--auth identity"), "{rendered}"); - } - - #[test] - fn sub_identity_with_ready_parent_requires_explicit_auth_non_interactively() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - let error = - resolve_non_interactive_approval_plan(&target, None, Some("alice.smith")).unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("--auth email"), "{rendered}"); - assert!(rendered.contains("--auth identity"), "{rendered}"); - } - - #[test] - fn sub_identity_identity_auth_targets_parent_identity() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - assert_eq!( - resolve_non_interactive_approval_plan( - &target, - Some(AuthMethod::Identity), - Some("alice.smith"), - ) - .unwrap(), - CreateApprovalPlan { - parent_identity: Some("alice.smith".to_string()), - auth: AuthMethod::Identity, - helper_action: None, - } - ); - } - - #[test] - fn sub_identity_email_auth_is_allowed() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - assert_eq!( - resolve_non_interactive_approval_plan(&target, Some(AuthMethod::Email), None).unwrap(), - CreateApprovalPlan { - parent_identity: Some("alice.smith".to_string()), - auth: AuthMethod::Email, - helper_action: None, - } - ); - } - - #[test] - fn sub_identity_without_ready_parent_defaults_to_email_non_interactively() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - assert_eq!( - resolve_non_interactive_approval_plan(&target, None, None).unwrap(), - CreateApprovalPlan { - parent_identity: Some("alice.smith".to_string()), - auth: AuthMethod::Email, - helper_action: None, - } - ); - } - - #[test] - fn sub_identity_identity_auth_requires_ready_local_parent() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - let error = - resolve_non_interactive_approval_plan(&target, Some(AuthMethod::Identity), None) - .unwrap_err(); - let rendered = error.to_string(); - assert!( - rendered.contains("ready parent identity saved here"), - "{rendered}" - ); - assert!(rendered.contains("phone.alice.smith"), "{rendered}"); - } - - #[test] - fn completed_root_identity_create_does_not_require_interactive_checkout() { - let target = IdentityTarget::parse("alice.smith").unwrap(); - let response: CreateDomainResponse = serde_json::from_str( - r#"{"domain":"alice.smith.dhttp.net","quotes":{"currency":"USD","monthly":0,"yearly":0,"default_billing_cycle":"yearly"},"next_action":"completed"}"#, - ) - .unwrap(); - - ensure_non_interactive_root_checkout_not_required(&target, &response).unwrap(); - } - - #[test] - fn payment_required_root_identity_create_requires_interactive_checkout() { - let target = IdentityTarget::parse("alice.smith").unwrap(); - let response: CreateDomainResponse = serde_json::from_str( - r#"{"domain":"alice.smith.dhttp.net","quotes":{"currency":"USD","monthly":9900,"yearly":99000,"default_billing_cycle":"yearly"},"next_action":"payment","payment_entry":{"url":"https://pay.example.com","checkout_token":"tok_123","expires_at":123456}}"#, - ) - .unwrap(); - - let error = - ensure_non_interactive_root_checkout_not_required(&target, &response).unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("interactive checkout"), "{rendered}"); - assert!(rendered.contains("alice.smith"), "{rendered}"); - } - - #[test] - fn root_checkout_instruction_block_includes_terminal_qr_and_checkout_url() { - let target = IdentityTarget::parse("alice.smith").unwrap(); - let response: CreateDomainResponse = serde_json::from_str( - r#"{"domain":"alice.smith.dhttp.net","quotes":{"currency":"USD","monthly":9900,"yearly":99000,"default_billing_cycle":"yearly"},"next_action":"payment","payment_entry":{"url":"https://pay.example.com","checkout_token":"tok_123","expires_at":123456}}"#, - ) - .unwrap(); - - let block = root_checkout_instruction_block(&target, &response, true).unwrap(); - - assert!(block.contains("Payment is required to create alice.smith.")); - assert!(!block.contains("Open this checkout page")); - assert!(block.contains("Scan this QR code to pay:")); - assert!(block.contains("Checkout URL:\n https://pay.example.com")); - } - - #[test] - fn direct_sub_identity_create_does_not_require_interactive_checkout() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - let response: CreateSubdomainResponse = serde_json::from_str( - r#"{"domain":"phone.alice.smith.dhttp.net","parent":"alice.smith.dhttp.net","status":"active","expires_at":1794305532,"cert":{"limit":1,"used":0},"url":"/v2/subdomain?parent=alice.smith.dhttp.net&domain=phone.alice.smith.dhttp.net","certs_url":"/v2/cert?domain=phone.alice.smith.dhttp.net","created_at":1794305532}"#, - ) - .unwrap(); - - ensure_non_interactive_sub_identity_checkout_not_required(&target, &response).unwrap(); - } - - #[test] - fn quota_expansion_sub_identity_create_requires_interactive_checkout() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - let response: CreateSubdomainResponse = serde_json::from_str( - r#"{"domain":"phone.alice.smith.dhttp.net","parent":"alice.smith.dhttp.net","status":"pending","expires_at":1794305532,"cert":{"limit":1,"used":0},"url":"/v2/subdomain?parent=alice.smith.dhttp.net&domain=phone.alice.smith.dhttp.net","certs_url":"/v2/cert?domain=phone.alice.smith.dhttp.net","created_at":1794305532,"invoice":{"number":"INV-123","amount":500,"currency":"USD"}}"#, - ) - .unwrap(); - - let error = ensure_non_interactive_sub_identity_checkout_not_required(&target, &response) - .unwrap_err(); - let rendered = error.to_string(); - assert!( - rendered.contains("exceeded the sub-identity quota"), - "{rendered}" - ); - assert!(rendered.contains("interactive checkout"), "{rendered}"); - assert!(rendered.contains("phone.alice.smith"), "{rendered}"); - } - - #[test] - fn subdomain_checkout_instruction_block_includes_amount_qr_and_checkout_url() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - let invoice = InvoiceDetail { - invoice_no: "INV-123".to_string(), - domain: "phone.alice.smith.dhttp.net".to_string(), - status: "open".to_string(), - amount: 500, - currency: "USD".to_string(), - url: "https://pay.example.com/invoice/INV-123".to_string(), - expires_at: None, - updated_at: None, - }; - let quote = SubdomainQuotaQuote { - domain: "phone.alice.smith.dhttp.net".to_string(), - due: 500, - currency: "USD".to_string(), - days_left: 10, - days_total: 365, - renewal: 500, - }; - - let block = subdomain_checkout_instruction_block(&target, &invoice, "e, true); - - assert!(block.contains("Creating phone.alice.smith exceeded the sub-identity quota.")); - assert!(block.contains("Amount due now to expand and continue: USD 5.00")); - assert!(!block.contains("Open this checkout page")); - assert!(block.contains("Scan this QR code to pay:")); - assert!(block.contains("Checkout URL:\n https://pay.example.com/invoice/INV-123")); - } - - #[test] - fn create_identity_name_opening_matches_spec_copy() { - let opening = super::create_identity_name_opening(); - assert!(opening.contains("Create a new identity here.")); - assert!(opening.contains(".")); - assert!(opening.contains("alice.smith")); - assert!(opening.contains("phone.alice.smith")); - } - - #[test] - fn create_subidentity_with_ready_parent_shows_local_before_email() { - let options = build_create_approval_options(Some(LocalApprovalCandidate::ready( - "alice.smith", - "alice.smith.dhttp.net", - ))); - - assert_eq!( - options - .iter() - .map(ApprovalMenuOption::label) - .collect::>(), - vec![ - "Verify with alice.smith on local device".to_string(), - "Verify with email".to_string(), - ] - ); - } - - #[test] - fn create_subidentity_with_missing_parent_uses_apply_copy() { - let options = build_create_approval_options(Some(LocalApprovalCandidate::missing( - "alice.smith", - "alice.smith.dhttp.net", - ))); - - assert_eq!( - options - .iter() - .map(ApprovalMenuOption::label) - .collect::>(), - vec![ - "Verify with email".to_string(), - "Apply alice.smith here, then verify with alice.smith".to_string(), - ] - ); - } - - #[test] - fn create_email_actions_use_explicit_return_point_copy() { - assert_eq!( - create_email_actions(true), - vec![ - CreateEmailAction::SwitchVerificationMethod, - CreateEmailAction::ChangeCertificateKind, - CreateEmailAction::ChangeIdentityName, - ] - ); - assert_eq!( - create_email_actions(true) - .into_iter() - .map(|action| action.label()) - .collect::>(), - vec![ - "Switch verification method (go back to verification method selection)".to_string(), - "Change certificate kind (go back to identity kind)".to_string(), - "Change identity name (go back to identity name)".to_string(), - ] - ); - } - - #[test] - fn create_verify_code_actions_include_resend_and_explicit_return_points() { - assert_eq!( - create_verify_code_actions(Some("phone.alice.smith")), - vec![ - CreateVerifyCodeAction::ResendVerificationCode, - CreateVerifyCodeAction::ChangeEmail, - CreateVerifyCodeAction::SwitchVerificationMethod, - CreateVerifyCodeAction::ChangeCertificateKind, - CreateVerifyCodeAction::ChangeIdentityName, - CreateVerifyCodeAction::ReturnToCreate { - target_name: "phone.alice.smith".to_string(), - }, - ] - ); - assert_eq!( - create_verify_code_actions(Some("phone.alice.smith")) - .into_iter() - .map(|action| action.label()) - .collect::>(), - vec![ - "Resend verification code".to_string(), - "Send code to another email (go back to email)".to_string(), - "Switch verification method (go back to verification method selection)".to_string(), - "Change certificate kind (go back to identity kind)".to_string(), - "Change identity name (go back to identity name)".to_string(), - "Return to create phone.alice.smith".to_string(), - ] - ); - } -} diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index 206cdc0..65be336 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -1,57 +1,18 @@ use std::io::IsTerminal; use dhttp::home::{DhttpHome, HomeScope}; -use snafu::{OptionExt, whatever}; +use snafu::whatever; -use super::{ - local::{self, InteractiveInventoryChoice, LocalIdentitySummary}, - output, - target::IdentityTarget, -}; +use super::{local, output, target::IdentityTarget}; use crate::{ cert_server::CertServer, - cli::{self, Default, Error, prompt::InquireResultExt}, + cli::{self, Default, Error}, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DefaultOrganizationAction { - ApplyToLocalDevice, - ChooseAnotherIdentity, -} - -fn default_organization_actions(target: &str) -> Vec { - let short_name = IdentityTarget::parse(target) - .map(|target| target.short_name().to_string()) - .unwrap_or_else(|_| target.to_string()); - vec![ - format!("Apply {short_name} here"), - "Choose another identity".to_string(), - ] -} - -fn organization_action_from_selection( - options: &[String], - selected: &str, -) -> Result { - options - .iter() - .find_map(|label| { - if label == selected { - Some(match label.as_str() { - "Choose another identity" => DefaultOrganizationAction::ChooseAnotherIdentity, - _ => DefaultOrganizationAction::ApplyToLocalDevice, - }) - } else { - None - } - }) - .whatever_context::<_, Error>("selected default action is unavailable") -} - async fn set_default_summary( dhttp_home: &DhttpHome, current_config: Option, - summary: LocalIdentitySummary, + summary: local::LocalIdentitySummary, ) -> Result<(), Error> { let mut current_config = current_config.unwrap_or_else(|| { dhttp::home::identity::settings::DhttpSettingsFile::new(dhttp_home.settings_path()) @@ -62,339 +23,59 @@ async fn set_default_summary( cli::save_settings(¤t_config).await } -async fn confirm_default_target( +pub(crate) async fn run( command: &Default, - summary: &LocalIdentitySummary, - current_default: Option<&super::epilogue::CurrentDefaultSummary>, - ansi: bool, - stdin_is_terminal: bool, -) -> Result<(), Error> { - if !summary.status.is_ready() && !command.allow_nonready { - let message = format!( - "{} is {}. Set it as the default identity anyway?", - summary.target.short_name(), - summary.status.label() - ); - let confirmed = - cli::prompt::sync(move || inquire::Confirm::new(&message).with_default(false).prompt()) - .await - .require_interactive("--allow-nonready")?; - if !confirmed { - whatever!("default identity was not changed"); - } - return Ok(()); - } - - // Non-interactive default changes must go through the explicit - // `genmeta identity default ` command, not through create/apply - // side effects. When the user has already named the target here, treat - // that explicit command as sufficient confirmation. - if command.name.is_some() && !stdin_is_terminal { - return Ok(()); - } - - if let Some(suggestion) = - super::epilogue::suggest_default_change(summary.target.short_name(), current_default, ansi) - { - let accepted = cli::prompt::sync(move || { - inquire::Confirm::new(&suggestion.prompt) - .with_default(suggestion.default) - .prompt() - }) - .await - .require_interactive("IDENTITY")?; - if !accepted { - whatever!("default identity was not changed"); - } - } - - Ok(()) -} - -async fn run_helper_apply( dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, - target: &IdentityTarget, + _home_scope: HomeScope, + _cert_server: &CertServer, ) -> Result<(), Error> { - crate::cli::flow::transcript::print_block(&format!( - "{} is not saved here.\n\nTo use it as the default identity, this command will first apply {} here, then return here and set it as the default identity.", - target.short_name(), - target.short_name() - )); - let command = helper_apply_command(target); - super::apply::run_with_policy( - &command, - dhttp_home, - home_scope, - cert_server, - super::apply::ApplyPostSavePolicy::SkipDefaultSuggestion, - ) - .await -} + let current_config = cli::load_current_settings(dhttp_home).await?; + let configured_default_name = current_config + .as_ref() + .and_then(|config| config.settings().default_identity_name().cloned()); -fn helper_apply_command(target: &IdentityTarget) -> crate::cli::Apply { - crate::cli::Apply { - name: Some(target.short_name().to_string()), - kind: None, - replace_local: false, - device_name: None, - email: None, - send_code: false, - verify_code: None, - auth: None, - } -} + let Some(name) = command.name.as_deref() else { + let name = match configured_default_name.as_ref() { + Some(name) => name.borrow(), + None => whatever!( + "No default identity configured. Use `genmeta identity default ` to set one." + ), + }; + let summary = + local::load_summary(dhttp_home, name, configured_default_name.clone()).await?; + let rendered = if command.verbose { + output::format_info(&summary, std::io::stdout().is_terminal()) + } else { + output::format_default_query(&summary) + }; + crate::cli::flow::transcript::print_block(&rendered); + return Ok(()); + }; -async fn summary_for_named_default_target( - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, - target: &IdentityTarget, - configured_default_name: Option>, -) -> Result { - if let Some(summary) = local::try_load_summary( + let target = IdentityTarget::parse(name)?; + let Some(summary) = local::try_load_summary( dhttp_home, target.dhttp_name(), - configured_default_name.clone(), + configured_default_name + .as_ref() + .map(dhttp::name::DhttpName::borrow), ) .await? - { - return Ok(summary); - } - - if !std::io::stdin().is_terminal() { + else { whatever!( - "{} is not saved here.\n\nTo use it as the default identity, apply {} here first or rerun this command interactively.", + "{} is not saved here.\n\nApply {} here first, then set it as the default identity.", target.short_name(), target.short_name(), ); - } - - run_helper_apply(dhttp_home, home_scope, cert_server, target).await?; - local::try_load_summary(dhttp_home, target.dhttp_name(), configured_default_name) - .await? - .whatever_context::<_, Error>("helper apply did not save the requested identity") -} - -async fn select_interactive_default_summary( - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, - configured_default_name: Option>, -) -> Result { - loop { - let inventory = local::load_inventory(dhttp_home, configured_default_name.clone()).await?; - let choices = local::build_default_inventory_choices(&inventory); - if choices.is_empty() { - whatever!("No identities found here"); - } - - let ansi = std::io::stdout().is_terminal(); - let labels = choices - .iter() - .map(|choice| output::render_choice_label(choice, ansi)) - .collect::>(); - let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to set as the default here:", - labels.clone(), - ) - .await - .require_interactive("IDENTITY")?; - let choice = choices - .into_iter() - .zip(labels) - .find_map(|(choice, label)| (label == selected).then_some(choice)) - .whatever_context::<_, Error>("selected identity choice is unavailable")?; - match choice { - InteractiveInventoryChoice::Saved(summary) => return Ok(summary), - InteractiveInventoryChoice::Organization { target } => { - let options = default_organization_actions(target.full_name()); - let selected = crate::cli::prompt::prompt_select_string( - &format!( - "{} is not saved here. Choose what to do next:", - target.short_name() - ), - options.clone(), - ) - .await - .require_interactive("IDENTITY")?; - match organization_action_from_selection(&options, &selected)? { - DefaultOrganizationAction::ApplyToLocalDevice => { - return summary_for_named_default_target( - dhttp_home, - home_scope, - cert_server, - &target, - configured_default_name, - ) - .await; - } - DefaultOrganizationAction::ChooseAnotherIdentity => continue, - } - } - } - } -} - -pub(crate) async fn run( - command: &Default, - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, -) -> Result<(), Error> { - let current_config = cli::load_current_settings(dhttp_home).await?; - let configured_default_name = current_config - .as_ref() - .and_then(|config| config.settings().default_identity_name().cloned()); - let current_default = super::epilogue::current_default_summary(dhttp_home).await?; - let ansi = std::io::stdout().is_terminal(); - let stdin_is_terminal = std::io::stdin().is_terminal(); - - match command.name.as_ref() { - None => { - let name = match configured_default_name.clone() { - Some(n) => n, - None => whatever!( - "No default identity configured. Use `genmeta identity default ` to set one." - ), - }; - let summary = local::load_summary( - dhttp_home, - name.borrow(), - configured_default_name - .as_ref() - .map(|default| default.borrow()), - ) - .await?; - crate::cli::flow::transcript::print_block(&output::format_default_summary( - &summary, - std::io::stdout().is_terminal(), - )); - - if !stdin_is_terminal { - return Ok(()); - } - - let switch_default = cli::prompt::sync(|| { - inquire::Confirm::new("Change the default identity here?") - .with_default(false) - .prompt() - }) - .await - .require_interactive("IDENTITY")?; - if !switch_default { - return Ok(()); - } - - let selected_summary = select_interactive_default_summary( - dhttp_home, - home_scope, - cert_server, - configured_default_name - .as_ref() - .map(|default| default.borrow()), - ) - .await?; - confirm_default_target( - command, - &selected_summary, - current_default.as_ref(), - ansi, - stdin_is_terminal, - ) - .await?; - set_default_summary(dhttp_home, current_config, selected_summary.clone()).await?; - let block = super::epilogue::default_block( - configured_default_name - .as_ref() - .map(|default| default.as_partial()), - Some(selected_summary.target.short_name()), - ); - crate::cli::flow::transcript::print_line(output::format_default_identity_sentence( - &block, - )); - Ok(()) - } - Some(name) => { - let target = IdentityTarget::parse(name)?; - let summary = summary_for_named_default_target( - dhttp_home, - home_scope, - cert_server, - &target, - configured_default_name - .as_ref() - .map(|default| default.borrow()), - ) - .await?; - confirm_default_target( - command, - &summary, - current_default.as_ref(), - ansi, - stdin_is_terminal, - ) - .await?; - set_default_summary(dhttp_home, current_config, summary.clone()).await?; - let block = super::epilogue::default_block( - configured_default_name - .as_ref() - .map(|default| default.as_partial()), - Some(summary.target.short_name()), - ); - crate::cli::flow::transcript::print_line(output::format_default_identity_sentence( - &block, - )); - Ok(()) - } - } -} - -#[cfg(test)] -mod tests { - use super::{ - DefaultOrganizationAction, default_organization_actions, helper_apply_command, - organization_action_from_selection, }; - use crate::cli::flow::target::IdentityTarget; - - #[test] - fn organization_action_menu_matches_spec_copy() { - assert_eq!( - default_organization_actions("alice.smith.dhttp.net"), - vec![ - "Apply alice.smith here".to_string(), - "Choose another identity".to_string(), - ] - ); - } - - #[test] - fn organization_action_selection_can_apply_to_local_device() { - let options = default_organization_actions("alice.smith.dhttp.net"); - assert_eq!( - organization_action_from_selection(&options, "Apply alice.smith here").unwrap(), - DefaultOrganizationAction::ApplyToLocalDevice, - ); - } - - #[test] - fn organization_action_selection_can_choose_another_identity() { - let options = default_organization_actions("alice.smith.dhttp.net"); - - assert_eq!( - organization_action_from_selection(&options, "Choose another identity").unwrap(), - DefaultOrganizationAction::ChooseAnotherIdentity, + if !summary.status.is_ready() && !command.allow_nonready { + whatever!( + "{} is {} and cannot be set as the default identity without --allow-nonready", + summary.target.short_name(), + summary.status.label(), ); } - #[test] - fn helper_apply_command_uses_explicit_name_without_default_lookup() { - let command = helper_apply_command(&IdentityTarget::parse("alice.smith").unwrap()); - - assert_eq!(command.name.as_deref(), Some("alice.smith")); - assert!(command.kind.is_none()); - } + set_default_summary(dhttp_home, current_config, summary).await } diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index 3306a83..587e0ef 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -10,35 +10,17 @@ use super::{ pub(crate) fn render_inventory(inventory: &LocalInventory, ansi: bool) -> String { let mut lines = Vec::new(); - let width = inventory - .groups - .iter() - .flat_map(|group| { - std::iter::once(root_label(&group.root).len()).chain( - group - .children - .iter() - .map(|summary| child_label(summary).len()), - ) - }) - .max() - .unwrap_or_default() - .max(40); - for group in &inventory.groups { - lines.push(render_root(&group.root, width, ansi)); - for (index, child) in group.children.iter().enumerate() { - let branch = if index + 1 == group.children.len() { - "└─ " - } else { - "├─ " - }; + if let LocalInventoryRoot::Saved(summary) = &group.root { + lines.push(render_line( + compact_identity_label(summary), + summary_line_style(summary), + ansi, + )); + } + for child in &group.children { lines.push(render_line( - render_summary_text( - &format!("{branch}{}", child.target.short_name()), - child, - width, - ), + compact_identity_label(child), summary_line_style(child), ansi, )); @@ -48,6 +30,22 @@ pub(crate) fn render_inventory(inventory: &LocalInventory, ansi: bool) -> String lines.join("\n") } +pub(crate) fn render_verbose_inventory(inventory: &LocalInventory, ansi: bool) -> String { + let mut blocks = Vec::new(); + for group in &inventory.groups { + if let LocalInventoryRoot::Saved(summary) = &group.root { + blocks.push(format_info(summary, ansi)); + } + blocks.extend( + group + .children + .iter() + .map(|summary| format_info(summary, ansi)), + ); + } + blocks.join("\n\n") +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LineStyle { Plain, @@ -114,9 +112,12 @@ pub(crate) fn compact_identity_label_parts( status: &LocalIdentityStatus, is_default: bool, ) -> String { - let mut label = format!("{name} [{}]", status.label()); + let mut label = name.to_string(); + if !matches!(status, LocalIdentityStatus::Ready { .. }) { + label.push_str(&format!(" [{}]", status.label())); + } if is_default { - label.push_str(" (default identity)"); + label.push_str(" (default)"); } label } @@ -220,6 +221,38 @@ pub(crate) fn format_default_summary(summary: &LocalIdentitySummary, ansi: bool) format_info(summary, ansi) } +pub(crate) fn format_default_query(summary: &LocalIdentitySummary) -> String { + let name = summary.target.short_name(); + match summary.status { + LocalIdentityStatus::Expired { expired_at } => format!( + "{name}\n\nWARNING: This name expired on {}. Renew it soon, or the name may be released.\nRun `genmeta identity renew {name}` to renew it.", + format_natural_date(expired_at), + ), + _ => name.to_string(), + } +} + +fn format_natural_date(timestamp: i64) -> String { + let date = time::OffsetDateTime::from_unix_timestamp(timestamp) + .expect("certificate timestamp should fit OffsetDateTime") + .date(); + let month = match date.month() { + time::Month::January => "Jan", + time::Month::February => "Feb", + time::Month::March => "Mar", + time::Month::April => "Apr", + time::Month::May => "May", + time::Month::June => "Jun", + time::Month::July => "Jul", + time::Month::August => "Aug", + time::Month::September => "Sep", + time::Month::October => "Oct", + time::Month::November => "Nov", + time::Month::December => "Dec", + }; + format!("{:02} {month} {}", date.day(), date.year()) +} + fn detail_lines(summary: &LocalIdentitySummary) -> Vec { let mut lines = Vec::new(); match (&summary.status, summary.certificate_chain.as_deref()) { @@ -233,71 +266,18 @@ fn detail_lines(summary: &LocalIdentitySummary) -> Vec { } _ => {} } - lines.push(format!(" saved at {}", summary.saved_at.display())); + lines.push(format!(" dir: {}", summary.saved_at.display())); lines } -fn render_root(root: &LocalInventoryRoot, width: usize, ansi: bool) -> String { - match root { - LocalInventoryRoot::Saved(summary) => render_line( - render_summary_text(&root_label(root), summary, width), - summary_line_style(summary), - ansi, - ), - LocalInventoryRoot::Organization { .. } => { - render_line(root_label(root), LineStyle::Dim, ansi) - } - } -} - -fn render_summary_text(label: &str, summary: &LocalIdentitySummary, width: usize) -> String { - let supplement = match &summary.status { - LocalIdentityStatus::Ready { .. } | LocalIdentityStatus::Expired { .. } => summary - .certificate_chain - .as_deref() - .unwrap_or("(certificate chain unavailable)") - .to_string(), - LocalIdentityStatus::Incomplete { detail } | LocalIdentityStatus::Invalid { detail } => { - format!("({detail})") - } - }; - - format!( - "{label: String { - match root { - LocalInventoryRoot::Saved(summary) => summary_label(summary), - LocalInventoryRoot::Organization { target } => { - format!("{} (not saved here)", target.short_name()) - } - } -} - -fn child_label(summary: &LocalIdentitySummary) -> String { - format!("├─ {}", summary.target.short_name()) -} - -fn summary_label(summary: &LocalIdentitySummary) -> String { - let mut label = summary.target.short_name().to_string(); - if summary.is_default { - label.push_str(" (default identity)"); - } - label -} - #[cfg(test)] mod tests { use std::path::PathBuf; use super::{ - DefaultIdentityBlock, LineStyle, SavedIdentityAction, compact_identity_label, - format_current_default_suffix, format_default_identity_sentence, format_default_summary, - format_info, format_saved_identity_result, render_choice_label, render_inventory, + DefaultIdentityBlock, LineStyle, compact_identity_label, format_current_default_suffix, + format_default_identity_sentence, format_default_query, format_default_summary, + format_info, render_choice_label, render_inventory, render_verbose_inventory, summary_line_style, }; use crate::cli::flow::{ @@ -325,7 +305,7 @@ mod tests { } #[test] - fn formats_compact_info_and_default_summary() { + fn formats_info_with_dir_detail() { let profile = summary( "phone.alice.smith", true, @@ -335,28 +315,26 @@ mod tests { Some("secondary:2"), ); - let expected = "phone.alice.smith [ready] (default identity)\n uses certificate chain secondary:2\n saved at /tmp/phone.alice.smith"; + let expected = "phone.alice.smith (default)\n uses certificate chain secondary:2\n dir: /tmp/phone.alice.smith"; assert_eq!(format_info(&profile, false), expected); assert_eq!(format_default_summary(&profile, false), expected); } #[test] - fn formats_created_identity_result() { + fn expired_default_query_keeps_name_and_actionable_warning() { let profile = summary( "alice.smith", - false, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, + true, + LocalIdentityStatus::Expired { + expired_at: 1_783_478_400, }, Some("primary:0"), ); - let expected = "Created identity alice.smith [ready]\n uses certificate chain primary:0\n saved at /tmp/alice.smith"; - assert_eq!( - format_saved_identity_result(SavedIdentityAction::Created, &profile, false), - expected, + format_default_query(&profile), + "alice.smith\n\nWARNING: This name expired on 08 Jul 2026. Renew it soon, or the name may be released.\nRun `genmeta identity renew alice.smith` to renew it.", ); } @@ -375,7 +353,7 @@ mod tests { } #[test] - fn compact_label_uses_square_bracket_status_without_chain_id() { + fn compact_label_hides_ready_status() { let profile = summary( "alice.smith", false, @@ -385,7 +363,7 @@ mod tests { Some("primary:0"), ); - assert_eq!(compact_identity_label(&profile), "alice.smith [ready]"); + assert_eq!(compact_identity_label(&profile), "alice.smith"); } #[test] @@ -413,7 +391,7 @@ mod tests { None, ); - let expected = "alice.smith [invalid]\n certificate chain metadata is invalid\n saved at /tmp/alice.smith"; + let expected = "alice.smith [invalid]\n certificate chain metadata is invalid\n dir: /tmp/alice.smith"; assert_eq!(format_info(&profile, false), expected); } @@ -460,7 +438,7 @@ mod tests { } #[test] - fn renders_tree_inventory_lines() { + fn renders_flat_compact_inventory_lines() { let inventory = build_inventory(vec![ summary( "tablet.reimu.scarlet", @@ -497,15 +475,31 @@ mod tests { ]); let expected = "\ -alice.smith (default identity) ready primary:0\n\ -├─ phone.alice.smith ready secondary:2\n\ -└─ tv.alice.smith incomplete (private key missing)\n\ -reimu.scarlet (not saved here)\n\ -└─ tablet.reimu.scarlet expired secondary:1"; +alice.smith (default)\n\ +phone.alice.smith\n\ +tv.alice.smith [incomplete]\n\ +tablet.reimu.scarlet [expired]"; assert_eq!(render_inventory(&inventory, false), expected); } + #[test] + fn verbose_inventory_reuses_info_renderer() { + let inventory = build_inventory(vec![summary( + "alice.smith", + true, + LocalIdentityStatus::Ready { + expires_at: EXPIRES_AT, + }, + Some("primary:0"), + )]); + + assert_eq!( + render_verbose_inventory(&inventory, false), + "alice.smith (default)\n uses certificate chain primary:0\n dir: /tmp/alice.smith", + ); + } + #[test] fn renew_chain_key_labels_include_parent_root_before_child() { let labels = vec![ @@ -532,7 +526,7 @@ reimu.scarlet (not saved here)\n\ labels, vec![ "alice.ma (not saved here)".to_string(), - " shanghai.alice.ma [ready]".to_string(), + " shanghai.alice.ma".to_string(), ] ); } @@ -573,9 +567,9 @@ reimu.scarlet (not saved here)\n\ assert_eq!( labels, vec![ - "alice.smith [ready] (default identity)".to_string(), + "alice.smith (default)".to_string(), "reimu.scarlet (not saved here)".to_string(), - " tablet.reimu.scarlet [ready]".to_string(), + " tablet.reimu.scarlet".to_string(), ] ); } diff --git a/genmeta-identity/src/cli/flow/registration.rs b/genmeta-identity/src/cli/flow/registration.rs new file mode 100644 index 0000000..0b82f9c --- /dev/null +++ b/genmeta-identity/src/cli/flow/registration.rs @@ -0,0 +1,384 @@ +use std::io::IsTerminal; + +use snafu::{OptionExt, whatever}; + +use super::target::IdentityTarget; +use crate::{ + cert_server::{ + CertServer, CreateDomainResponse, CreateSubdomainAttempt, CreateSubdomainResponse, + InvoiceDetail, SubdomainQuotaQuote, + }, + cli::{ + Error, + prompt::{self, InquireResultExt}, + }, +}; + +fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { + error.is_api_code("domain_not_found") +} + +fn is_domain_conflict(error: &crate::cert_server::Error) -> bool { + error.is_api_code("domain_conflict") +} + +pub(crate) fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { + error.is_api_code("subdomain_quota_exceeded") +} +pub(crate) fn ensure_non_interactive_root_checkout_not_required( + target: &IdentityTarget, + response: &CreateDomainResponse, +) -> Result<(), Error> { + match crate::checkout::classify_checkout(response) { + crate::checkout::CheckoutState::Completed => Ok(()), + crate::checkout::CheckoutState::Pending + | crate::checkout::CheckoutState::Expired + | crate::checkout::CheckoutState::Cancelled => whatever!( + "creating {} requires interactive checkout; rerun this command in an interactive terminal to complete payment", + target.short_name() + ), + } +} + +pub(crate) fn ensure_non_interactive_sub_identity_checkout_not_required( + target: &IdentityTarget, + response: &CreateSubdomainResponse, +) -> Result<(), Error> { + if response.invoice.is_some() { + whatever!( + "creating {} exceeded the sub-identity quota and requires interactive checkout; rerun this command in an interactive terminal to expand the parent identity quota", + target.short_name() + ); + } + Ok(()) +} + +pub(crate) async fn prompt_restart_checkout(message: &str) -> Result { + let message = message.to_string(); + Ok( + prompt::sync(move || inquire::Confirm::new(&message).with_default(true).prompt()) + .await + .require_interactive("interactive input")?, + ) +} +pub(crate) fn print_root_checkout_instructions( + target: &IdentityTarget, + response: &CreateDomainResponse, +) { + if let Some(block) = + root_checkout_instruction_block(target, response, std::io::stderr().is_terminal()) + { + crate::cli::flow::transcript::print_err_block(&block); + } +} + +fn root_checkout_summary(target: &IdentityTarget) -> String { + format!("Payment is required to create {}.", target.short_name()) +} + +fn root_checkout_instruction_block( + target: &IdentityTarget, + response: &CreateDomainResponse, + include_qr: bool, +) -> Option { + response.payment_entry.as_ref().map(|payment_entry| { + crate::checkout::checkout_instruction_block( + &root_checkout_summary(target), + &payment_entry.url, + include_qr, + ) + }) +} + +fn print_subdomain_checkout_instructions( + target: &IdentityTarget, + invoice: &InvoiceDetail, + quote: &SubdomainQuotaQuote, +) { + crate::cli::flow::transcript::print_err_block(&subdomain_checkout_instruction_block( + target, + invoice, + quote, + std::io::stderr().is_terminal(), + )); +} + +fn subdomain_checkout_summary(target: &IdentityTarget, quote: &SubdomainQuotaQuote) -> String { + format!( + "Creating {} exceeded the sub-identity quota.\n\nAmount due now to expand and continue: {} {}", + target.short_name(), + quote.currency, + format_minor_amount(quote.due), + ) +} + +fn subdomain_checkout_instruction_block( + target: &IdentityTarget, + invoice: &InvoiceDetail, + quote: &SubdomainQuotaQuote, + include_qr: bool, +) -> String { + crate::checkout::checkout_instruction_block( + &subdomain_checkout_summary(target, quote), + &invoice.url, + include_qr, + ) +} + +fn format_minor_amount(amount: i64) -> String { + let major = amount / 100; + let cents = amount.abs() % 100; + format!("{major}.{cents:02}") +} +async fn wait_for_invoice_terminal( + cert_server: &CertServer, + access_token: &str, + invoice_no: &str, +) -> Result { + super::progress::run_with_spinner("Waiting for payment confirmation...", async { + loop { + let invoice = cert_server.get_invoice(access_token, invoice_no).await?; + match invoice.status.as_str() { + "paid" | "expired" | "cancelled" | "canceled" => return Ok(invoice), + _ => tokio::time::sleep(std::time::Duration::from_secs(3)).await, + } + } + }) + .await +} + +pub(crate) async fn ensure_root_identity_exists_with_token( + cert_server: &CertServer, + parent: &dhttp::name::DhttpName<'_>, + access_token: &str, +) -> Result<(), Error> { + let parent_target = IdentityTarget::parse(parent.as_partial())?; + ensure_identity_exists_with_token( + cert_server, + &parent_target, + access_token, + "Creating parent identity...", + ) + .await +} + +pub(crate) async fn ensure_identity_exists_with_token( + cert_server: &CertServer, + target: &IdentityTarget, + access_token: &str, + progress_message: &str, +) -> Result<(), Error> { + let created = match super::progress::run_with_spinner( + progress_message, + cert_server.create_domain_with_token(access_token, target.full_name()), + ) + .await + { + Ok(created) => created, + Err(error) if is_domain_conflict(&error) => return Ok(()), + Err(error) => return Err(Error::from(error)), + }; + + ensure_non_interactive_root_checkout_not_required(target, &created)?; + Ok(()) +} + +pub(crate) async fn ensure_root_identity_exists_with_token_interactively( + cert_server: &CertServer, + parent: &dhttp::name::DhttpName<'_>, + access_token: &str, +) -> Result<(), Error> { + let parent_target = IdentityTarget::parse(parent.as_partial())?; + ensure_identity_exists_with_token_interactively( + cert_server, + &parent_target, + access_token, + "Creating parent identity...", + ) + .await +} + +pub(crate) async fn ensure_identity_exists_with_token_interactively( + cert_server: &CertServer, + target: &IdentityTarget, + access_token: &str, + progress_message: &str, +) -> Result<(), Error> { + loop { + let created = match super::progress::run_with_spinner( + progress_message, + cert_server.create_domain_with_token(access_token, target.full_name()), + ) + .await + { + Ok(created) => created, + Err(error) if is_domain_conflict(&error) => return Ok(()), + Err(error) => return Err(Error::from(error)), + }; + + if created.payment_entry.is_none() { + return Ok(()); + } + + print_root_checkout_instructions(target, &created); + let completed = crate::checkout::wait_for_checkout_completion( + cert_server, + &created + .payment_entry + .as_ref() + .expect("payment entry just checked") + .checkout_token, + ) + .await?; + + match crate::checkout::classify_checkout(&completed) { + crate::checkout::CheckoutState::Completed => return Ok(()), + crate::checkout::CheckoutState::Expired => { + if !prompt_restart_checkout( + "This checkout expired. Start a new checkout for this identity?", + ) + .await? + { + whatever!("checkout was not completed"); + } + } + crate::checkout::CheckoutState::Cancelled => { + if !prompt_restart_checkout( + "This checkout was cancelled. Start a new checkout for this identity?", + ) + .await? + { + whatever!("checkout was not completed"); + } + } + crate::checkout::CheckoutState::Pending => { + whatever!("checkout did not reach a terminal state"); + } + } + } +} + +pub(crate) async fn create_sub_identity_with_token( + cert_server: &CertServer, + _target: &IdentityTarget, + access_token: &str, + parent: &dhttp::name::DhttpName<'_>, + label: &str, +) -> Result { + match super::progress::run_with_spinner( + "Creating sub-identity...", + cert_server.create_subdomain(access_token, parent.as_full(), label, None), + ) + .await + { + Ok(created) => Ok(created), + Err(error) if is_domain_not_found(&error) => { + ensure_root_identity_exists_with_token(cert_server, parent, access_token).await?; + super::progress::run_with_spinner( + "Creating sub-identity...", + cert_server.create_subdomain(access_token, parent.as_full(), label, None), + ) + .await + .map_err(Error::from) + } + Err(error) => Err(Error::from(error)), + } +} + +pub(crate) async fn create_sub_identity_with_token_interactively( + cert_server: &CertServer, + target: &IdentityTarget, + access_token: &str, + parent: &dhttp::name::DhttpName<'_>, + label: &str, +) -> Result { + loop { + match super::progress::run_with_spinner( + "Creating sub-identity...", + cert_server.create_subdomain_attempt(access_token, parent.as_full(), label, None), + ) + .await + { + Ok(CreateSubdomainAttempt::Created(response)) => return Ok(response), + Ok(CreateSubdomainAttempt::QuotaExceeded(quote)) => { + let continue_checkout = prompt_restart_checkout(&format!( + "Creating {} exceeded the sub-identity quota under {}. Expand quota and continue?", + target.short_name(), + parent.as_partial() + )) + .await?; + if !continue_checkout { + whatever!("checkout was not completed"); + } + + loop { + let invoice_response = match super::progress::run_with_spinner( + "Creating sub-identity...", + cert_server.create_subdomain_attempt( + access_token, + parent.as_full(), + label, + Some(quote.due), + ), + ) + .await? + { + CreateSubdomainAttempt::Created(response) => response, + CreateSubdomainAttempt::QuotaExceeded(_) => { + whatever!("subdomain quota expansion quote changed during checkout") + } + }; + let invoice_no = invoice_response + .invoice + .as_ref() + .map(|invoice| invoice.number.as_str()) + .whatever_context::<_, Error>( + "quota expansion checkout did not return an invoice number", + )?; + let invoice = super::progress::run_with_spinner( + "Loading payment details...", + cert_server.get_invoice(access_token, invoice_no), + ) + .await?; + print_subdomain_checkout_instructions(target, &invoice, "e); + let invoice = + wait_for_invoice_terminal(cert_server, access_token, invoice_no).await?; + match invoice.status.as_str() { + "paid" => break, + "expired" => { + if !prompt_restart_checkout( + "This checkout expired. Start a new checkout for this sub-identity slot?", + ) + .await? + { + whatever!("checkout was not completed"); + } + } + "cancelled" | "canceled" => { + if !prompt_restart_checkout( + "This checkout was cancelled. Start a new checkout for this sub-identity slot?", + ) + .await? + { + whatever!("checkout was not completed"); + } + } + _ => whatever!("invoice did not reach a terminal state"), + } + } + } + Err(error) if is_domain_not_found(&error) => { + ensure_root_identity_exists_with_token_interactively( + cert_server, + parent, + access_token, + ) + .await?; + crate::cli::flow::transcript::print_line( + "Parent identity was created. Continuing with sub-identity creation...", + ); + } + Err(error) => return Err(Error::from(error)), + } + } +} diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 48f8fae..a9563d5 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -4,10 +4,7 @@ use dhttp::home::{DhttpHome, HomeScope}; use snafu::{OptionExt, whatever}; use tracing::Instrument; -use super::{ - approval, - local::{self, InteractiveInventoryChoice}, -}; +use super::{approval, local}; use crate::{ auth::AuthMethod, cert_server::CertServer, @@ -259,47 +256,9 @@ async fn resolve_target( command: &Renew, dhttp_home: &DhttpHome, ) -> Result, Error> { - if command.use_default { - return cli::resolve_default_target_name(dhttp_home).await; - } - match command.name.as_deref() { Some(name) => cli::parse_identity_name(name), - None => { - let default_name = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let inventory = - local::load_inventory(dhttp_home, default_name.as_ref().map(|name| name.borrow())) - .await?; - let choices = local::build_renew_inventory_choices(&inventory); - if choices.is_empty() { - whatever!("No identities found here. Renew requires an identity saved here."); - } - let labels: Vec = choices - .iter() - .map(|choice| { - super::output::render_choice_label(choice, std::io::stdout().is_terminal()) - }) - .collect(); - let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to renew here:", - labels.clone(), - ) - .await - .require_interactive("IDENTITY")?; - let choice = choices - .into_iter() - .zip(labels) - .find_map(|(choice, label)| (label == selected).then_some(choice)) - .whatever_context::<_, Error>("selected identity choice is unavailable")?; - match choice { - InteractiveInventoryChoice::Saved(summary) => Ok(summary.target.into_dhttp_name()), - InteractiveInventoryChoice::Organization { .. } => { - whatever!("renew requires an identity already saved here") - } - } - } + None => cli::resolve_default_target_name(dhttp_home).await, } } @@ -366,60 +325,13 @@ async fn run_interactive( home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - let initial_target = if command.use_default { - Some(cli::resolve_default_target_name(dhttp_home).await?) - } else { - command - .name - .as_deref() - .map(cli::parse_identity_name) - .transpose()? + let initial_target = match command.name.as_deref() { + Some(name) => cli::parse_identity_name(name)?, + None => cli::resolve_default_target_name(dhttp_home).await?, }; - let mut state = InteractiveRenewState::from_command(command, initial_target); + let mut state = InteractiveRenewState::from_command(command, Some(initial_target)); loop { - if state.target.is_none() { - let default_name = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let inventory = - local::load_inventory(dhttp_home, default_name.as_ref().map(|name| name.borrow())) - .await?; - let choices = local::build_renew_inventory_choices(&inventory); - if choices.is_empty() { - whatever!("No identities found here. Renew requires an identity saved here."); - } - let labels: Vec = choices - .iter() - .map(|choice| { - super::output::render_choice_label(choice, std::io::stdout().is_terminal()) - }) - .collect(); - let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to renew here:", - labels.clone(), - ) - .await - .require_interactive("IDENTITY")?; - let choice = choices - .into_iter() - .zip(labels) - .find_map(|(choice, label)| (label == selected).then_some(choice)) - .whatever_context::<_, Error>("selected identity choice is unavailable")?; - match choice { - InteractiveInventoryChoice::Saved(summary) => { - state.target = Some(summary.target.into_dhttp_name()); - } - InteractiveInventoryChoice::Organization { target } => { - crate::cli::flow::transcript::print_block(&renew_not_saved_root_message( - target.short_name(), - )); - state.revisit_target_selection(); - } - } - continue; - } - let domain = state .target .clone() @@ -654,7 +566,6 @@ pub(crate) async fn run_helper_for_verification( ) -> Result { let command = Renew { name: Some(short_name.to_string()), - use_default: false, device_name: None, email: None, send_code: false, @@ -795,7 +706,6 @@ mod tests { let mut state = InteractiveRenewState::from_command( &Renew { name: Some("alice.smith".to_string()), - use_default: false, device_name: None, email: Some("alice@example.test".to_string()), send_code: false, @@ -821,7 +731,6 @@ mod tests { let mut state = InteractiveRenewState::from_command( &Renew { name: Some("alice.smith".to_string()), - use_default: false, device_name: None, email: Some("alice@example.test".to_string()), send_code: false, @@ -897,7 +806,6 @@ Apply alice.ma here first, then return to renew." let dhttp_home = DhttpHome::new(home_path); let command = Renew { name: Some("alice.smith".to_string()), - use_default: false, device_name: None, email: None, send_code: false, diff --git a/genmeta/src/main.rs b/genmeta/src/main.rs index 70d7163..403b0ac 100644 --- a/genmeta/src/main.rs +++ b/genmeta/src/main.rs @@ -11,6 +11,7 @@ enum Options { #[command(subcommand)] options: genmeta_doctor::Options, }, + #[command(visible_alias = "id")] Identity(genmeta_identity::Cli), Nslookup(genmeta_nslookup::Options), Proxy(genmeta_proxy::Options), @@ -127,7 +128,7 @@ async fn run(options: Options) -> Result<(), Error> { #[cfg(test)] mod tests { - use clap::Parser; + use clap::{CommandFactory, Parser}; use super::{Options, install_process_crypto_provider}; @@ -143,4 +144,22 @@ mod tests { let parsed = Options::try_parse_from(["genmeta", "identity", "--global", "list"]); assert!(parsed.is_ok(), "{parsed:?}"); } + + #[test] + fn launcher_accepts_identity_alias() { + let canonical = Options::try_parse_from(["genmeta", "identity", "apply", "alice.smith"]) + .expect("canonical identity command should parse"); + let alias = Options::try_parse_from(["genmeta", "id", "apply", "alice.smith"]) + .expect("id alias should parse"); + + assert!(matches!(canonical, Options::Identity(_))); + assert!(matches!(alias, Options::Identity(_))); + } + + #[test] + fn launcher_help_exposes_identity_alias() { + let help = Options::command().render_long_help().to_string(); + assert!(help.contains("identity"), "{help}"); + assert!(help.contains("id"), "{help}"); + } } From 3dd0aeedcf3703cbf4a4a7c5aad00a3f49f7e0f0 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 13 Jul 2026 14:08:59 +0800 Subject: [PATCH 02/27] feat(identity): add safe authentication fallback --- genmeta-identity/src/checkout.rs | 12 +- genmeta-identity/src/cli.rs | 13 +- genmeta-identity/src/cli/flow.rs | 2 +- genmeta-identity/src/cli/flow/apply.rs | 615 ++++----------------- genmeta-identity/src/cli/flow/approval.rs | 360 ------------ genmeta-identity/src/cli/flow/auth_plan.rs | 216 ++++++++ genmeta-identity/src/cli/flow/epilogue.rs | 24 - genmeta-identity/src/cli/flow/local.rs | 33 ++ genmeta-identity/src/cli/flow/output.rs | 80 ++- genmeta-identity/src/cli/flow/progress.rs | 31 +- genmeta-identity/src/cli/flow/recovery.rs | 30 +- genmeta-identity/src/cli/flow/renew.rs | 335 ++++------- genmeta-identity/src/cli/prompt.rs | 13 +- 13 files changed, 595 insertions(+), 1169 deletions(-) delete mode 100644 genmeta-identity/src/cli/flow/approval.rs create mode 100644 genmeta-identity/src/cli/flow/auth_plan.rs diff --git a/genmeta-identity/src/checkout.rs b/genmeta-identity/src/checkout.rs index d518c1f..e4c275b 100644 --- a/genmeta-identity/src/checkout.rs +++ b/genmeta-identity/src/checkout.rs @@ -63,7 +63,6 @@ fn payment_instruction_block(response: &CreateDomainResponse, include_qr: bool) )); } if let Some(payment_entry) = &response.payment_entry { - lines.push(format!("checkout token: {}", payment_entry.checkout_token)); lines.push(format!("checkout expires at: {}", payment_entry.expires_at)); return checkout_instruction_block(&lines.join("\n"), &payment_entry.url, include_qr); } @@ -74,6 +73,8 @@ fn render_terminal_qr(url: &str) -> Result { let code = QrCode::new(url.as_bytes())?; Ok(code .render() + .quiet_zone(true) + .module_dimensions(2, 1) .dark_color("\u{1b}[40m \u{1b}[0m") .light_color("\u{1b}[47m \u{1b}[0m") .build()) @@ -87,7 +88,7 @@ pub(crate) fn checkout_instruction_block(summary: &str, url: &str, include_qr: b block.push_str(&qr); } - block.push_str("\n\nCheckout URL:\n "); + block.push_str("\n\nOpen link: "); block.push_str(url); block } @@ -146,7 +147,7 @@ mod tests { assert!(block.contains("Payment is required to create alice.smith.")); assert!(block.contains("Scan this QR code to pay:")); assert!(block.contains("\u{1b}[47m"), "{block:?}"); - assert!(block.contains("Checkout URL:\n https://pay.example.test/checkout/ckt_123")); + assert!(block.contains("Open link: https://pay.example.test/checkout/ckt_123")); } #[test] @@ -162,7 +163,7 @@ mod tests { assert!(!block.contains("\u{1b}[47m")); assert_eq!( block, - "Payment is required to create alice.smith.\n\nCheckout URL:\n https://pay.example.test/checkout/ckt_123" + "Payment is required to create alice.smith.\n\nOpen link: https://pay.example.test/checkout/ckt_123" ); } @@ -178,6 +179,7 @@ mod tests { assert!(block.contains("payment required for alice.smith.dhttp.net")); assert!(block.contains("currency: USD")); assert!(block.contains("Scan this QR code to pay:")); - assert!(block.contains("Checkout URL:\n https://pay.example.com")); + assert!(block.contains("Open link: https://pay.example.com")); + assert!(!block.contains("tok_123")); } } diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 52d5826..3a50c36 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -127,10 +127,15 @@ fn certificate_chain_key_from_identity( fn generate_private_key_and_csr( name: &Name<'_>, ) -> Result<(impl Deref + use<>, String), Error> { - tracing::Span::current().pb_set_message(&format!("Generating private key for {name}...")); - let key_pem = rankey::generate_secp384r1_key() - .map_err(|e| Box::new(e) as Box) - .context(GenerateKeySnafu)?; + let key_pem = flow::progress::run_with_retained_progress( + "Generating secp384r1 ECC key pair locally...", + "Generated secp384r1 ECC key pair locally.", + || { + rankey::generate_secp384r1_key() + .map_err(|e| Box::new(e) as Box) + .context(GenerateKeySnafu) + }, + )?; tracing::Span::current().pb_set_message(&format!( "Generating Certificate Signing Request (CSR) for {name}..." )); diff --git a/genmeta-identity/src/cli/flow.rs b/genmeta-identity/src/cli/flow.rs index deb71b4..aa1615a 100644 --- a/genmeta-identity/src/cli/flow.rs +++ b/genmeta-identity/src/cli/flow.rs @@ -1,5 +1,5 @@ pub(crate) mod apply; -pub(crate) mod approval; +pub(crate) mod auth_plan; pub(crate) mod default_identity; pub(crate) mod device; pub(crate) mod email; diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 35e8375..3ede076 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -5,9 +5,7 @@ use snafu::{FromString, OptionExt, whatever}; use tracing::Instrument; use super::{ - approval, kind::IdentityKind, - local::{self, LocalIdentityStatus, LocalIdentitySummary}, target::{IdentityLevel, IdentityTarget}, }; use crate::{ @@ -19,13 +17,7 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq)] enum ApplyApprovalPlan { Email, - DirectIdentity { - auth_domain: String, - }, - HelperIdentity { - auth_domain: String, - action: approval::ApprovalHelperAction, - }, + DirectIdentity { auth_domain: String }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -67,40 +59,6 @@ fn apply_email_actions(return_to: Option<&str>) -> Vec { actions } -#[derive(Debug, Clone, PartialEq, Eq)] -enum ApplyApprovalMenuAction { - ChangeCertificateKind, - ChangeIdentitySelection, - ReturnToCaller { label: String }, -} - -impl ApplyApprovalMenuAction { - fn label(&self) -> String { - match self { - Self::ChangeCertificateKind => { - "Change certificate kind (go back to identity kind)".to_string() - } - Self::ChangeIdentitySelection => { - "Change identity (go back to identity selection)".to_string() - } - Self::ReturnToCaller { label } => format!("Return to {label}"), - } - } -} - -fn apply_approval_menu_actions(return_to: Option<&str>) -> Vec { - let mut actions = vec![ - ApplyApprovalMenuAction::ChangeCertificateKind, - ApplyApprovalMenuAction::ChangeIdentitySelection, - ]; - if let Some(label) = return_to { - actions.push(ApplyApprovalMenuAction::ReturnToCaller { - label: label.to_string(), - }); - } - actions -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ApplyRunOutcome { Applied, @@ -110,114 +68,46 @@ pub(crate) enum ApplyRunOutcome { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ApplyPostSavePolicy { ManageDefaultSuggestion, - SkipDefaultSuggestion, } #[derive(Debug, Clone, PartialEq, Eq)] enum ApplyVerifyCodeAction { ResendVerificationCode, ChangeEmail, - SwitchVerificationMethod, - ChangeCertificateKind, - ChangeIdentitySelection, - ReturnToCaller { label: String }, + Cancel, } impl ApplyVerifyCodeAction { fn label(&self) -> String { match self { Self::ResendVerificationCode => "Resend verification code".to_string(), - Self::ChangeEmail => "Send code to another email (go back to email)".to_string(), - Self::SwitchVerificationMethod => { - "Switch verification method (go back to verification method selection)".to_string() - } - Self::ChangeCertificateKind => { - "Change certificate kind (go back to identity kind)".to_string() - } - Self::ChangeIdentitySelection => { - "Change identity (go back to identity selection)".to_string() - } - Self::ReturnToCaller { label } => format!("Return to {label}"), + Self::ChangeEmail => "Change email".to_string(), + Self::Cancel => "Cancel".to_string(), } } } -fn apply_verify_code_actions(return_to: Option<&str>) -> Vec { - let mut actions = vec![ +fn apply_verify_code_actions(_return_to: Option<&str>) -> Vec { + vec![ ApplyVerifyCodeAction::ResendVerificationCode, ApplyVerifyCodeAction::ChangeEmail, - ApplyVerifyCodeAction::SwitchVerificationMethod, - ApplyVerifyCodeAction::ChangeCertificateKind, - ApplyVerifyCodeAction::ChangeIdentitySelection, - ]; - if let Some(label) = return_to { - actions.push(ApplyVerifyCodeAction::ReturnToCaller { - label: label.to_string(), - }); - } - actions -} - -fn build_apply_approval_options( - candidate: Option, -) -> Vec { - approval::build_options_for_candidate("Verify with email", candidate) + ApplyVerifyCodeAction::Cancel, + ] } fn apply_verification_options(auth_domain: &str) -> Vec<(String, ApplyApprovalPlan)> { let short_name = IdentityTarget::parse(auth_domain) .map(|target| target.short_name().to_string()) .unwrap_or_else(|_| auth_domain.to_string()); - build_apply_approval_options(Some(approval::LocalApprovalCandidate::ready( - short_name, - auth_domain, - ))) - .into_iter() - .filter_map(|option| match option { - approval::ApprovalMenuOption::Email { label } => Some((label, ApplyApprovalPlan::Email)), - approval::ApprovalMenuOption::DirectLocal(local) => Some(( - format!("Verify with {} on local device", local.short_name), + vec![ + ( + format!("Verify with {short_name} on local device"), ApplyApprovalPlan::DirectIdentity { - auth_domain: local.auth_domain, + auth_domain: auth_domain.to_string(), }, - )), - approval::ApprovalMenuOption::Helper(_) => None, - }) - .collect() -} - -fn apply_candidate_from_summary( - summary: &LocalIdentitySummary, -) -> approval::LocalApprovalCandidate { - let short_name = summary.target.short_name().to_string(); - let auth_domain = summary.target.full_name(); - match &summary.status { - LocalIdentityStatus::Ready { .. } => { - approval::LocalApprovalCandidate::ready(short_name, auth_domain) - } - LocalIdentityStatus::Expired { .. } => { - approval::LocalApprovalCandidate::expired(short_name, auth_domain, true, true) - } - LocalIdentityStatus::Incomplete { detail } => { - approval::LocalApprovalCandidate::incomplete(short_name, auth_domain, detail.clone()) - } - LocalIdentityStatus::Invalid { detail } => { - approval::LocalApprovalCandidate::invalid(short_name, auth_domain, detail.clone()) - } - } -} - -fn apply_plan_from_option(option: &approval::ApprovalMenuOption) -> ApplyApprovalPlan { - match option { - approval::ApprovalMenuOption::Email { .. } => ApplyApprovalPlan::Email, - approval::ApprovalMenuOption::DirectLocal(local) => ApplyApprovalPlan::DirectIdentity { - auth_domain: local.auth_domain.clone(), - }, - approval::ApprovalMenuOption::Helper(helper) => ApplyApprovalPlan::HelperIdentity { - auth_domain: helper.auth_domain.clone(), - action: helper.action.clone(), - }, - } + ), + ("Verify with email".to_string(), ApplyApprovalPlan::Email), + ] } #[derive(Debug, Clone)] @@ -297,6 +187,10 @@ fn apply_verification_recovery( crate::cli::flow::transcript::print_line(message); true } + crate::cli::flow::recovery::VerificationRecovery::OfferResend { message } => { + crate::cli::flow::transcript::print_line(message); + true + } crate::cli::flow::recovery::VerificationRecovery::BackToEmail { message } => { crate::cli::flow::transcript::print_line(message); state.revisit_email(); @@ -306,6 +200,32 @@ fn apply_verification_recovery( } } +async fn offer_expired_code_resend( + state: &mut InteractiveApplyState, + cert_server: &CertServer, + email: &str, + message: &str, +) -> Result<(), Error> { + crate::cli::flow::transcript::print_line(message); + let resend = crate::cli::prompt::sync(|| { + inquire::Confirm::new("Send a new verification code?") + .with_default(true) + .prompt() + }) + .await + .require_interactive("interactive input")?; + if resend { + super::progress::run_with_spinner( + "Sending verification code...", + cert_server.send_email_verification(email), + ) + .await?; + state.verification_code_sent_to = Some(email.to_string()); + } + state.verify_code = None; + Ok(()) +} + fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { error.is_api_code("domain_not_found") } @@ -400,11 +320,10 @@ fn resolve_non_interactive_approval_plan( }) } None => { - if identity_auth_domain.is_some() { - whatever!( - "applying {} non-interactively requires choosing an approval path; rerun with --auth email or --auth identity", - target - ); + if let Some(auth_domain) = identity_auth_domain { + return Ok(ApplyApprovalPlan::DirectIdentity { + auth_domain: auth_domain.to_string(), + }); } Ok(ApplyApprovalPlan::Email) } @@ -444,7 +363,7 @@ async fn resolve_approval_plan( } fn apply_identity_name_opening() -> &'static str { - "Apply an identity here.\n\nThis will create a new certificate chain for the selected identity and save it here.\nIf the identity does not exist yet, this flow can register it first and then continue.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo apply a sub-identity, add one more name before it:\n phone.alice.smith" + "Name format:\n [handle.]your.name" } fn explicit_target_from_command( @@ -490,128 +409,6 @@ async fn resolve_email(command: &Apply) -> Result { } } -async fn resolve_identity_auth_domain( - dhttp_home: &DhttpHome, - target: &IdentityTarget, -) -> Result>, Error> { - if dhttp_home - .identity_profile_exists_exactly(target.dhttp_name()) - .await - { - let summary = local::load_summary(dhttp_home, target.dhttp_name(), None).await?; - if matches!(summary.status, LocalIdentityStatus::Ready { .. }) { - return Ok(Some(target.dhttp_name().into_owned())); - } - } - - if target.level() == IdentityLevel::SubIdentity - && let Some(parent) = target.parent() - && dhttp_home - .identity_profile_exists_exactly(parent.clone()) - .await - { - let summary = local::load_summary(dhttp_home, parent.clone(), None).await?; - if matches!(summary.status, LocalIdentityStatus::Ready { .. }) { - return Ok(Some(parent.into_owned())); - } - } - - Ok(None) -} - -async fn resolve_apply_candidate( - dhttp_home: &DhttpHome, - target: &IdentityTarget, -) -> Result, Error> { - let target_candidate = if dhttp_home - .identity_profile_exists_exactly(target.dhttp_name()) - .await - { - let summary = local::load_summary(dhttp_home, target.dhttp_name(), None).await?; - Some(apply_candidate_from_summary(&summary)) - } else { - None - }; - - let parent_candidate = if target.level() == IdentityLevel::SubIdentity { - if let Some(parent) = target.parent() { - if dhttp_home - .identity_profile_exists_exactly(parent.clone()) - .await - { - let summary = local::load_summary(dhttp_home, parent.clone(), None).await?; - Some(apply_candidate_from_summary(&summary)) - } else { - None - } - } else { - None - } - } else { - None - }; - - if matches!( - target_candidate, - Some(approval::LocalApprovalCandidate::Ready { .. }) - ) { - return Ok(target_candidate); - } - if matches!( - parent_candidate, - Some(approval::LocalApprovalCandidate::Ready { .. }) - ) { - return Ok(parent_candidate); - } - - Ok(target_candidate.or(parent_candidate)) -} - -async fn run_helper_apply_action( - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, - auth_domain: &str, - action: approval::ApprovalHelperAction, - return_to: Option<&str>, -) -> Result { - match action { - approval::ApprovalHelperAction::Apply | approval::ApprovalHelperAction::Reapply => { - let command = Apply { - name: Some(auth_domain.to_string()), - kind: None, - replace_local: matches!(action, approval::ApprovalHelperAction::Reapply), - device_name: None, - email: None, - send_code: false, - verify_code: None, - auth: None, - }; - match Box::pin(run_interactive( - &command, - dhttp_home, - home_scope, - cert_server, - return_to, - )) - .await? - { - ApplyRunOutcome::Applied => Ok(true), - ApplyRunOutcome::ReturnedToCaller => Ok(false), - } - } - approval::ApprovalHelperAction::Renew => { - super::renew::run_helper_for_verification( - dhttp_home, - cert_server, - auth_domain, - return_to.unwrap_or("apply"), - ) - .await - } - } -} - async fn prompt_apply_verify_code_action( return_to: Option<&str>, ) -> Result { @@ -646,24 +443,6 @@ async fn prompt_apply_email_action(return_to: Option<&str>) -> Result("selected apply email action is unavailable") } -async fn prompt_apply_approval_menu_action( - return_to: Option<&str>, -) -> Result { - let actions = apply_approval_menu_actions(return_to); - let labels = actions - .iter() - .map(ApplyApprovalMenuAction::label) - .collect::>(); - let selected = crate::cli::prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected apply approval action is unavailable") -} - async fn run_post_save_epilogue( post_save: ApplyPostSavePolicy, dhttp_home: &DhttpHome, @@ -672,28 +451,16 @@ async fn run_post_save_epilogue( interactive: bool, welcome: Option<&super::welcome::WelcomeServiceCreated>, ) -> Result<(), Error> { - match post_save { - ApplyPostSavePolicy::ManageDefaultSuggestion => { - crate::cli::flow::epilogue::run_lifecycle_epilogue( - dhttp_home, - domain, - default_identity_when_command_started, - interactive, - super::output::SavedIdentityAction::Applied, - welcome, - ) - .await - } - ApplyPostSavePolicy::SkipDefaultSuggestion => { - crate::cli::flow::epilogue::run_local_epilogue( - dhttp_home, - domain, - super::output::SavedIdentityAction::Applied, - welcome, - ) - .await - } - } + let ApplyPostSavePolicy::ManageDefaultSuggestion = post_save; + crate::cli::flow::epilogue::run_lifecycle_epilogue( + dhttp_home, + domain, + default_identity_when_command_started, + interactive, + super::output::SavedIdentityAction::Applied, + welcome, + ) + .await } fn register_during_apply_message(target: &IdentityTarget) -> String { @@ -849,50 +616,16 @@ async fn run_interactive_with_policy( .clone() .whatever_context::<_, Error>("interactive apply target is unavailable")?; let target = IdentityTarget::parse(domain.as_partial())?; - let identity_auth_domain = resolve_identity_auth_domain(dhttp_home, &target).await?; - let approval_candidate = resolve_apply_candidate(dhttp_home, &target).await?; - if state.approval_plan.is_none() { - if let Some(auth) = command.auth { - state.approval_plan = Some(resolve_non_interactive_approval_plan( - target.short_name(), - Some(auth), - identity_auth_domain.as_ref().map(|name| name.as_full()), - )?); - continue; - } - - if let Some(candidate) = approval_candidate.clone() { - let options = build_apply_approval_options(Some(candidate)); - let mut labels = options - .iter() - .map(approval::ApprovalMenuOption::label) - .collect::>(); - labels.push(crate::cli::prompt::MORE_OPTIONS_LABEL.to_string()); - let message = format!("Choose how to verify applying {}:", target.short_name()); - let selected = crate::cli::prompt::prompt_select_string(&message, labels) - .await - .require_interactive("--auth")?; - if selected == crate::cli::prompt::MORE_OPTIONS_LABEL { - match prompt_apply_approval_menu_action(return_to).await? { - ApplyApprovalMenuAction::ChangeCertificateKind => state.revisit_kind(), - ApplyApprovalMenuAction::ChangeIdentitySelection => { - state.revisit_target_selection(); - } - ApplyApprovalMenuAction::ReturnToCaller { .. } => { - return Ok(ApplyRunOutcome::ReturnedToCaller); - } - } - } else { - let option = options - .iter() - .find(|option| option.label() == selected) - .whatever_context::<_, Error>("selected approval path is unavailable")?; - state.approval_plan = Some(apply_plan_from_option(option)); - } - } else { - state.approval_plan = Some(ApplyApprovalPlan::Email); + let auth_plan = super::auth_plan::load_apply_auth_plan(dhttp_home, &target).await?; + for warning in &auth_plan.warnings { + crate::cli::flow::transcript::print_err_block(warning); } + state.approval_plan = Some(resolve_non_interactive_approval_plan( + target.short_name(), + command.auth, + auth_plan.first_identity_full_name(), + )?); continue; } @@ -900,29 +633,6 @@ async fn run_interactive_with_policy( .approval_plan .clone() .whatever_context::<_, Error>("interactive apply approval plan is unavailable")?; - if let ApplyApprovalPlan::HelperIdentity { - auth_domain, - action, - } = approval_plan.clone() - { - if !run_helper_apply_action( - dhttp_home, - home_scope, - cert_server, - &auth_domain, - action, - return_to, - ) - .await? - { - state.approval_plan = None; - state.revisit_verification_method(); - continue; - } - state.approval_plan = Some(ApplyApprovalPlan::DirectIdentity { auth_domain }); - continue; - } - if matches!(approval_plan, ApplyApprovalPlan::Email) && (state.email.is_none() || state.email_prompt_required) { @@ -1012,14 +722,7 @@ async fn run_interactive_with_policy( } } ApplyVerifyCodeAction::ChangeEmail => state.revisit_email(), - ApplyVerifyCodeAction::SwitchVerificationMethod => { - state.revisit_verification_method(); - } - ApplyVerifyCodeAction::ChangeCertificateKind => state.revisit_kind(), - ApplyVerifyCodeAction::ChangeIdentitySelection => { - state.revisit_target_selection(); - } - ApplyVerifyCodeAction::ReturnToCaller { .. } => { + ApplyVerifyCodeAction::Cancel => { return Ok(ApplyRunOutcome::ReturnedToCaller); } } @@ -1056,6 +759,14 @@ async fn run_interactive_with_policy( Err(error) => { let recovery = crate::cli::flow::recovery::classify_verify_submit_error(&error); + if let crate::cli::flow::recovery::VerificationRecovery::OfferResend { + message, + } = &recovery + { + offer_expired_code_resend(&mut state, cert_server, &email, message) + .await?; + continue; + } if matches!( recovery, crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } @@ -1184,9 +895,6 @@ async fn run_interactive_with_policy( ) .await? } - ApplyApprovalPlan::HelperIdentity { .. } => { - unreachable!("helper approval plan should be resolved before issuing certificate") - } }; cli::save_identity( @@ -1216,24 +924,6 @@ async fn run_interactive_with_policy( } } -pub(crate) async fn run_interactive( - command: &Apply, - dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, - return_to: Option<&str>, -) -> Result { - run_interactive_with_policy( - command, - dhttp_home, - home_scope, - cert_server, - return_to, - ApplyPostSavePolicy::ManageDefaultSuggestion, - ) - .await -} - pub(crate) async fn run_with_policy( command: &Apply, dhttp_home: &DhttpHome, @@ -1265,11 +955,14 @@ pub(crate) async fn run_with_policy( let kind = resolve_kind(command).await?; let device_name = super::device::resolve_device_name(command.device_name.as_deref(), home_scope); - let identity_auth_domain = resolve_identity_auth_domain(dhttp_home, &target).await?; + let auth_plan = super::auth_plan::load_apply_auth_plan(dhttp_home, &target).await?; + for warning in &auth_plan.warnings { + crate::cli::flow::transcript::print_err_block(warning); + } let approval_plan = resolve_approval_plan( target.short_name(), command.auth, - identity_auth_domain.as_ref().map(|name| name.as_full()), + auth_plan.first_identity_full_name(), is_interactive, ) .await?; @@ -1360,9 +1053,6 @@ pub(crate) async fn run_with_policy( Err(error) => return Err(Error::from(error)), } } - ApplyApprovalPlan::HelperIdentity { .. } => { - unreachable!("helper approval plan should be resolved before issuing certificate") - } }; cli::save_identity( @@ -1409,23 +1099,16 @@ pub(crate) async fn run( #[cfg(test)] mod tests { use super::{ - ApplyApprovalMenuAction, ApplyApprovalPlan, ApplyEmailAction, ApplyVerifyCodeAction, - InteractiveApplyState, apply_approval_menu_actions, apply_email_actions, - apply_identity_name_opening, apply_verification_options, apply_verify_code_actions, - approval_plan_from_selection, build_apply_approval_options, - classify_apply_email_issue_error, explicit_target_from_command, - register_during_apply_message, resolve_non_interactive_approval_plan, - rewrite_apply_email_issue_error, rewrite_apply_registration_error, + ApplyApprovalPlan, ApplyEmailAction, ApplyVerifyCodeAction, InteractiveApplyState, + apply_email_actions, apply_identity_name_opening, apply_verification_options, + apply_verify_code_actions, approval_plan_from_selection, classify_apply_email_issue_error, + explicit_target_from_command, register_during_apply_message, + resolve_non_interactive_approval_plan, rewrite_apply_email_issue_error, + rewrite_apply_registration_error, }; use crate::{ auth::AuthMethod, - cli::{ - Apply, - flow::{ - approval::{ApprovalMenuOption, LocalApprovalCandidate}, - target::IdentityTarget, - }, - }, + cli::{Apply, flow::target::IdentityTarget}, }; #[test] @@ -1544,12 +1227,14 @@ mod tests { } #[test] - fn root_apply_with_local_auth_requires_explicit_auth_non_interactively() { - let error = resolve_non_interactive_approval_plan("alice.smith", None, Some("alice.smith")) - .unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("--auth email"), "{rendered}"); - assert!(rendered.contains("--auth identity"), "{rendered}"); + fn root_apply_prefers_ready_local_auth_non_interactively() { + assert_eq!( + resolve_non_interactive_approval_plan("alice.smith", None, Some("alice.smith")) + .unwrap(), + ApplyApprovalPlan::DirectIdentity { + auth_domain: "alice.smith".to_string(), + }, + ); } #[test] @@ -1581,23 +1266,22 @@ mod tests { } #[test] - fn sub_identity_apply_with_parent_local_auth_still_requires_explicit_auth_when_missing() { - let error = - resolve_non_interactive_approval_plan("phone.alice.smith", None, Some("alice.smith")) - .unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("--auth email"), "{rendered}"); - assert!(rendered.contains("--auth identity"), "{rendered}"); + fn sub_identity_apply_automatically_uses_ready_parent() { + assert_eq!( + resolve_non_interactive_approval_plan("phone.alice.smith", None, Some("alice.smith"),) + .unwrap(), + ApplyApprovalPlan::DirectIdentity { + auth_domain: "alice.smith".to_string(), + }, + ); } #[test] fn apply_identity_name_opening_matches_spec_copy() { - let opening = apply_identity_name_opening(); - assert!(opening.contains("Apply an identity here.")); - assert!(opening.contains(".")); - assert!(opening.contains("alice.smith")); - assert!(opening.contains("phone.alice.smith")); - assert!(opening.contains("register it first"), "{opening}"); + assert_eq!( + apply_identity_name_opening(), + "Name format:\n [handle.]your.name" + ); } #[test] @@ -1683,48 +1367,6 @@ mod tests { )); } - #[test] - fn apply_with_invalid_local_identity_uses_reapply_copy() { - let options = build_apply_approval_options(Some(LocalApprovalCandidate::invalid( - "alice.smith", - "alice.smith.dhttp.net", - "certificate is unreadable", - ))); - - assert_eq!( - options - .iter() - .map(ApprovalMenuOption::label) - .collect::>(), - vec![ - "Verify with email".to_string(), - "Re-apply alice.smith here, then verify with alice.smith".to_string(), - ] - ); - } - - #[test] - fn apply_with_expired_local_identity_shows_renew_before_reapply() { - let options = build_apply_approval_options(Some(LocalApprovalCandidate::expired( - "alice.smith", - "alice.smith.dhttp.net", - true, - true, - ))); - - assert_eq!( - options - .iter() - .map(ApprovalMenuOption::label) - .collect::>(), - vec![ - "Verify with email".to_string(), - "Renew alice.smith here, then verify with alice.smith".to_string(), - "Re-apply alice.smith here, then verify with alice.smith".to_string(), - ] - ); - } - #[test] fn interactive_apply_selection_can_choose_email() { let options = apply_verification_options("alice.smith.dhttp.net"); @@ -1757,11 +1399,8 @@ mod tests { .collect::>(), vec![ "Resend verification code".to_string(), - "Send code to another email (go back to email)".to_string(), - "Switch verification method (go back to verification method selection)".to_string(), - "Change certificate kind (go back to identity kind)".to_string(), - "Change identity (go back to identity selection)".to_string(), - "Return to create phone.alice.smith".to_string(), + "Change email".to_string(), + "Cancel".to_string(), ] ); assert_eq!( @@ -1769,12 +1408,7 @@ mod tests { vec![ ApplyVerifyCodeAction::ResendVerificationCode, ApplyVerifyCodeAction::ChangeEmail, - ApplyVerifyCodeAction::SwitchVerificationMethod, - ApplyVerifyCodeAction::ChangeCertificateKind, - ApplyVerifyCodeAction::ChangeIdentitySelection, - ApplyVerifyCodeAction::ReturnToCaller { - label: "create phone.alice.smith".to_string(), - }, + ApplyVerifyCodeAction::Cancel, ] ); } @@ -1805,29 +1439,4 @@ mod tests { ] ); } - - #[test] - fn apply_approval_menu_actions_include_explicit_return_points() { - assert_eq!( - apply_approval_menu_actions(Some("create phone.alice.smith")) - .into_iter() - .map(|action| action.label()) - .collect::>(), - vec![ - "Change certificate kind (go back to identity kind)".to_string(), - "Change identity (go back to identity selection)".to_string(), - "Return to create phone.alice.smith".to_string(), - ] - ); - assert_eq!( - apply_approval_menu_actions(Some("create phone.alice.smith")), - vec![ - ApplyApprovalMenuAction::ChangeCertificateKind, - ApplyApprovalMenuAction::ChangeIdentitySelection, - ApplyApprovalMenuAction::ReturnToCaller { - label: "create phone.alice.smith".to_string(), - }, - ] - ); - } } diff --git a/genmeta-identity/src/cli/flow/approval.rs b/genmeta-identity/src/cli/flow/approval.rs deleted file mode 100644 index aee0918..0000000 --- a/genmeta-identity/src/cli/flow/approval.rs +++ /dev/null @@ -1,360 +0,0 @@ -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ApprovalHelperAction { - Apply, - Reapply, - Renew, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ApprovalDirectLocal { - pub(crate) short_name: String, - pub(crate) auth_domain: String, -} - -impl ApprovalDirectLocal { - pub(crate) fn new(short_name: impl Into, auth_domain: impl Into) -> Self { - Self { - short_name: short_name.into(), - auth_domain: auth_domain.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum LocalApprovalCandidate { - Ready { - short_name: String, - auth_domain: String, - }, - Expired { - short_name: String, - auth_domain: String, - can_renew: bool, - can_reapply: bool, - }, - Incomplete { - short_name: String, - auth_domain: String, - detail: String, - }, - Invalid { - short_name: String, - auth_domain: String, - detail: String, - }, - Missing { - short_name: String, - auth_domain: String, - }, -} - -impl LocalApprovalCandidate { - pub(crate) fn ready(short_name: impl Into, auth_domain: impl Into) -> Self { - Self::Ready { - short_name: short_name.into(), - auth_domain: auth_domain.into(), - } - } - - pub(crate) fn expired( - short_name: impl Into, - auth_domain: impl Into, - can_renew: bool, - can_reapply: bool, - ) -> Self { - Self::Expired { - short_name: short_name.into(), - auth_domain: auth_domain.into(), - can_renew, - can_reapply, - } - } - - pub(crate) fn incomplete( - short_name: impl Into, - auth_domain: impl Into, - detail: impl Into, - ) -> Self { - Self::Incomplete { - short_name: short_name.into(), - auth_domain: auth_domain.into(), - detail: detail.into(), - } - } - - pub(crate) fn invalid( - short_name: impl Into, - auth_domain: impl Into, - detail: impl Into, - ) -> Self { - Self::Invalid { - short_name: short_name.into(), - auth_domain: auth_domain.into(), - detail: detail.into(), - } - } - - pub(crate) fn missing(short_name: impl Into, auth_domain: impl Into) -> Self { - Self::Missing { - short_name: short_name.into(), - auth_domain: auth_domain.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ApprovalHelperOption { - pub(crate) action: ApprovalHelperAction, - pub(crate) short_name: String, - pub(crate) auth_domain: String, - pub(crate) detail: Option, -} - -impl ApprovalHelperOption { - #[cfg(test)] - pub(crate) fn apply(short_name: impl Into) -> Self { - let short_name = short_name.into(); - Self::apply_for(short_name.clone(), short_name) - } - - pub(crate) fn apply_for(short_name: impl Into, auth_domain: impl Into) -> Self { - Self { - action: ApprovalHelperAction::Apply, - auth_domain: auth_domain.into(), - short_name: short_name.into(), - detail: None, - } - } - - #[cfg(test)] - pub(crate) fn reapply(short_name: impl Into, detail: impl Into) -> Self { - let short_name = short_name.into(); - Self::reapply_for(short_name.clone(), short_name, detail) - } - - pub(crate) fn reapply_for( - short_name: impl Into, - auth_domain: impl Into, - detail: impl Into, - ) -> Self { - Self { - action: ApprovalHelperAction::Reapply, - auth_domain: auth_domain.into(), - short_name: short_name.into(), - detail: Some(detail.into()), - } - } - - #[cfg(test)] - pub(crate) fn renew(short_name: impl Into) -> Self { - let short_name = short_name.into(); - Self::renew_for(short_name.clone(), short_name) - } - - pub(crate) fn renew_for(short_name: impl Into, auth_domain: impl Into) -> Self { - Self { - action: ApprovalHelperAction::Renew, - auth_domain: auth_domain.into(), - short_name: short_name.into(), - detail: None, - } - } - - pub(crate) fn label(&self) -> String { - match self.action { - ApprovalHelperAction::Apply => format!( - "Apply {} here, then verify with {}", - self.short_name, self.short_name - ), - ApprovalHelperAction::Reapply => format!( - "Re-apply {} here, then verify with {}", - self.short_name, self.short_name - ), - ApprovalHelperAction::Renew => format!( - "Renew {} here, then verify with {}", - self.short_name, self.short_name - ), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ApprovalMenuOption { - DirectLocal(ApprovalDirectLocal), - Email { label: String }, - Helper(ApprovalHelperOption), -} - -impl ApprovalMenuOption { - pub(crate) fn label(&self) -> String { - match self { - Self::DirectLocal(option) => { - format!("Verify with {} on local device", option.short_name) - } - Self::Email { label } => label.clone(), - Self::Helper(option) => option.label(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ApprovalMenuSpec { - pub(crate) email_label: String, - pub(crate) direct_local: Vec, - pub(crate) helpers: Vec, -} - -pub(crate) fn build_approval_options(spec: ApprovalMenuSpec) -> Vec { - let mut options = spec - .direct_local - .into_iter() - .map(ApprovalMenuOption::DirectLocal) - .collect::>(); - options.push(ApprovalMenuOption::Email { - label: spec.email_label, - }); - - if options - .iter() - .any(|option| matches!(option, ApprovalMenuOption::DirectLocal(_))) - { - return options; - } - - let mut helpers = spec.helpers; - helpers.sort_by_key(|helper| match helper.action { - ApprovalHelperAction::Renew => 0, - ApprovalHelperAction::Reapply => 1, - ApprovalHelperAction::Apply => 2, - }); - options.extend(helpers.into_iter().map(ApprovalMenuOption::Helper)); - options -} - -pub(crate) fn build_options_for_candidate( - email_label: impl Into, - candidate: Option, -) -> Vec { - let mut direct_local = Vec::new(); - let mut helpers = Vec::new(); - - match candidate { - Some(LocalApprovalCandidate::Ready { - short_name, - auth_domain, - }) => { - direct_local.push(ApprovalDirectLocal::new(short_name, auth_domain)); - } - Some(LocalApprovalCandidate::Expired { - short_name, - auth_domain, - can_renew, - can_reapply, - }) => { - if can_renew { - helpers.push(ApprovalHelperOption::renew_for( - short_name.clone(), - auth_domain.clone(), - )); - } - if can_reapply { - helpers.push(ApprovalHelperOption::reapply_for( - short_name, - auth_domain, - "expired local identity", - )); - } - } - Some(LocalApprovalCandidate::Incomplete { - short_name, - auth_domain, - detail, - }) - | Some(LocalApprovalCandidate::Invalid { - short_name, - auth_domain, - detail, - }) => { - helpers.push(ApprovalHelperOption::reapply_for( - short_name, - auth_domain, - detail, - )); - } - Some(LocalApprovalCandidate::Missing { - short_name, - auth_domain, - }) => { - helpers.push(ApprovalHelperOption::apply_for(short_name, auth_domain)); - } - None => {} - } - - build_approval_options(ApprovalMenuSpec { - email_label: email_label.into(), - direct_local, - helpers, - }) -} - -#[cfg(test)] -mod tests { - use super::{ - ApprovalDirectLocal, ApprovalHelperOption, ApprovalMenuOption, ApprovalMenuSpec, - build_approval_options, - }; - - #[test] - fn direct_local_hides_helper_options() { - let options = build_approval_options(ApprovalMenuSpec { - email_label: "Verify with email".to_string(), - direct_local: vec![ApprovalDirectLocal::new("alice.smith", "alice.smith")], - helpers: vec![ApprovalHelperOption::apply("alice.smith")], - }); - - assert_eq!( - options - .iter() - .map(ApprovalMenuOption::label) - .collect::>(), - vec![ - "Verify with alice.smith on local device".to_string(), - "Verify with email".to_string(), - ] - ); - } - - #[test] - fn expired_identity_sorts_renew_before_reapply() { - let options = build_approval_options(ApprovalMenuSpec { - email_label: "Verify with email".to_string(), - direct_local: vec![], - helpers: vec![ - ApprovalHelperOption::reapply("alice.smith", "expired local identity"), - ApprovalHelperOption::renew("alice.smith"), - ], - }); - - assert_eq!( - options - .iter() - .map(ApprovalMenuOption::label) - .collect::>(), - vec![ - "Verify with email".to_string(), - "Renew alice.smith here, then verify with alice.smith".to_string(), - "Re-apply alice.smith here, then verify with alice.smith".to_string(), - ] - ); - } - - #[test] - fn missing_local_identity_uses_apply_copy() { - let option = ApprovalHelperOption::apply("alice.smith"); - - assert_eq!( - option.label(), - "Apply alice.smith here, then verify with alice.smith" - ); - } -} diff --git a/genmeta-identity/src/cli/flow/auth_plan.rs b/genmeta-identity/src/cli/flow/auth_plan.rs new file mode 100644 index 0000000..602379b --- /dev/null +++ b/genmeta-identity/src/cli/flow/auth_plan.rs @@ -0,0 +1,216 @@ +use dhttp::home::DhttpHome; + +use super::{ + local::{self, LocalIdentityStatus, LocalIdentitySummary}, + target::{IdentityLevel, IdentityTarget}, +}; +use crate::cli::Error; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AuthCandidate { + Identity { + short_name: String, + full_name: String, + }, + Email, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthPlan { + pub(crate) candidates: Vec, + pub(crate) warnings: Vec, +} + +impl AuthPlan { + pub(crate) fn first_identity_full_name(&self) -> Option<&str> { + self.candidates + .iter() + .find_map(|candidate| match candidate { + AuthCandidate::Identity { full_name, .. } => Some(full_name.as_str()), + AuthCandidate::Email => None, + }) + } +} + +fn unavailable_reason(summary: &LocalIdentitySummary) -> String { + match &summary.status { + LocalIdentityStatus::Expired { .. } => "its local certificate has expired".to_string(), + LocalIdentityStatus::Incomplete { detail } => { + format!("its local identity is incomplete: {detail}") + } + LocalIdentityStatus::Invalid { detail } => { + format!("its local identity is invalid: {detail}") + } + LocalIdentityStatus::Ready { .. } => unreachable!("ready identities are available"), + } +} + +pub(crate) fn plan_auth_candidates( + target: Option<&LocalIdentitySummary>, + parent: Option<&LocalIdentitySummary>, +) -> AuthPlan { + let summaries = [target, parent]; + let mut candidates = Vec::new(); + let mut warnings = Vec::new(); + + for (index, summary) in summaries.iter().enumerate() { + let Some(summary) = summary else { + continue; + }; + if summary.status.is_ready() { + candidates.push(AuthCandidate::Identity { + short_name: summary.target.short_name().to_string(), + full_name: summary.target.full_name().to_string(), + }); + continue; + } + + let next_ready = summaries[index + 1..] + .iter() + .flatten() + .find(|candidate| candidate.status.is_ready()); + let action = match next_ready { + Some(next) => format!("Trying its parent identity, {}.", next.target.short_name()), + None => "Falling back to email verification.".to_string(), + }; + warnings.push(format!( + "Cannot verify with {} because {}.\n{action}", + summary.target.short_name(), + unavailable_reason(summary), + )); + } + + candidates.push(AuthCandidate::Email); + AuthPlan { + candidates, + warnings, + } +} + +pub(crate) async fn load_apply_auth_plan( + dhttp_home: &DhttpHome, + target: &IdentityTarget, +) -> Result { + let target_summary = local::try_load_summary(dhttp_home, target.dhttp_name(), None).await?; + let parent_summary = if target.level() == IdentityLevel::SubIdentity { + match target.parent() { + Some(parent) => local::try_load_summary(dhttp_home, parent, None).await?, + None => None, + } + } else { + None + }; + Ok(plan_auth_candidates( + target_summary.as_ref(), + parent_summary.as_ref(), + )) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::cli::flow::{ + local::{LocalIdentityStatus, LocalIdentitySummary}, + target::IdentityTarget, + }; + + fn summary(name: &str, status: LocalIdentityStatus) -> LocalIdentitySummary { + LocalIdentitySummary { + target: IdentityTarget::parse(name).unwrap(), + certificate_chain: Some("primary:0".to_string()), + valid_from: Some(1_600_000_000), + issuer: Some("CN=Genmeta Test CA".to_string()), + status, + saved_at: PathBuf::from(format!("/tmp/{name}")), + is_default: false, + } + } + + fn ready(name: &str) -> LocalIdentitySummary { + summary( + name, + LocalIdentityStatus::Ready { + expires_at: 1_900_000_000, + }, + ) + } + + #[test] + fn ready_subidentity_prefers_target_then_parent_then_email() { + let target = ready("handle.alice.smith"); + let parent = ready("alice.smith"); + let plan = plan_auth_candidates(Some(&target), Some(&parent)); + + assert_eq!( + plan.candidates, + vec![ + AuthCandidate::Identity { + short_name: "handle.alice.smith".into(), + full_name: "handle.alice.smith.dhttp.net".into(), + }, + AuthCandidate::Identity { + short_name: "alice.smith".into(), + full_name: "alice.smith.dhttp.net".into(), + }, + AuthCandidate::Email, + ] + ); + assert!(plan.warnings.is_empty()); + } + + #[test] + fn expired_target_warns_then_uses_ready_parent() { + let target = summary( + "handle.alice.smith", + LocalIdentityStatus::Expired { + expired_at: 1_700_000_000, + }, + ); + let parent = ready("alice.smith"); + let plan = plan_auth_candidates(Some(&target), Some(&parent)); + + assert_eq!( + plan.candidates, + vec![ + AuthCandidate::Identity { + short_name: "alice.smith".into(), + full_name: "alice.smith.dhttp.net".into(), + }, + AuthCandidate::Email, + ] + ); + assert_eq!( + plan.warnings, + vec![ + "Cannot verify with handle.alice.smith because its local certificate has expired.\nTrying its parent identity, alice.smith." + ] + ); + } + + #[test] + fn invalid_parent_warns_before_email() { + let parent = summary( + "alice.smith", + LocalIdentityStatus::Invalid { + detail: "certificate does not match local key".into(), + }, + ); + let plan = plan_auth_candidates(None, Some(&parent)); + + assert_eq!(plan.candidates, vec![AuthCandidate::Email]); + assert_eq!( + plan.warnings, + vec![ + "Cannot verify with alice.smith because its local identity is invalid: certificate does not match local key.\nFalling back to email verification." + ] + ); + } + + #[test] + fn unrelated_default_is_never_a_candidate() { + let plan = plan_auth_candidates(None, None); + assert_eq!(plan.candidates, vec![AuthCandidate::Email]); + } +} diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index f3ce19f..4b6e280 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -151,30 +151,6 @@ pub(crate) async fn run_lifecycle_epilogue( Ok(()) } -pub(crate) async fn run_local_epilogue( - dhttp_home: &DhttpHome, - name: DhttpName<'_>, - action: output::SavedIdentityAction, - welcome: Option<&super::welcome::WelcomeServiceCreated>, -) -> Result<(), Error> { - let ansi = std::io::stdout().is_terminal(); - let default_name = current_default_name(dhttp_home).await?; - let summary = local::load_summary( - dhttp_home, - name, - default_name.as_ref().map(|default| default.borrow()), - ) - .await?; - transcript::print_block(&output::format_saved_identity_result( - action, &summary, ansi, - )); - transcript::print_line(output::format_safekeeping_reminder(ansi)); - if let Some(welcome) = welcome { - transcript::print_block(&super::welcome::format_welcome_service_created(welcome)); - } - Ok(()) -} - #[cfg(test)] mod tests { use std::{ diff --git a/genmeta-identity/src/cli/flow/local.rs b/genmeta-identity/src/cli/flow/local.rs index e7a3de9..0b425f1 100644 --- a/genmeta-identity/src/cli/flow/local.rs +++ b/genmeta-identity/src/cli/flow/local.rs @@ -19,7 +19,9 @@ pub(crate) struct LocalIdentityAssessment { pub(crate) certificate: LocalIdentityMaterialState, pub(crate) private_key: LocalIdentityMaterialState, pub(crate) certificate_chain: Option, + pub(crate) valid_from: Option, pub(crate) expires_at: Option, + pub(crate) issuer: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -43,12 +45,22 @@ impl LocalIdentityStatus { pub(crate) fn is_ready(&self) -> bool { matches!(self, Self::Ready { .. }) } + + pub(crate) fn expires_at(&self) -> Option { + match self { + Self::Ready { expires_at } => Some(*expires_at), + Self::Expired { expired_at } => Some(*expired_at), + Self::Incomplete { .. } | Self::Invalid { .. } => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LocalIdentitySummary { pub(crate) target: IdentityTarget, pub(crate) certificate_chain: Option, + pub(crate) valid_from: Option, + pub(crate) issuer: Option, pub(crate) status: LocalIdentityStatus, pub(crate) saved_at: PathBuf, pub(crate) is_default: bool, @@ -88,6 +100,7 @@ pub(crate) struct LocalInventory { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg(test)] pub(crate) enum InteractiveInventoryChoice { Saved(LocalIdentitySummary), Organization { target: IdentityTarget }, @@ -257,6 +270,8 @@ pub(crate) async fn load_summary( Ok(LocalIdentitySummary { target, certificate_chain: assessment.certificate_chain, + valid_from: assessment.valid_from, + issuer: assessment.issuer, status, saved_at: profile.path().to_path_buf(), is_default: default_name @@ -266,6 +281,7 @@ pub(crate) async fn load_summary( }) } +#[cfg(test)] pub(crate) fn build_renew_inventory_choices( inventory: &LocalInventory, ) -> Vec { @@ -292,6 +308,7 @@ pub(crate) fn build_renew_inventory_choices( choices } +#[cfg(test)] pub(crate) fn build_default_inventory_choices( inventory: &LocalInventory, ) -> Vec { @@ -345,7 +362,9 @@ async fn assess_profile( certificate: certificate_state_from_error(&error), private_key: LocalIdentityMaterialState::Present, certificate_chain: None, + valid_from: None, expires_at: None, + issuer: None, }; } }; @@ -361,7 +380,9 @@ async fn assess_profile( certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, certificate_chain: None, + valid_from: None, expires_at: None, + issuer: None, }; match profile.load_key().await { @@ -400,7 +421,9 @@ async fn assess_profile( }; match x509_parser::parse_x509_certificate(leaf.as_ref()) { Ok((_, certificate)) => { + assessment.valid_from = Some(certificate.validity().not_before.timestamp()); assessment.expires_at = Some(certificate.validity().not_after.timestamp()); + assessment.issuer = Some(certificate.issuer().to_string()); } Err(_) => { assessment.certificate = @@ -484,6 +507,8 @@ mod tests { LocalIdentitySummary { target: IdentityTarget::parse(name).unwrap(), certificate_chain: Some(chain.to_string()), + valid_from: Some(NOW - 300), + issuer: Some("CN=Genmeta Test CA".to_string()), status: LocalIdentityStatus::Ready { expires_at: NOW + 300, }, @@ -499,7 +524,9 @@ mod tests { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, certificate_chain: Some("primary:0".to_string()), + valid_from: Some(NOW - 300), expires_at: Some(NOW + 300), + issuer: Some("CN=Genmeta Test CA".to_string()), }, NOW, ); @@ -515,7 +542,9 @@ mod tests { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, certificate_chain: Some("secondary:2".to_string()), + valid_from: Some(NOW - 300), expires_at: Some(NOW - 1), + issuer: Some("CN=Genmeta Test CA".to_string()), }, NOW, ); @@ -531,7 +560,9 @@ mod tests { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Missing("private key missing"), certificate_chain: Some("secondary:3".to_string()), + valid_from: Some(NOW - 300), expires_at: Some(NOW + 300), + issuer: Some("CN=Genmeta Test CA".to_string()), }, NOW, ); @@ -549,7 +580,9 @@ mod tests { ), private_key: LocalIdentityMaterialState::Present, certificate_chain: Some("secondary:4".to_string()), + valid_from: Some(NOW - 300), expires_at: Some(NOW + 300), + issuer: Some("CN=Genmeta Test CA".to_string()), }, NOW, ); diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index 587e0ef..48d527f 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -1,12 +1,10 @@ use crossterm::style::Stylize; -use super::{ - local::{ - InteractiveInventoryChoice, LocalIdentityStatus, LocalIdentitySummary, LocalInventory, - LocalInventoryRoot, - }, - target::IdentityLevel, -}; +#[cfg(test)] +use super::local::InteractiveInventoryChoice; +use super::local::{LocalIdentityStatus, LocalIdentitySummary, LocalInventory, LocalInventoryRoot}; +#[cfg(test)] +use super::target::IdentityLevel; pub(crate) fn render_inventory(inventory: &LocalInventory, ansi: bool) -> String { let mut lines = Vec::new(); @@ -63,17 +61,17 @@ pub(crate) enum DefaultIdentityBlock { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SavedIdentityAction { + #[cfg(test)] Created, Applied, - Renewed, } impl SavedIdentityAction { fn verb(self) -> &'static str { match self { + #[cfg(test)] Self::Created => "Created", Self::Applied => "Applied", - Self::Renewed => "Renewed", } } } @@ -146,6 +144,7 @@ pub(crate) fn format_current_default_suffix( ) } +#[cfg(test)] pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: bool) -> String { match choice { InteractiveInventoryChoice::Saved(summary) => { @@ -217,6 +216,7 @@ pub(crate) fn format_info(summary: &LocalIdentitySummary, ansi: bool) -> String lines.join("\n") } +#[cfg(test)] pub(crate) fn format_default_summary(summary: &LocalIdentitySummary, ansi: bool) -> String { format_info(summary, ansi) } @@ -254,19 +254,31 @@ fn format_natural_date(timestamp: i64) -> String { } fn detail_lines(summary: &LocalIdentitySummary) -> Vec { - let mut lines = Vec::new(); - match (&summary.status, summary.certificate_chain.as_deref()) { - (LocalIdentityStatus::Ready { .. }, Some(chain)) - | (LocalIdentityStatus::Expired { .. }, Some(chain)) => { - lines.push(format!(" uses certificate chain {chain}")); - } - (LocalIdentityStatus::Incomplete { detail }, _) - | (LocalIdentityStatus::Invalid { detail }, _) => { - lines.push(format!(" {detail}")); + let mut lines = vec![format!(" status: {}", summary.status.label())]; + if let Some(chain) = summary.certificate_chain.as_deref() { + if let Some((usage, sequence)) = chain.split_once(':') { + lines.push(format!(" usage: {usage}")); + lines.push(format!(" sequence: {sequence}")); } - _ => {} + lines.push(format!(" chain: {chain}")); } lines.push(format!(" dir: {}", summary.saved_at.display())); + if let (Some(valid_from), Some(expires_at)) = (summary.valid_from, summary.status.expires_at()) + { + lines.push(format!( + " validity: {} - {}", + format_natural_date(valid_from), + format_natural_date(expires_at) + )); + } + if let Some(issuer) = summary.issuer.as_deref() { + lines.push(format!(" issuer: {issuer}")); + } + if let LocalIdentityStatus::Incomplete { detail } | LocalIdentityStatus::Invalid { detail } = + &summary.status + { + lines.push(format!(" reason: {detail}")); + } lines } @@ -298,6 +310,8 @@ mod tests { LocalIdentitySummary { target: IdentityTarget::parse(name).unwrap(), certificate_chain: chain.map(ToOwned::to_owned), + valid_from: Some(1_700_000_000), + issuer: Some("CN=Genmeta Test CA".to_string()), status, saved_at: PathBuf::from(format!("/tmp/{name}")), is_default, @@ -315,10 +329,15 @@ mod tests { Some("secondary:2"), ); - let expected = "phone.alice.smith (default)\n uses certificate chain secondary:2\n dir: /tmp/phone.alice.smith"; - - assert_eq!(format_info(&profile, false), expected); - assert_eq!(format_default_summary(&profile, false), expected); + let rendered = format_info(&profile, false); + assert!(rendered.contains(" status: ready")); + assert!(rendered.contains(" usage: secondary")); + assert!(rendered.contains(" sequence: 2")); + assert!(rendered.contains(" chain: secondary:2")); + assert!(rendered.contains(" dir: /tmp/phone.alice.smith")); + assert!(rendered.contains(" validity: 14 Nov 2023 - 10 Nov 2026")); + assert!(rendered.contains(" issuer: CN=Genmeta Test CA")); + assert_eq!(format_default_summary(&profile, false), rendered); } #[test] @@ -381,7 +400,7 @@ mod tests { } #[test] - fn formats_invalid_identity_without_field_labels() { + fn formats_invalid_identity_with_reason_detail() { let profile = summary( "alice.smith", false, @@ -391,9 +410,10 @@ mod tests { None, ); - let expected = "alice.smith [invalid]\n certificate chain metadata is invalid\n dir: /tmp/alice.smith"; - - assert_eq!(format_info(&profile, false), expected); + let rendered = format_info(&profile, false); + assert!(rendered.contains(" status: invalid")); + assert!(rendered.contains(" dir: /tmp/alice.smith")); + assert!(rendered.contains(" reason: certificate chain metadata is invalid")); } #[test] @@ -494,9 +514,13 @@ tablet.reimu.scarlet [expired]"; Some("primary:0"), )]); + let summary = &match &inventory.groups[0].root { + crate::cli::flow::local::LocalInventoryRoot::Saved(summary) => summary, + crate::cli::flow::local::LocalInventoryRoot::Organization { .. } => unreachable!(), + }; assert_eq!( render_verbose_inventory(&inventory, false), - "alice.smith (default)\n uses certificate chain primary:0\n dir: /tmp/alice.smith", + format_info(summary, false) ); } diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index fdf2806..da9f50d 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -19,6 +19,23 @@ where result } +pub(crate) fn run_with_retained_progress( + message: &str, + finish_message: &str, + operation: impl FnOnce() -> Result, +) -> Result { + let span = info_span!("cli_progress", indicatif.pb_show = tracing::field::Empty); + span.pb_set_message(message); + span.pb_set_finish_message(finish_message); + span.pb_start(); + let result = { + let _entered = span.enter(); + operation() + }; + drop(span); + result +} + #[cfg(test)] mod tests { use std::sync::{ @@ -30,7 +47,7 @@ mod tests { use tracing_indicatif::filter::IndicatifFilter; use tracing_subscriber::{Layer, layer::SubscriberExt, registry::LookupSpan}; - use super::run_with_spinner; + use super::{run_with_retained_progress, run_with_spinner}; #[tokio::test] async fn run_with_spinner_returns_inner_result() { @@ -43,6 +60,18 @@ mod tests { assert_eq!(value, "ok"); } + #[test] + fn retained_progress_returns_inner_result() { + let value = run_with_retained_progress( + "Generating secp384r1 ECC key pair locally...", + "Generated secp384r1 ECC key pair locally.", + || Ok::<_, std::io::Error>("ok"), + ) + .unwrap(); + + assert_eq!(value, "ok"); + } + #[derive(Clone, Default)] struct CountLayer(Arc); diff --git a/genmeta-identity/src/cli/flow/recovery.rs b/genmeta-identity/src/cli/flow/recovery.rs index 94da4e7..92529df 100644 --- a/genmeta-identity/src/cli/flow/recovery.rs +++ b/genmeta-identity/src/cli/flow/recovery.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum VerificationRecovery { StayCurrentStep { message: String }, + OfferResend { message: String }, BackToEmail { message: String }, Abort, } @@ -34,9 +35,18 @@ pub(crate) fn classify_verify_submit_error( error: &crate::cert_server::Error, ) -> VerificationRecovery { match error { + crate::cert_server::Error::Api { + status, + code, + message, + } if *status == reqwest::StatusCode::UNAUTHORIZED && code == "verify_code_expired" => + { + VerificationRecovery::OfferResend { + message: message.clone(), + } + } crate::cert_server::Error::Api { status, code, .. } - if *status == reqwest::StatusCode::UNAUTHORIZED - && matches!(code.as_str(), "verify_code_invalid" | "verify_code_expired") => + if *status == reqwest::StatusCode::UNAUTHORIZED && code == "verify_code_invalid" => { VerificationRecovery::StayCurrentStep { message: "The verification code could not be used. To continue, enter the code again or choose another option.".to_string(), @@ -103,6 +113,22 @@ mod tests { ); } + #[test] + fn expired_code_offers_resend_with_server_message() { + let error = crate::cert_server::Error::Api { + status: reqwest::StatusCode::UNAUTHORIZED, + code: "verify_code_expired".to_string(), + message: "verification code expired".to_string(), + }; + + assert_eq!( + classify_verify_submit_error(&error), + VerificationRecovery::OfferResend { + message: "verification code expired".to_string(), + } + ); + } + #[test] fn verify_domain_email_mismatch_goes_back_to_email() { let error = crate::cert_server::Error::Api { diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index a9563d5..67b2233 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -4,7 +4,7 @@ use dhttp::home::{DhttpHome, HomeScope}; use snafu::{OptionExt, whatever}; use tracing::Instrument; -use super::{approval, local}; +use super::local; use crate::{ auth::AuthMethod, cert_server::CertServer, @@ -14,7 +14,7 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq)] enum RenewApprovalPlan { Email, - Identity, + Identity { auth_domain: String }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -75,25 +75,6 @@ fn renew_verify_code_actions() -> Vec { ] } -#[derive(Debug, Clone, PartialEq, Eq)] -enum RenewApprovalMenuAction { - ChangeIdentitySelection, -} - -impl RenewApprovalMenuAction { - fn label(&self) -> String { - match self { - Self::ChangeIdentitySelection => { - "Change identity (go back to identity selection)".to_string() - } - } - } -} - -fn renew_approval_menu_actions() -> Vec { - vec![RenewApprovalMenuAction::ChangeIdentitySelection] -} - #[derive(Debug, Clone)] struct InteractiveRenewState { target: Option>, @@ -149,6 +130,10 @@ fn apply_verification_recovery( crate::cli::flow::transcript::print_line(message); true } + crate::cli::flow::recovery::VerificationRecovery::OfferResend { message } => { + crate::cli::flow::transcript::print_line(message); + true + } crate::cli::flow::recovery::VerificationRecovery::BackToEmail { message } => { crate::cli::flow::transcript::print_line(message); state.revisit_email(); @@ -158,6 +143,32 @@ fn apply_verification_recovery( } } +async fn offer_expired_code_resend( + state: &mut InteractiveRenewState, + cert_server: &CertServer, + email: &str, + message: &str, +) -> Result<(), Error> { + crate::cli::flow::transcript::print_line(message); + let resend = crate::cli::prompt::sync(|| { + inquire::Confirm::new("Send a new verification code?") + .with_default(true) + .prompt() + }) + .await + .require_interactive("interactive input")?; + if resend { + super::progress::run_with_spinner( + "Sending verification code...", + cert_server.send_email_verification(email), + ) + .await?; + state.verification_code_sent_to = Some(email.to_string()); + } + state.verify_code = None; + Ok(()) +} + fn renew_not_saved_root_message(short_name: &str) -> String { format!( "The identity {short_name} is not saved here.\n\nRenew updates an identity already saved here.\nThis identity has not been applied here yet.\n\nApply {short_name} here first, then return to renew." @@ -178,77 +189,26 @@ async fn ensure_saved_renew_target( whatever!("{}", renew_not_saved_root_message(name.as_partial())); } -fn build_renew_approval_options(target: &str) -> Vec { - approval::build_approval_options(approval::ApprovalMenuSpec { - email_label: "Verify with email".to_string(), - direct_local: vec![approval::ApprovalDirectLocal::new(target, target)], - helpers: Vec::new(), - }) -} - -fn renew_verification_options(target: &str) -> Vec<(String, RenewApprovalPlan)> { - build_renew_approval_options(target) - .into_iter() - .filter_map(|option| match option { - approval::ApprovalMenuOption::Email { label } => { - Some((label, RenewApprovalPlan::Email)) - } - approval::ApprovalMenuOption::DirectLocal(local) => Some(( - format!("Verify with {} on local device", local.short_name), - RenewApprovalPlan::Identity, - )), - approval::ApprovalMenuOption::Helper(_) => None, - }) - .collect() -} - -fn approval_plan_from_selection( - options: &[(String, RenewApprovalPlan)], - selected: &str, -) -> Result { - options - .iter() - .find_map(|(label, plan)| (label == selected).then_some(plan.clone())) - .whatever_context::<_, Error>("selected approval path is unavailable") -} - fn resolve_non_interactive_approval_plan( target: &str, requested_auth: Option, + ready_identity: Option<&str>, ) -> Result { match requested_auth { Some(AuthMethod::Email) => Ok(RenewApprovalPlan::Email), - Some(AuthMethod::Identity) => Ok(RenewApprovalPlan::Identity), - None => whatever!( - "renewing {} non-interactively requires choosing an approval path; rerun with --auth email or --auth identity", - target - ), - } -} - -async fn resolve_approval_plan( - target: &str, - requested_auth: Option, - is_interactive: bool, -) -> Result { - if !is_interactive { - return resolve_non_interactive_approval_plan(target, requested_auth); - } - - match requested_auth { - Some(auth) => resolve_non_interactive_approval_plan(target, Some(auth)), - None => { - let options = renew_verification_options(target); - let labels = options - .iter() - .map(|(label, _)| label.clone()) - .collect::>(); - let message = format!("Choose how to verify renewing {target}:"); - let selected = crate::cli::prompt::prompt_select_string(&message, labels) - .await - .require_interactive("--auth")?; - approval_plan_from_selection(&options, &selected) - } + Some(AuthMethod::Identity) => ready_identity + .map(|auth_domain| RenewApprovalPlan::Identity { + auth_domain: auth_domain.to_string(), + }) + .whatever_context::<_, Error>(format!( + "renewing {target} cannot use local identity verification because neither it nor its parent has a ready local certificate; use --auth email" + )), + None => Ok(match ready_identity { + Some(auth_domain) => RenewApprovalPlan::Identity { + auth_domain: auth_domain.to_string(), + }, + None => RenewApprovalPlan::Email, + }), } } @@ -303,22 +263,6 @@ async fn prompt_renew_verify_code_action() -> Result("selected renew action is unavailable") } -async fn prompt_renew_approval_menu_action() -> Result { - let actions = renew_approval_menu_actions(); - let labels = actions - .iter() - .map(RenewApprovalMenuAction::label) - .collect::>(); - let selected = crate::cli::prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected renew approval action is unavailable") -} - async fn run_interactive( command: &Renew, dhttp_home: &DhttpHome, @@ -339,33 +283,16 @@ async fn run_interactive( ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; if state.approval_plan.is_none() { - if let Some(auth) = command.auth { - state.approval_plan = Some(resolve_non_interactive_approval_plan( - domain.as_partial(), - Some(auth), - )?); - continue; - } - - let options = renew_verification_options(domain.as_partial()); - let mut labels = options - .iter() - .map(|(label, _)| label.clone()) - .collect::>(); - labels.push(crate::cli::prompt::MORE_OPTIONS_LABEL.to_string()); - let message = format!("Choose how to verify renewing {}:", domain.as_partial()); - let selected = crate::cli::prompt::prompt_select_string(&message, labels) - .await - .require_interactive("--auth")?; - if selected == crate::cli::prompt::MORE_OPTIONS_LABEL { - match prompt_renew_approval_menu_action().await? { - RenewApprovalMenuAction::ChangeIdentitySelection => { - state.revisit_target_selection(); - } - } - } else { - state.approval_plan = Some(approval_plan_from_selection(&options, &selected)?); + let target = crate::cli::flow::target::IdentityTarget::parse(domain.as_partial())?; + let auth_plan = super::auth_plan::load_apply_auth_plan(dhttp_home, &target).await?; + for warning in &auth_plan.warnings { + crate::cli::flow::transcript::print_err_block(warning); } + state.approval_plan = Some(resolve_non_interactive_approval_plan( + domain.as_partial(), + command.auth, + auth_plan.first_identity_full_name(), + )?); continue; } @@ -499,6 +426,14 @@ async fn run_interactive( Err(error) => { let recovery = crate::cli::flow::recovery::classify_verify_submit_error(&error); + if let crate::cli::flow::recovery::VerificationRecovery::OfferResend { + message, + } = &recovery + { + offer_expired_code_resend(&mut state, cert_server, &email, message) + .await?; + continue; + } if matches!( recovery, crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } @@ -524,11 +459,11 @@ async fn run_interactive( ) .await? } - RenewApprovalPlan::Identity => { + RenewApprovalPlan::Identity { auth_domain } => { super::progress::run_with_spinner( "Renewing identity...", cert_server.renew_cert_with_identity( - domain.as_full(), + &auth_domain, domain.as_full(), kind, sequence, @@ -548,34 +483,10 @@ async fn run_interactive( ) .instrument(super::progress::save_identity_span()) .await?; - return crate::cli::flow::epilogue::run_local_epilogue( - dhttp_home, - domain.borrow(), - crate::cli::flow::output::SavedIdentityAction::Renewed, - None, - ) - .await; + return Ok(()); } } -pub(crate) async fn run_helper_for_verification( - dhttp_home: &DhttpHome, - cert_server: &CertServer, - short_name: &str, - _return_to: &str, -) -> Result { - let command = Renew { - name: Some(short_name.to_string()), - device_name: None, - email: None, - send_code: false, - verify_code: None, - auth: None, - }; - run(&command, dhttp_home, HomeScope::User, cert_server).await?; - Ok(true) -} - pub(crate) async fn run( command: &Renew, dhttp_home: &DhttpHome, @@ -588,8 +499,16 @@ pub(crate) async fn run( } let domain = resolve_target(command, dhttp_home).await?; ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; - let approval_plan = - resolve_approval_plan(domain.as_partial(), command.auth, is_interactive).await?; + let target = crate::cli::flow::target::IdentityTarget::parse(domain.as_partial())?; + let auth_plan = super::auth_plan::load_apply_auth_plan(dhttp_home, &target).await?; + for warning in &auth_plan.warnings { + crate::cli::flow::transcript::print_err_block(warning); + } + let approval_plan = resolve_non_interactive_approval_plan( + domain.as_partial(), + command.auth, + auth_plan.first_identity_full_name(), + )?; let identity_profile = dhttp_home.resolve_identity_profile(domain.borrow()).await?; let local_identity = identity_profile.load_identity().await?; let chain_key = cli::certificate_chain_key_from_identity(&local_identity)? @@ -635,11 +554,11 @@ pub(crate) async fn run( ) .await? } - RenewApprovalPlan::Identity => { + RenewApprovalPlan::Identity { auth_domain } => { super::progress::run_with_spinner( "Renewing identity...", cert_server.renew_cert_with_identity( - domain.as_full(), + &auth_domain, domain.as_full(), kind, sequence, @@ -658,13 +577,7 @@ pub(crate) async fn run( detail.cert_pem.as_bytes(), ) .await?; - crate::cli::flow::epilogue::run_local_epilogue( - dhttp_home, - domain.borrow(), - crate::cli::flow::output::SavedIdentityAction::Renewed, - None, - ) - .await + Ok(()) } #[cfg(test)] @@ -677,10 +590,8 @@ mod tests { use dhttp::home::{DhttpHome, HomeScope}; use super::{ - InteractiveRenewState, RenewApprovalMenuAction, RenewApprovalPlan, RenewEmailAction, - RenewVerifyCodeAction, approval_plan_from_selection, build_renew_approval_options, - renew_approval_menu_actions, renew_email_actions, renew_not_saved_root_message, - renew_verification_options, renew_verify_code_actions, + InteractiveRenewState, RenewApprovalPlan, RenewEmailAction, RenewVerifyCodeAction, + renew_email_actions, renew_not_saved_root_message, renew_verify_code_actions, resolve_non_interactive_approval_plan, }; use crate::{auth::AuthMethod, cli::Renew}; @@ -753,36 +664,40 @@ mod tests { } #[test] - fn renew_requires_explicit_auth_non_interactively() { - let error = resolve_non_interactive_approval_plan("alice.smith", None).unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("--auth email"), "{rendered}"); - assert!(rendered.contains("--auth identity"), "{rendered}"); - } - - #[test] - fn renew_identity_auth_is_allowed() { + fn renew_prefers_ready_identity_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan("alice.smith", Some(AuthMethod::Identity)) - .unwrap(), - RenewApprovalPlan::Identity, + resolve_non_interactive_approval_plan( + "alice.smith", + None, + Some("alice.smith.dhttp.net") + ) + .unwrap(), + RenewApprovalPlan::Identity { + auth_domain: "alice.smith.dhttp.net".to_string() + } ); } #[test] - fn renew_email_auth_is_allowed() { + fn renew_identity_auth_is_allowed() { assert_eq!( - resolve_non_interactive_approval_plan("alice.smith", Some(AuthMethod::Email)).unwrap(), - RenewApprovalPlan::Email, + resolve_non_interactive_approval_plan( + "alice.smith", + Some(AuthMethod::Identity), + Some("alice.smith.dhttp.net") + ) + .unwrap(), + RenewApprovalPlan::Identity { + auth_domain: "alice.smith.dhttp.net".to_string() + }, ); } #[test] - fn interactive_renew_selection_can_choose_email() { - let options = renew_verification_options("alice.smith"); - + fn renew_email_auth_is_allowed() { assert_eq!( - approval_plan_from_selection(&options, "Verify with email").unwrap(), + resolve_non_interactive_approval_plan("alice.smith", Some(AuthMethod::Email), None) + .unwrap(), RenewApprovalPlan::Email, ); } @@ -824,33 +739,6 @@ Apply alice.ma here first, then return to renew." ); } - #[test] - fn renew_verification_options_place_local_before_email() { - let options = build_renew_approval_options("alice.ma"); - - assert_eq!( - options - .iter() - .map(crate::cli::flow::approval::ApprovalMenuOption::label) - .collect::>(), - vec![ - "Verify with alice.ma on local device".to_string(), - "Verify with email".to_string(), - ] - ); - } - - #[test] - fn interactive_renew_selection_can_choose_local_identity() { - let options = renew_verification_options("alice.smith"); - - assert_eq!( - approval_plan_from_selection(&options, "Verify with alice.smith on local device",) - .unwrap(), - RenewApprovalPlan::Identity, - ); - } - #[test] fn renew_email_actions_include_explicit_return_points() { assert_eq!( @@ -896,19 +784,4 @@ Apply alice.ma here first, then return to renew." ] ); } - - #[test] - fn renew_approval_menu_actions_include_explicit_return_points() { - assert_eq!( - renew_approval_menu_actions() - .into_iter() - .map(|action| action.label()) - .collect::>(), - vec!["Change identity (go back to identity selection)".to_string(),] - ); - assert_eq!( - renew_approval_menu_actions(), - vec![RenewApprovalMenuAction::ChangeIdentitySelection,] - ); - } } diff --git a/genmeta-identity/src/cli/prompt.rs b/genmeta-identity/src/cli/prompt.rs index 7bf31fd..1d55797 100644 --- a/genmeta-identity/src/cli/prompt.rs +++ b/genmeta-identity/src/cli/prompt.rs @@ -2,8 +2,6 @@ use std::{borrow::Cow, fmt::Display}; use crate::cli::{flow::kind::IdentityKind, validator}; -pub(crate) const MORE_OPTIONS_LABEL: &str = "More options..."; - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum TextPromptResult { Submitted(String), @@ -104,7 +102,7 @@ macro_rules! sync { pub(crate) async fn prompt_email() -> Result { sync!( - inquire::Text::new("Enter your email address:") + inquire::Text::new("Enter your email:") .with_validator(inquire::required!("Email address cannot be empty.")) .with_validator(validator::EmailValidator) .prompt() @@ -126,7 +124,7 @@ pub(crate) async fn prompt_identity_name_with_default( } let default = default.map(ToOwned::to_owned); sync!({ - let mut prompt = inquire::Text::new("Enter the identity name:") + let mut prompt = inquire::Text::new("Enter your name:") .with_validator(inquire::required!("Identity name cannot be empty.")) .with_validator(|value: &str| { match crate::cli::flow::target::IdentityTarget::parse(value) { @@ -261,7 +259,7 @@ pub(crate) async fn prompt_select_string_with_cursor( mod tests { use inquire::validator::{StringValidator, Validation}; - use super::{MORE_OPTIONS_LABEL, MoreOptionsFriendlyValidator}; + use super::MoreOptionsFriendlyValidator; #[test] fn question_mark_bypasses_inner_validation() { @@ -284,9 +282,4 @@ mod tests { Validation::Invalid(inquire::validator::ErrorMessage::from("always invalid")) ); } - - #[test] - fn more_options_label_matches_spec_copy() { - assert_eq!(MORE_OPTIONS_LABEL, "More options..."); - } } From 8bee4e2871ca47ac6412ebd6d76bd3c81729fcfb Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 13 Jul 2026 14:12:21 +0800 Subject: [PATCH 03/27] docs(identity): document the unified apply flow --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 750a0b5..bc985c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `genmeta id` is a visible alias for `genmeta identity`. +- `genmeta identity default -v` shows the default identity's full details. + +### Changed + +- `genmeta identity apply [name]` is now the single create-or-update flow; + the former `identity create` command has been removed. +- Bare `genmeta identity renew` targets the configured default identity, and + renew no longer accepts `--default`. +- Identity authentication skips expired, incomplete, and invalid local + certificates with an actionable warning, then tries the direct parent when + available before falling back to email verification. +- Compact identity output uses `(default)`, while detail output uses `dir` and + includes certificate usage, sequence, validity, and issuer information. + ## [0.8.0-beta.3] - 2026-07-09 ### Added From 31c3cb49282708161900c4c80e7eb010c073950d Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 13 Jul 2026 16:45:34 +0800 Subject: [PATCH 04/27] fix(identity): align interactive copy with design --- CHANGELOG.md | 5 +- genmeta-identity/src/cert_server.rs | 13 +- genmeta-identity/src/checkout.rs | 27 +- genmeta-identity/src/cli.rs | 164 ++-- genmeta-identity/src/cli/flow/apply.rs | 710 ++++++++---------- genmeta-identity/src/cli/flow/auth_plan.rs | 47 +- .../src/cli/flow/default_identity.rs | 23 +- genmeta-identity/src/cli/flow/epilogue.rs | 58 +- genmeta-identity/src/cli/flow/kind.rs | 28 +- genmeta-identity/src/cli/flow/output.rs | 310 ++++---- genmeta-identity/src/cli/flow/progress.rs | 6 +- genmeta-identity/src/cli/flow/recovery.rs | 89 ++- genmeta-identity/src/cli/flow/registration.rs | 100 ++- genmeta-identity/src/cli/flow/renew.rs | 162 +--- genmeta-identity/src/cli/flow/target.rs | 49 +- genmeta-identity/src/cli/prompt.rs | 115 +-- genmeta-identity/src/cli/validator.rs | 14 +- 17 files changed, 957 insertions(+), 963 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc985c4..dcdcf98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Identity authentication skips expired, incomplete, and invalid local certificates with an actionable warning, then tries the direct parent when available before falling back to email verification. -- Compact identity output uses `(default)`, while detail output uses `dir` and - includes certificate usage, sequence, validity, and issuer information. +- Compact identity output uses `(default)` and retains abnormal states. Detail + output keeps the chain in the summary, then shows `Issuer`, `Valid from`, + `Expires`/`Expired`, `dir`, and an invalid/incomplete reason when available. ## [0.8.0-beta.3] - 2026-07-09 diff --git a/genmeta-identity/src/cert_server.rs b/genmeta-identity/src/cert_server.rs index 2ab018a..91eafc7 100644 --- a/genmeta-identity/src/cert_server.rs +++ b/genmeta-identity/src/cert_server.rs @@ -9,7 +9,7 @@ use snafu::{FromString, ResultExt, Snafu, Whatever}; pub enum Error { #[snafu(transparent)] Request { source: reqwest::Error }, - #[snafu(display("cert server returned {status} {code}: {message}"))] + #[snafu(display("{message}"))] Api { status: reqwest::StatusCode, code: String, @@ -820,6 +820,17 @@ mod tests { assert!(!error.is_api_code("domain_not_found")); } + #[test] + fn api_error_displays_the_certserver_problem_message_verbatim() { + let error = Error::Api { + status: reqwest::StatusCode::FORBIDDEN, + code: "domain_forbidden".to_string(), + message: "domain access is forbidden".to_string(), + }; + + assert_eq!(error.to_string(), "domain access is forbidden"); + } + #[test] fn create_domain_response_accepts_payment_payload() { let payload = r#" diff --git a/genmeta-identity/src/checkout.rs b/genmeta-identity/src/checkout.rs index e4c275b..5809078 100644 --- a/genmeta-identity/src/checkout.rs +++ b/genmeta-identity/src/checkout.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use qrcode::{QrCode, types::QrError}; +use qrcode::{QrCode, render::unicode, types::QrError}; use crate::{cert_server::CreateDomainResponse, cli::flow::transcript}; @@ -72,11 +72,9 @@ fn payment_instruction_block(response: &CreateDomainResponse, include_qr: bool) fn render_terminal_qr(url: &str) -> Result { let code = QrCode::new(url.as_bytes())?; Ok(code - .render() + .render::() .quiet_zone(true) - .module_dimensions(2, 1) - .dark_color("\u{1b}[40m \u{1b}[0m") - .light_color("\u{1b}[47m \u{1b}[0m") + .module_dimensions(1, 1) .build()) } @@ -146,10 +144,25 @@ mod tests { assert!(block.contains("Payment is required to create alice.smith.")); assert!(block.contains("Scan this QR code to pay:")); - assert!(block.contains("\u{1b}[47m"), "{block:?}"); + assert!(block.contains(['â–€', 'â–„', 'â–ˆ']), "{block:?}"); assert!(block.contains("Open link: https://pay.example.test/checkout/ckt_123")); } + #[test] + fn terminal_qr_uses_the_compact_stable_layout() { + let qr = render_terminal_qr("https://pay.example.test/checkout/ckt_123").unwrap(); + let rows = qr.lines().count(); + let columns = qr + .lines() + .map(str::chars) + .map(Iterator::count) + .max() + .unwrap(); + + assert!(rows <= 24, "QR uses {rows} terminal rows"); + assert!(columns <= 64, "QR uses {columns} terminal columns"); + } + #[test] fn checkout_block_omits_terminal_qr_when_stderr_is_not_terminal() { let block = checkout_instruction_block( @@ -160,7 +173,7 @@ mod tests { assert!(block.contains("Payment is required to create alice.smith.")); assert!(!block.contains("Scan this QR code to pay:")); - assert!(!block.contains("\u{1b}[47m")); + assert!(!block.contains(['â–€', 'â–„', 'â–ˆ'])); assert_eq!( block, "Payment is required to create alice.smith.\n\nOpen link: https://pay.example.test/checkout/ckt_123" diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 3a50c36..cf79ef7 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -27,7 +27,6 @@ use tokio::io; use tracing_indicatif::{ IndicatifLayer, filter::{IndicatifFilter, hide_indicatif_span_fields}, - span_ext::IndicatifSpanExt, }; use tracing_subscriber::{ EnvFilter, filter::LevelFilter, fmt::format::DefaultFields, prelude::*, util::SubscriberInitExt, @@ -136,9 +135,6 @@ fn generate_private_key_and_csr( .context(GenerateKeySnafu) }, )?; - tracing::Span::current().pb_set_message(&format!( - "Generating Certificate Signing Request (CSR) for {name}..." - )); let csr = rankey::generate_csr(&key_pem, "CN", name.as_full(), &[name.as_full()]) .map_err(|e| Box::new(e) as Box) .context(GenerateCsrSnafu)?; @@ -146,9 +142,6 @@ fn generate_private_key_and_csr( .to_pem(rankey::LineEnding::LF) .map_err(|e| Box::new(e) as Box) .context(EncodeCsrSnafu)?; - tracing::Span::current().pb_set_message(&format!( - "Successfully generated private key and CSR for {name}." - )); Ok((key_pem, csr_pem)) } @@ -158,22 +151,23 @@ async fn save_identity( key_pem: &[u8], cert_pem: &[u8], ) -> Result<(), Error> { - let identity_dir = dhttp_home.join_identity_name(name.borrow()); - tracing::Span::current().pb_set_message(&format!( - "Saving identity for {name} to {}...", - identity_dir.display() - )); - dhttp_home - .identity_profile(name.borrow()) - .save_identity(cert_pem, key_pem) - .await?; - tracing::Span::current().pb_set_finish_message(&format!( - "Identity for {name} successfully saved to {}", - identity_dir.display() - )); + let profile = dhttp_home.identity_profile(name.borrow()); + flow::progress::run_with_spinner( + save_identity_progress_message(), + profile.save_identity(cert_pem, key_pem), + ) + .await?; Ok(()) } +fn save_identity_progress_message() -> &'static str { + "Saving identity..." +} + +fn save_default_identity_progress_message() -> &'static str { + "Saving default identity..." +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LocalIdentitySave { New, @@ -186,6 +180,27 @@ impl LocalIdentitySave { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalReplacementDecision { + New, + Replace, + RequireFlag, +} + +fn local_replacement_decision( + profile_exists: bool, + replace_local: bool, + interactive: bool, +) -> LocalReplacementDecision { + if !profile_exists { + LocalReplacementDecision::New + } else if replace_local || interactive { + LocalReplacementDecision::Replace + } else { + LocalReplacementDecision::RequireFlag + } +} + #[tracing::instrument()] async fn load_current_settings(dhttp_home: &DhttpHome) -> Result, Error> { match dhttp_home.load_settings().await { @@ -199,7 +214,7 @@ async fn load_current_settings(dhttp_home: &DhttpHome) -> Result Result<(), Error> { if let Some(parent) = default_config.path().parent() { tokio::fs::create_dir_all(parent) @@ -209,11 +224,11 @@ async fn save_settings(default_config: &DhttpSettingsFile) -> Result<(), Error> })?; } - let path = default_config.path().display(); - tracing::Span::current().pb_set_message(&format!("Saving default configuration to {path}...")); - default_config.save().await?; - tracing::Span::current() - .pb_set_finish_message(&format!("Default configuration saved to {path}.")); + flow::progress::run_with_spinner( + save_default_identity_progress_message(), + default_config.save(), + ) + .await?; Ok(()) } @@ -234,28 +249,20 @@ async fn ensure_replace_local_allowed( name: Name<'_>, replace_local: bool, ) -> Result { - if !dhttp_home + let profile_exists = dhttp_home .identity_profile_exists_exactly(name.clone()) - .await - { - return Ok(LocalIdentitySave::New); - } - if replace_local { - return Ok(LocalIdentitySave::Replace); - } - - let message = format!( - "Replace the local identity saved at {}?", - dhttp_home.join_identity_name(name.clone()).display() - ); - let confirmed = - prompt::sync(move || inquire::Confirm::new(&message).with_default(false).prompt()) - .await - .require_interactive("--replace-local")?; - if confirmed { - Ok(LocalIdentitySave::Replace) - } else { - whatever!("local identity was not replaced") + .await; + match local_replacement_decision( + profile_exists, + replace_local, + std::io::stdin().is_terminal(), + ) { + LocalReplacementDecision::New => Ok(LocalIdentitySave::New), + LocalReplacementDecision::Replace => Ok(LocalIdentitySave::Replace), + LocalReplacementDecision::RequireFlag => Err(prompt::Error::NotInteractive { + hint: "--replace-local".into(), + } + .into()), } } @@ -321,6 +328,9 @@ pub struct Apply { pub name: Option, #[arg(long)] pub kind: Option, + /// Register a missing sub-identity before applying it. + #[arg(long)] + pub register_if_missing: bool, #[arg(long)] pub replace_local: bool, #[arg(long)] @@ -592,8 +602,9 @@ mod tests { use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use super::{ - Apply, Cli, Default, Info, Options, cert_server_base_url, - certificate_chain_key_from_identity, + Apply, Cli, Default, Info, LocalReplacementDecision, Options, cert_server_base_url, + certificate_chain_key_from_identity, local_replacement_decision, + save_default_identity_progress_message, save_identity_progress_message, }; use crate::CERT_SERVER_BASE_URL; @@ -686,6 +697,26 @@ mod tests { ); } + #[test] + fn apply_accepts_register_if_missing() { + let help = Apply::command().render_long_help().to_string(); + assert!( + help.contains("Register a missing sub-identity before applying it"), + "{help}" + ); + assert!( + Options::try_parse_from([ + "genmeta", + "apply", + "phone.alice.smith", + "--kind", + "primary", + "--register-if-missing", + ]) + .is_ok() + ); + } + #[test] fn default_accepts_allow_nonready() { assert!( @@ -765,6 +796,35 @@ mod tests { ); } + #[test] + fn local_replacement_has_no_deleted_confirmation_copy() { + assert_eq!( + local_replacement_decision(true, false, true), + LocalReplacementDecision::Replace + ); + assert_eq!( + local_replacement_decision(true, false, false), + LocalReplacementDecision::RequireFlag + ); + assert_eq!( + local_replacement_decision(true, true, false), + LocalReplacementDecision::Replace + ); + assert_eq!( + local_replacement_decision(false, false, true), + LocalReplacementDecision::New + ); + } + + #[test] + fn save_progress_uses_user_task_copy() { + assert_eq!(save_identity_progress_message(), "Saving identity..."); + assert_eq!( + save_default_identity_progress_message(), + "Saving default identity..." + ); + } + #[test] fn apply_and_renew_reject_default_flag() { let apply_error = @@ -867,9 +927,9 @@ mod tests { .unwrap_err(); let rendered = error.to_string(); - assert!( - rendered.contains("alice.smith is not saved here"), - "{rendered}" + assert_eq!( + rendered, + "Failed to set default identity: alice.smith not found!" ); } diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 3ede076..2a683e5 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -2,7 +2,6 @@ use std::io::IsTerminal; use dhttp::home::{DhttpHome, HomeScope}; use snafu::{FromString, OptionExt, whatever}; -use tracing::Instrument; use super::{ kind::IdentityKind, @@ -20,45 +19,6 @@ enum ApplyApprovalPlan { DirectIdentity { auth_domain: String }, } -#[derive(Debug, Clone, PartialEq, Eq)] -enum ApplyEmailAction { - SwitchVerificationMethod, - ChangeCertificateKind, - ChangeIdentitySelection, - ReturnToCaller { label: String }, -} - -impl ApplyEmailAction { - fn label(&self) -> String { - match self { - Self::SwitchVerificationMethod => { - "Switch verification method (go back to verification method selection)".to_string() - } - Self::ChangeCertificateKind => { - "Change certificate kind (go back to identity kind)".to_string() - } - Self::ChangeIdentitySelection => { - "Change identity (go back to identity selection)".to_string() - } - Self::ReturnToCaller { label } => format!("Return to {label}"), - } - } -} - -fn apply_email_actions(return_to: Option<&str>) -> Vec { - let mut actions = vec![ - ApplyEmailAction::SwitchVerificationMethod, - ApplyEmailAction::ChangeCertificateKind, - ApplyEmailAction::ChangeIdentitySelection, - ]; - if let Some(label) = return_to { - actions.push(ApplyEmailAction::ReturnToCaller { - label: label.to_string(), - }); - } - actions -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ApplyRunOutcome { Applied, @@ -70,6 +30,45 @@ pub(crate) enum ApplyPostSavePolicy { ManageDefaultSuggestion, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MissingTargetAction { + Register, + Reject, +} + +fn missing_target_action( + target: &IdentityTarget, + register_if_missing: bool, + private_test_continuation: bool, +) -> MissingTargetAction { + if !register_if_missing { + return MissingTargetAction::Reject; + } + match target.level() { + IdentityLevel::SubIdentity => MissingTargetAction::Register, + IdentityLevel::Identity if private_test_continuation => MissingTargetAction::Register, + IdentityLevel::Identity => MissingTargetAction::Reject, + } +} + +fn private_test_root_registration(command: &Apply) -> bool { + command.verify_code.is_some() && matches!(command.auth, Some(AuthMethod::Email)) +} + +fn missing_target_error(target: &IdentityTarget) -> Error { + let message = match target.level() { + IdentityLevel::Identity => format!( + "{} does not exist yet. Apply can register a missing sub-identity, but not a new root identity.", + target.short_name() + ), + IdentityLevel::SubIdentity => format!( + "{} does not exist yet. To register this sub-identity before applying it, rerun with --register-if-missing.", + target.short_name() + ), + }; + Error::without_source(message) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ApplyVerifyCodeAction { ResendVerificationCode, @@ -87,7 +86,7 @@ impl ApplyVerifyCodeAction { } } -fn apply_verify_code_actions(_return_to: Option<&str>) -> Vec { +fn apply_verify_code_actions() -> Vec { vec![ ApplyVerifyCodeAction::ResendVerificationCode, ApplyVerifyCodeAction::ChangeEmail, @@ -95,21 +94,6 @@ fn apply_verify_code_actions(_return_to: Option<&str>) -> Vec Vec<(String, ApplyApprovalPlan)> { - let short_name = IdentityTarget::parse(auth_domain) - .map(|target| target.short_name().to_string()) - .unwrap_or_else(|_| auth_domain.to_string()); - vec![ - ( - format!("Verify with {short_name} on local device"), - ApplyApprovalPlan::DirectIdentity { - auth_domain: auth_domain.to_string(), - }, - ), - ("Verify with email".to_string(), ApplyApprovalPlan::Email), - ] -} - #[derive(Debug, Clone)] struct InteractiveApplyState { target: Option>, @@ -143,35 +127,14 @@ impl InteractiveApplyState { }) } - fn revisit_target_selection(&mut self) { - self.target = None; - self.kind = None; - self.kind_prompt_required = true; - self.approval_plan = None; - self.email = None; - self.email_prompt_required = true; - self.verify_code = None; - self.verification_code_sent_to = None; - } - - fn revisit_kind(&mut self) { - self.kind_prompt_required = true; - self.approval_plan = None; - self.email = None; - self.email_prompt_required = true; - self.verify_code = None; - self.verification_code_sent_to = None; - } - fn revisit_email(&mut self) { self.email_prompt_required = true; self.verify_code = None; self.verification_code_sent_to = None; } - fn revisit_verification_method(&mut self) { - self.approval_plan = None; - self.email = None; + fn fall_back_to_email(&mut self) { + self.approval_plan = Some(ApplyApprovalPlan::Email); self.email_prompt_required = true; self.verify_code = None; self.verification_code_sent_to = None; @@ -206,7 +169,9 @@ async fn offer_expired_code_resend( email: &str, message: &str, ) -> Result<(), Error> { - crate::cli::flow::transcript::print_line(message); + crate::cli::flow::transcript::print_block(&crate::cli::flow::recovery::format_resend_offer( + message, + )); let resend = crate::cli::prompt::sync(|| { inquire::Confirm::new("Send a new verification code?") .with_default(true) @@ -231,7 +196,7 @@ fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { } fn classify_apply_email_issue_error( - target: &IdentityTarget, + _target: &IdentityTarget, error: &crate::cert_server::Error, ) -> Option { match error { @@ -241,64 +206,23 @@ fn classify_apply_email_issue_error( message, } if *status == reqwest::StatusCode::FORBIDDEN && code == "domain_forbidden" => Some( crate::cli::flow::recovery::VerificationRecovery::BackToEmail { - message: format!( - "{message}. To continue, enter the owner email for {} again or choose another option.", - target.short_name() - ), + message: message.clone(), }, ), _ => None, } } -fn rewrite_apply_email_issue_error( - target: &IdentityTarget, - error: crate::cert_server::Error, -) -> Error { - if error.is_api_code("domain_forbidden") { - Error::with_source( - Box::new(error), - format!( - "applying {} requires the owner email for this identity; rerun with --email or use --auth identity with a local identity that can authorize it", - target.short_name(), - ), - ) - } else { - Error::from(error) - } +fn preserve_apply_email_issue_error(error: crate::cert_server::Error) -> Error { + Error::from(error) } fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { super::registration::is_subdomain_quota_exceeded(error) } -fn rewrite_apply_registration_error(target: &IdentityTarget, error: Error) -> Error { - match error { - Error::CertServer { source } if source.is_api_code("starter_domain_limit_reached") => { - Error::with_source( - Box::new(source), - format!( - "applying {} would need to register free starter identity {}, but this account already holds the maximum 3 free starter domains. The cert server did not create a checkout for this case.", - target.short_name(), - target - .parent() - .map(|parent| parent.as_partial().to_string()) - .unwrap_or_else(|| target.short_name().to_string()), - ), - ) - } - other => other, - } -} - -fn approval_plan_from_selection( - options: &[(String, ApplyApprovalPlan)], - selected: &str, -) -> Result { - options - .iter() - .find_map(|(label, plan)| (label == selected).then_some(plan.clone())) - .whatever_context::<_, Error>("selected approval path is unavailable") +fn preserve_apply_registration_error(error: Error) -> Error { + error } fn resolve_non_interactive_approval_plan( @@ -330,40 +254,8 @@ fn resolve_non_interactive_approval_plan( } } -async fn resolve_approval_plan( - target: &str, - requested_auth: Option, - identity_auth_domain: Option<&str>, - is_interactive: bool, -) -> Result { - if !is_interactive { - return resolve_non_interactive_approval_plan(target, requested_auth, identity_auth_domain); - } - - match requested_auth { - Some(auth) => { - resolve_non_interactive_approval_plan(target, Some(auth), identity_auth_domain) - } - None => { - if let Some(auth_domain) = identity_auth_domain { - let options = apply_verification_options(auth_domain); - let labels = options - .iter() - .map(|(label, _)| label.clone()) - .collect::>(); - let message = format!("Choose how to verify applying {target}:"); - let selected = crate::cli::prompt::prompt_select_string(&message, labels) - .await - .require_interactive("--auth")?; - return approval_plan_from_selection(&options, &selected); - } - Ok(ApplyApprovalPlan::Email) - } - } -} - fn apply_identity_name_opening() -> &'static str { - "Name format:\n [handle.]your.name" + "Apply an identity here." } fn explicit_target_from_command( @@ -395,8 +287,7 @@ async fn resolve_kind(command: &Apply) -> Result { Some(kind) => Ok(kind.parse::()?), None => Ok(crate::cli::prompt::prompt_kind() .await - .require_interactive("--kind")? - .parse::()?), + .require_interactive("--kind")?), } } @@ -409,10 +300,8 @@ async fn resolve_email(command: &Apply) -> Result { } } -async fn prompt_apply_verify_code_action( - return_to: Option<&str>, -) -> Result { - let actions = apply_verify_code_actions(return_to); +async fn prompt_apply_verify_code_action() -> Result { + let actions = apply_verify_code_actions(); let labels = actions .iter() .map(ApplyVerifyCodeAction::label) @@ -427,22 +316,6 @@ async fn prompt_apply_verify_code_action( .whatever_context::<_, Error>("selected apply action is unavailable") } -async fn prompt_apply_email_action(return_to: Option<&str>) -> Result { - let actions = apply_email_actions(return_to); - let labels = actions - .iter() - .map(ApplyEmailAction::label) - .collect::>(); - let selected = crate::cli::prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected apply email action is unavailable") -} - async fn run_post_save_epilogue( post_save: ApplyPostSavePolicy, dhttp_home: &DhttpHome, @@ -463,35 +336,8 @@ async fn run_post_save_epilogue( .await } -fn register_during_apply_message(target: &IdentityTarget) -> String { - match target.level() { - IdentityLevel::Identity => format!( - "{} does not exist yet.\n\nApply will register {} first, then apply its certificate chain here.\nThis may require payment.\n\nContinue?", - target.short_name(), - target.short_name() - ), - IdentityLevel::SubIdentity => { - let parent = target - .parent() - .map(|name| name.as_partial().to_string()) - .unwrap_or_else(|| "".to_string()); - format!( - "{} does not exist yet.\n\nApply will register {} first if needed, then create {} and apply its certificate chain here.\nThis may require payment or quota expansion.\n\nContinue?", - target.short_name(), - parent, - target.short_name() - ) - } - } -} - -async fn prompt_register_during_apply(target: &IdentityTarget) -> Result { - let message = register_during_apply_message(target); - Ok(crate::cli::prompt::sync(move || { - inquire::Confirm::new(&message).with_default(true).prompt() - }) - .await - .require_interactive("interactive input")?) +fn new_identity_confirmation_message() -> &'static str { + "This new name is yours now." } async fn ensure_identity_exists_after_apply_login( @@ -507,7 +353,7 @@ async fn ensure_identity_exists_after_apply_login( cert_server, target, access_token, - "Creating identity...", + super::registration::create_identity_progress_message(), ) .await } else { @@ -515,7 +361,7 @@ async fn ensure_identity_exists_after_apply_login( cert_server, target, access_token, - "Creating identity...", + super::registration::create_identity_progress_message(), ) .await } @@ -556,7 +402,7 @@ async fn ensure_identity_exists_after_apply_login( } } -async fn ensure_sub_identity_exists_with_identity_interactively( +async fn ensure_sub_identity_exists_with_identity( target: &IdentityTarget, cert_server: &CertServer, identity_domain: &str, @@ -569,7 +415,7 @@ async fn ensure_sub_identity_exists_with_identity_interactively( .whatever_context::<_, Error>("sub-identity target is missing its direct child label")?; match super::progress::run_with_spinner( - "Creating sub-identity...", + super::registration::create_identity_progress_message(), cert_server.create_subdomain_with_identity(identity_domain, parent.as_full(), label, None), ) .await @@ -585,7 +431,6 @@ async fn run_interactive_with_policy( dhttp_home: &DhttpHome, home_scope: HomeScope, cert_server: &CertServer, - return_to: Option<&str>, post_save: ApplyPostSavePolicy, ) -> Result { let default_identity_when_command_started = cli::load_current_settings(dhttp_home) @@ -604,8 +449,7 @@ async fn run_interactive_with_policy( state.kind = Some( crate::cli::prompt::prompt_kind_with_cursor(state.kind) .await - .require_interactive("--kind")? - .parse::()?, + .require_interactive("--kind")?, ); state.kind_prompt_required = false; continue; @@ -636,29 +480,11 @@ async fn run_interactive_with_policy( if matches!(approval_plan, ApplyApprovalPlan::Email) && (state.email.is_none() || state.email_prompt_required) { - match crate::cli::prompt::prompt_email_with_more_options(state.email.as_deref()) + let email = crate::cli::prompt::prompt_email_with_default(state.email.as_deref()) .await - .require_interactive("--email")? - { - crate::cli::prompt::TextPromptResult::Submitted(email) => { - state.email = Some(email); - state.email_prompt_required = false; - } - crate::cli::prompt::TextPromptResult::MoreOptions => { - match prompt_apply_email_action(return_to).await? { - ApplyEmailAction::SwitchVerificationMethod => { - state.revisit_verification_method(); - } - ApplyEmailAction::ChangeCertificateKind => state.revisit_kind(), - ApplyEmailAction::ChangeIdentitySelection => { - state.revisit_target_selection(); - } - ApplyEmailAction::ReturnToCaller { .. } => { - return Ok(ApplyRunOutcome::ReturnedToCaller); - } - } - } - } + .require_interactive("--email")?; + state.email = Some(email); + state.email_prompt_required = false; continue; } @@ -700,7 +526,7 @@ async fn run_interactive_with_policy( state.verify_code = Some(code); } crate::cli::prompt::TextPromptResult::MoreOptions => { - match prompt_apply_verify_code_action(return_to).await? { + match prompt_apply_verify_code_action().await? { ApplyVerifyCodeAction::ResendVerificationCode => { match super::progress::run_with_spinner( "Sending verification code...", @@ -731,9 +557,6 @@ async fn run_interactive_with_policy( continue; } - let local_identity_save = - cli::ensure_replace_local_allowed(dhttp_home, domain.borrow(), command.replace_local) - .await?; let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; let kind = state .kind @@ -794,8 +617,13 @@ async fn run_interactive_with_policy( { Ok(detail) => detail, Err(error) if is_domain_not_found(&error) => { - if !prompt_register_during_apply(&target).await? { - return Ok(ApplyRunOutcome::ReturnedToCaller); + if missing_target_action( + &target, + command.register_if_missing, + private_test_root_registration(command), + ) == MissingTargetAction::Reject + { + return Err(missing_target_error(&target)); } if let Err(error) = ensure_identity_exists_after_apply_login( &target, @@ -805,8 +633,11 @@ async fn run_interactive_with_policy( ) .await { - return Err(rewrite_apply_registration_error(&target, error)); + return Err(preserve_apply_registration_error(error)); } + crate::cli::flow::transcript::print_line( + new_identity_confirmation_message(), + ); let detail = super::progress::run_with_spinner( "Applying identity...", cert_server.issue_cert( @@ -819,18 +650,23 @@ async fn run_interactive_with_policy( ), ) .await?; + let local_identity_save = cli::ensure_replace_local_allowed( + dhttp_home, + domain.borrow(), + command.replace_local, + ) + .await?; cli::save_identity( dhttp_home, &domain, key_pem.as_bytes(), detail.cert_pem.as_bytes(), ) - .instrument(super::progress::save_identity_span()) .await?; let welcome = super::welcome::maybe_create_welcome_service( dhttp_home, domain.borrow(), - true, + local_identity_save.created_new_identity(), ) .await?; run_post_save_epilogue( @@ -856,34 +692,8 @@ async fn run_interactive_with_policy( } } ApplyApprovalPlan::DirectIdentity { auth_domain } => { - if target.level() == IdentityLevel::SubIdentity { - match ensure_sub_identity_exists_with_identity_interactively( - &target, - cert_server, - &auth_domain, - ) - .await - { - Ok(()) => {} - Err(Error::CertServer { source }) - if is_subdomain_quota_exceeded(&source) => - { - crate::cli::flow::transcript::print_block(&format!( - "Creating {} exceeded the sub-identity quota under {}.\nChoose email verification to expand quota and continue.", - target.short_name(), - target - .parent() - .map(|parent| parent.as_partial().to_string()) - .unwrap_or_else(|| "".to_string()), - )); - state.revisit_verification_method(); - continue; - } - Err(error) => return Err(error), - } - } - super::progress::run_with_spinner( - "Verifying with local identity...", + match super::progress::run_with_spinner( + "Applying identity...", cert_server.issue_cert_with_identity( &auth_domain, domain.as_full(), @@ -893,17 +703,80 @@ async fn run_interactive_with_policy( &csr_pem, ), ) - .await? + .await + { + Ok(detail) => detail, + Err(error) if is_domain_not_found(&error) => { + if missing_target_action( + &target, + command.register_if_missing, + private_test_root_registration(command), + ) == MissingTargetAction::Reject + { + return Err(missing_target_error(&target)); + } + if target.level() != IdentityLevel::SubIdentity { + crate::cli::flow::transcript::print_block(&format!( + "Registering {} requires email verification.\nFalling back to email verification.", + target.short_name() + )); + state.fall_back_to_email(); + continue; + } + match ensure_sub_identity_exists_with_identity( + &target, + cert_server, + &auth_domain, + ) + .await + { + Ok(()) => {} + Err(Error::CertServer { source }) + if is_subdomain_quota_exceeded(&source) => + { + crate::cli::flow::transcript::print_block(&format!( + "Creating {} exceeded the sub-identity quota under {}.\nFalling back to email verification.", + target.short_name(), + target + .parent() + .map(|parent| parent.as_partial().to_string()) + .unwrap_or_else(|| "".to_string()), + )); + state.fall_back_to_email(); + continue; + } + Err(error) => return Err(error), + } + crate::cli::flow::transcript::print_line( + new_identity_confirmation_message(), + ); + super::progress::run_with_spinner( + "Applying identity...", + cert_server.issue_cert_with_identity( + &auth_domain, + domain.as_full(), + kind.as_str(), + None, + &device_name, + &csr_pem, + ), + ) + .await? + } + Err(error) => return Err(Error::from(error)), + } } }; + let local_identity_save = + cli::ensure_replace_local_allowed(dhttp_home, domain.borrow(), command.replace_local) + .await?; cli::save_identity( dhttp_home, &domain, key_pem.as_bytes(), detail.cert_pem.as_bytes(), ) - .instrument(super::progress::save_identity_span()) .await?; let welcome = super::welcome::maybe_create_welcome_service( dhttp_home, @@ -938,7 +811,6 @@ pub(crate) async fn run_with_policy( dhttp_home, home_scope, cert_server, - None, post_save, ) .await? @@ -959,13 +831,11 @@ pub(crate) async fn run_with_policy( for warning in &auth_plan.warnings { crate::cli::flow::transcript::print_err_block(warning); } - let approval_plan = resolve_approval_plan( + let approval_plan = resolve_non_interactive_approval_plan( target.short_name(), command.auth, auth_plan.first_identity_full_name(), - is_interactive, - ) - .await?; + )?; if command.send_code { if !matches!(command.auth, Some(AuthMethod::Email)) { @@ -1021,17 +891,43 @@ pub(crate) async fn run_with_policy( { Ok(detail) => detail, Err(error) if is_domain_not_found(&error) => { - whatever!( - "applying {} would register it first; rerun this command in an interactive terminal to confirm registration", - target.short_name() - ); + if missing_target_action( + &target, + command.register_if_missing, + private_test_root_registration(command), + ) == MissingTargetAction::Reject + { + return Err(missing_target_error(&target)); + } + ensure_identity_exists_after_apply_login( + &target, + cert_server, + &token, + is_interactive, + ) + .await + .map_err(preserve_apply_registration_error)?; + crate::cli::flow::transcript::print_line(new_identity_confirmation_message()); + super::progress::run_with_spinner( + "Applying identity...", + cert_server.issue_cert( + &token, + domain.as_full(), + kind.as_str(), + None, + &device_name, + &csr_pem, + ), + ) + .await + .map_err(preserve_apply_email_issue_error)? } - Err(error) => return Err(rewrite_apply_email_issue_error(&target, error)), + Err(error) => return Err(preserve_apply_email_issue_error(error)), } } ApplyApprovalPlan::DirectIdentity { auth_domain } => { match super::progress::run_with_spinner( - "Verifying with local identity...", + "Applying identity...", cert_server.issue_cert_with_identity( &auth_domain, domain.as_full(), @@ -1045,10 +941,35 @@ pub(crate) async fn run_with_policy( { Ok(detail) => detail, Err(error) if is_domain_not_found(&error) => { - whatever!( - "applying {} would register it first; rerun this command in an interactive terminal to confirm registration", - target.short_name() - ); + if missing_target_action( + &target, + command.register_if_missing, + private_test_root_registration(command), + ) == MissingTargetAction::Reject + { + return Err(missing_target_error(&target)); + } + if target.level() != IdentityLevel::SubIdentity { + whatever!( + "registering {} requires email verification; rerun with --auth email", + target.short_name() + ); + } + ensure_sub_identity_exists_with_identity(&target, cert_server, &auth_domain) + .await?; + crate::cli::flow::transcript::print_line(new_identity_confirmation_message()); + super::progress::run_with_spinner( + "Applying identity...", + cert_server.issue_cert_with_identity( + &auth_domain, + domain.as_full(), + kind.as_str(), + None, + &device_name, + &csr_pem, + ), + ) + .await? } Err(error) => return Err(Error::from(error)), } @@ -1061,7 +982,6 @@ pub(crate) async fn run_with_policy( key_pem.as_bytes(), detail.cert_pem.as_bytes(), ) - .instrument(super::progress::save_identity_span()) .await?; let welcome = super::welcome::maybe_create_welcome_service( dhttp_home, @@ -1099,12 +1019,11 @@ pub(crate) async fn run( #[cfg(test)] mod tests { use super::{ - ApplyApprovalPlan, ApplyEmailAction, ApplyVerifyCodeAction, InteractiveApplyState, - apply_email_actions, apply_identity_name_opening, apply_verification_options, - apply_verify_code_actions, approval_plan_from_selection, classify_apply_email_issue_error, - explicit_target_from_command, register_during_apply_message, - resolve_non_interactive_approval_plan, rewrite_apply_email_issue_error, - rewrite_apply_registration_error, + ApplyApprovalPlan, ApplyVerifyCodeAction, InteractiveApplyState, MissingTargetAction, + apply_identity_name_opening, apply_verify_code_actions, classify_apply_email_issue_error, + explicit_target_from_command, missing_target_action, missing_target_error, + new_identity_confirmation_message, preserve_apply_email_issue_error, + preserve_apply_registration_error, resolve_non_interactive_approval_plan, }; use crate::{ auth::AuthMethod, @@ -1117,6 +1036,7 @@ mod tests { &Apply { name: Some("alice.smith".to_string()), kind: Some("primary".to_string()), + register_if_missing: false, replace_local: false, device_name: None, email: Some("alice@example.test".to_string()), @@ -1145,6 +1065,7 @@ mod tests { &Apply { name: Some("alice.smith".to_string()), kind: Some("primary".to_string()), + register_if_missing: false, replace_local: false, device_name: None, email: Some("alice@example.test".to_string()), @@ -1179,26 +1100,24 @@ mod tests { assert_eq!( classify_apply_email_issue_error(&target, &error), - Some(crate::cli::flow::recovery::VerificationRecovery::BackToEmail { - message: "domain access is forbidden. To continue, enter the owner email for alice.smith again or choose another option.".to_string(), - }), + Some( + crate::cli::flow::recovery::VerificationRecovery::BackToEmail { + message: "domain access is forbidden".to_string(), + } + ), ); } #[test] - fn non_interactive_apply_email_domain_forbidden_mentions_owner_email() { + fn non_interactive_apply_email_issue_keeps_the_certserver_problem_message() { let error = crate::cert_server::Error::Api { status: reqwest::StatusCode::FORBIDDEN, code: "domain_forbidden".to_string(), message: "domain access is forbidden".to_string(), }; - let target = IdentityTarget::parse("alice.smith").unwrap(); + let rendered = preserve_apply_email_issue_error(error).to_string(); - let rendered = rewrite_apply_email_issue_error(&target, error).to_string(); - - assert!(rendered.contains("alice.smith"), "{rendered}"); - assert!(rendered.contains("owner email"), "{rendered}"); - assert!(rendered.contains("--auth identity"), "{rendered}"); + assert_eq!(rendered, "domain access is forbidden"); } #[test] @@ -1206,6 +1125,7 @@ mod tests { let target = explicit_target_from_command(&Apply { name: None, kind: None, + register_if_missing: false, replace_local: false, device_name: None, email: None, @@ -1278,75 +1198,89 @@ mod tests { #[test] fn apply_identity_name_opening_matches_spec_copy() { + assert_eq!(apply_identity_name_opening(), "Apply an identity here."); + } + + #[test] + fn newly_registered_identity_uses_the_approved_confirmation() { assert_eq!( - apply_identity_name_opening(), - "Name format:\n [handle.]your.name" + new_identity_confirmation_message(), + "This new name is yours now." ); } #[test] - fn register_during_apply_message_mentions_root_registration() { - let message = register_during_apply_message(&IdentityTarget::parse("alice.smith").unwrap()); + fn explicit_registration_flag_allows_registration() { + let target = IdentityTarget::parse("phone.alice.smith").unwrap(); + assert_eq!( + missing_target_action(&target, true, false), + MissingTargetAction::Register + ); + } - assert!( - message.contains("alice.smith does not exist yet."), - "{message}" + #[test] + fn missing_target_requires_the_explicit_flag_in_every_mode() { + let target = IdentityTarget::parse("phone.alice.smith").unwrap(); + assert_eq!( + missing_target_action(&target, false, false), + MissingTargetAction::Reject ); - assert!(message.contains("register alice.smith first"), "{message}"); - assert!(message.contains("This may require payment."), "{message}"); } #[test] - fn register_during_apply_message_mentions_subidentity_registration() { - let message = - register_during_apply_message(&IdentityTarget::parse("phone.alice.smith").unwrap()); + fn root_registration_requires_the_private_test_continuation() { + let target = IdentityTarget::parse("alice.smith").unwrap(); - assert!( - message.contains("phone.alice.smith does not exist yet."), - "{message}" - ); - assert!( - message.contains("register alice.smith first if needed"), - "{message}" + assert_eq!( + missing_target_action(&target, true, false), + MissingTargetAction::Reject ); - assert!(message.contains("create phone.alice.smith"), "{message}"); - assert!( - message.contains("This may require payment or quota expansion."), - "{message}" + assert_eq!( + missing_target_action(&target, true, true), + MissingTargetAction::Register ); } #[test] - fn starter_domain_limit_registration_error_keeps_api_code_and_adds_apply_context() { + fn non_interactive_missing_target_error_names_explicit_flag() { let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - let error = rewrite_apply_registration_error( - &target, - crate::cli::Error::CertServer { - source: crate::cert_server::Error::Api { - status: reqwest::StatusCode::CONFLICT, - code: "starter_domain_limit_reached".to_string(), - message: "starter plan is limited to 3 free domains per account".to_string(), - }, - }, + let rendered = missing_target_error(&target).to_string(); + + assert_eq!( + rendered, + "phone.alice.smith does not exist yet. To register this sub-identity before applying it, rerun with --register-if-missing." ); + } + + #[test] + fn missing_root_error_does_not_advertise_private_root_registration() { + let target = IdentityTarget::parse("alice.smith").unwrap(); + + assert_eq!( + missing_target_error(&target).to_string(), + "alice.smith does not exist yet. Apply can register a missing sub-identity, but not a new root identity." + ); + } + + #[test] + fn starter_domain_limit_registration_error_keeps_the_certserver_problem_message() { + let error = preserve_apply_registration_error(crate::cli::Error::CertServer { + source: crate::cert_server::Error::Api { + status: reqwest::StatusCode::CONFLICT, + code: "starter_domain_limit_reached".to_string(), + message: "starter plan is limited to 3 free domains per account".to_string(), + }, + }); let rendered = error.to_string(); - assert!(rendered.contains("phone.alice.smith"), "{rendered}"); - assert!(rendered.contains("alice.smith"), "{rendered}"); - assert!( - rendered.contains("maximum 3 free starter domains"), - "{rendered}" + assert_eq!( + rendered, + "starter plan is limited to 3 free domains per account" ); - match error { - crate::cli::Error::Whatever { source } => { - let inner = std::error::Error::source(&source) - .and_then(|inner| inner.downcast_ref::()); - assert!(matches!( - inner, - Some(error) if error.is_api_code("starter_domain_limit_reached") - )); - } - other => panic!("unexpected error: {other:?}"), - } + assert!(matches!( + error, + crate::cli::Error::CertServer { source } + if source.is_api_code("starter_domain_limit_reached") + )); } #[test] @@ -1368,32 +1302,9 @@ mod tests { } #[test] - fn interactive_apply_selection_can_choose_email() { - let options = apply_verification_options("alice.smith.dhttp.net"); - - assert_eq!( - approval_plan_from_selection(&options, "Verify with email").unwrap(), - ApplyApprovalPlan::Email, - ); - } - - #[test] - fn interactive_apply_selection_can_choose_local_identity() { - let options = apply_verification_options("alice.smith.dhttp.net"); - - assert_eq!( - approval_plan_from_selection(&options, "Verify with alice.smith on local device",) - .unwrap(), - ApplyApprovalPlan::DirectIdentity { - auth_domain: "alice.smith.dhttp.net".to_string(), - }, - ); - } - - #[test] - fn apply_verify_code_actions_include_return_to_parent_flow() { + fn apply_verify_code_actions_are_limited_to_the_code_recovery_boundary() { assert_eq!( - apply_verify_code_actions(Some("create phone.alice.smith")) + apply_verify_code_actions() .into_iter() .map(|action| action.label()) .collect::>(), @@ -1404,7 +1315,7 @@ mod tests { ] ); assert_eq!( - apply_verify_code_actions(Some("create phone.alice.smith")), + apply_verify_code_actions(), vec![ ApplyVerifyCodeAction::ResendVerificationCode, ApplyVerifyCodeAction::ChangeEmail, @@ -1412,31 +1323,4 @@ mod tests { ] ); } - - #[test] - fn apply_email_actions_include_explicit_return_points() { - assert_eq!( - apply_email_actions(Some("create phone.alice.smith")) - .into_iter() - .map(|action| action.label()) - .collect::>(), - vec![ - "Switch verification method (go back to verification method selection)".to_string(), - "Change certificate kind (go back to identity kind)".to_string(), - "Change identity (go back to identity selection)".to_string(), - "Return to create phone.alice.smith".to_string(), - ] - ); - assert_eq!( - apply_email_actions(Some("create phone.alice.smith")), - vec![ - ApplyEmailAction::SwitchVerificationMethod, - ApplyEmailAction::ChangeCertificateKind, - ApplyEmailAction::ChangeIdentitySelection, - ApplyEmailAction::ReturnToCaller { - label: "create phone.alice.smith".to_string(), - }, - ] - ); - } } diff --git a/genmeta-identity/src/cli/flow/auth_plan.rs b/genmeta-identity/src/cli/flow/auth_plan.rs index 602379b..41a4b70 100644 --- a/genmeta-identity/src/cli/flow/auth_plan.rs +++ b/genmeta-identity/src/cli/flow/auth_plan.rs @@ -69,15 +69,21 @@ pub(crate) fn plan_auth_candidates( .iter() .flatten() .find(|candidate| candidate.status.is_ready()); - let action = match next_ready { - Some(next) => format!("Trying its parent identity, {}.", next.target.short_name()), - None => "Falling back to email verification.".to_string(), - }; - warnings.push(format!( - "Cannot verify with {} because {}.\n{action}", + let problem = format!( + "Cannot verify with {} because {}.", summary.target.short_name(), unavailable_reason(summary), - )); + ); + let later_saved_candidate_exists = summaries[index + 1..].iter().flatten().next().is_some(); + let warning = match next_ready { + Some(next) => format!( + "{problem}\nTrying its parent identity, {}.", + next.target.short_name() + ), + None if later_saved_candidate_exists => problem, + None => format!("{problem}\nFalling back to email verification."), + }; + warnings.push(warning); } candidates.push(AuthCandidate::Email); @@ -208,6 +214,33 @@ mod tests { ); } + #[test] + fn unavailable_target_and_parent_explain_each_skip_before_one_email_fallback() { + let target = summary( + "phone.alice.smith", + LocalIdentityStatus::Incomplete { + detail: "private key missing".into(), + }, + ); + let parent = summary( + "alice.smith", + LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".into(), + }, + ); + + let plan = plan_auth_candidates(Some(&target), Some(&parent)); + + assert_eq!(plan.candidates, vec![AuthCandidate::Email]); + assert_eq!( + plan.warnings, + vec![ + "Cannot verify with phone.alice.smith because its local identity is incomplete: private key missing.", + "Cannot verify with alice.smith because its local identity is invalid: certificate is unreadable.\nFalling back to email verification.", + ] + ); + } + #[test] fn unrelated_default_is_never_a_candidate() { let plan = plan_auth_candidates(None, None); diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index 65be336..a08a1de 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -9,6 +9,10 @@ use crate::{ cli::{self, Default, Error}, }; +fn default_not_found_message(short_name: &str) -> String { + format!("Failed to set default identity: {short_name} not found!") +} + async fn set_default_summary( dhttp_home: &DhttpHome, current_config: Option, @@ -62,11 +66,7 @@ pub(crate) async fn run( ) .await? else { - whatever!( - "{} is not saved here.\n\nApply {} here first, then set it as the default identity.", - target.short_name(), - target.short_name(), - ); + whatever!("{}", default_not_found_message(target.short_name())); }; if !summary.status.is_ready() && !command.allow_nonready { @@ -79,3 +79,16 @@ pub(crate) async fn run( set_default_summary(dhttp_home, current_config, summary).await } + +#[cfg(test)] +mod tests { + use super::default_not_found_message; + + #[test] + fn missing_default_target_matches_the_edited_document() { + assert_eq!( + default_not_found_message("alice.smith"), + "Failed to set default identity: alice.smith not found!" + ); + } +} diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index 4b6e280..f3087e3 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -26,37 +26,18 @@ pub(crate) fn suggest_default_change( Some(current) if current.name == saved_name => None, Some(current) => Some(DefaultSuggestion { prompt: format!( - "Set {saved_name} as the default here? {}", + "Set this name({saved_name}) as default? {}", output::format_current_default_suffix(¤t.name, ¤t.status, ansi) ), default: false, }), None => Some(DefaultSuggestion { - prompt: format!("Set {saved_name} as the default here?"), + prompt: format!("Set this name({saved_name}) as default?"), default: true, }), } } -pub(crate) fn default_block( - before: Option<&str>, - after: Option<&str>, -) -> output::DefaultIdentityBlock { - match (before, after) { - (_, None) => output::DefaultIdentityBlock::None, - (None, Some(current)) => output::DefaultIdentityBlock::NewlySet { - name: current.to_string(), - }, - (Some(old), Some(current)) if old == current => output::DefaultIdentityBlock::Unchanged { - name: current.to_string(), - }, - (Some(old), Some(current)) => output::DefaultIdentityBlock::Changed { - old: old.to_string(), - new: current.to_string(), - }, - } -} - async fn current_default_name(dhttp_home: &DhttpHome) -> Result>, Error> { Ok(cli::load_current_settings(dhttp_home) .await? @@ -101,13 +82,13 @@ async fn save_default_name( pub(crate) async fn run_lifecycle_epilogue( dhttp_home: &DhttpHome, name: DhttpName<'_>, - default_at_start: Option>, + _default_at_start: Option>, interactive: bool, action: output::SavedIdentityAction, welcome: Option<&super::welcome::WelcomeServiceCreated>, ) -> Result<(), Error> { let ansi = std::io::stdout().is_terminal(); - let mut default_after = current_default_name(dhttp_home).await?; + let default_after = current_default_name(dhttp_home).await?; let current_default = current_default_summary(dhttp_home).await?; let summary = local::load_summary( dhttp_home, @@ -119,7 +100,6 @@ pub(crate) async fn run_lifecycle_epilogue( transcript::print_block(&output::format_saved_identity_result( action, &summary, ansi, )); - transcript::print_line(output::format_safekeeping_reminder(ansi)); if interactive && let Some(suggestion) = @@ -134,17 +114,10 @@ pub(crate) async fn run_lifecycle_epilogue( .require_interactive("interactive input")?; if accepted { - default_after = Some(save_default_name(dhttp_home, name.clone()).await?); + save_default_name(dhttp_home, name.clone()).await?; } } - let block = default_block( - default_at_start - .as_ref() - .map(|default| default.as_partial()), - default_after.as_ref().map(|default| default.as_partial()), - ); - transcript::print_line(output::format_default_identity_sentence(&block)); if let Some(welcome) = welcome { transcript::print_block(&super::welcome::format_welcome_service_created(welcome)); } @@ -161,8 +134,8 @@ mod tests { use dhttp::{home::DhttpHome, name::DhttpName}; use tokio::fs; - use super::{CurrentDefaultSummary, DefaultSuggestion, default_block, suggest_default_change}; - use crate::cli::flow::{local::LocalIdentityStatus, output::DefaultIdentityBlock}; + use super::{CurrentDefaultSummary, DefaultSuggestion, suggest_default_change}; + use crate::cli::flow::local::LocalIdentityStatus; fn unique_test_home_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() @@ -180,7 +153,7 @@ mod tests { let suggestion = suggest_default_change("alice.smith", None, false).unwrap(); assert!(suggestion.default); - assert_eq!(suggestion.prompt, "Set alice.smith as the default here?"); + assert_eq!(suggestion.prompt, "Set this name(alice.smith) as default?"); } #[test] @@ -200,24 +173,13 @@ mod tests { assert_eq!( suggestion, DefaultSuggestion { - prompt: "Set alice.smith as the default here? (current: meng.lin [invalid])" + prompt: "Set this name(alice.smith) as default? (current: meng.lin [invalid])" .to_string(), default: false, } ); } - #[test] - fn default_block_reports_changed_identity() { - assert_eq!( - default_block(Some("meng.lin"), Some("alice.smith")), - DefaultIdentityBlock::Changed { - old: "meng.lin".to_string(), - new: "alice.smith".to_string(), - } - ); - } - #[tokio::test] async fn non_interactive_lifecycle_epilogue_keeps_default_unset_when_none_exists() { let home_path = unique_test_home_path("keeps-default-unset"); @@ -231,7 +193,7 @@ mod tests { name.borrow(), None, false, - crate::cli::flow::output::SavedIdentityAction::Created, + crate::cli::flow::output::SavedIdentityAction::Applied, None, ) .await diff --git a/genmeta-identity/src/cli/flow/kind.rs b/genmeta-identity/src/cli/flow/kind.rs index faa6105..9140d78 100644 --- a/genmeta-identity/src/cli/flow/kind.rs +++ b/genmeta-identity/src/cli/flow/kind.rs @@ -10,11 +10,8 @@ pub(crate) enum IdentityKind { } impl IdentityKind { - pub(crate) const SELECT_PROMPT: &str = "Choose how this identity should be used here."; - pub(crate) const PRIMARY_HELP: &str = - "Primary\n For a main host, server, desktop, home gateway, or always-on endpoint."; - pub(crate) const SECONDARY_HELP: &str = - "Secondary\n For an additional device, such as a phone, laptop, or temporary endpoint."; + pub(crate) const SELECT_PROMPT: &str = "Select usage for this name:"; + pub(crate) const USAGE_HELP: &str = "both client and server\n For a main host, server, desktop, home gateway, or always-on endpoint.\nclient only\n For an additional device, such as a phone, laptop, or temporary endpoint."; pub(crate) fn as_str(self) -> &'static str { match self { @@ -22,6 +19,13 @@ impl IdentityKind { Self::Secondary => "secondary", } } + + pub(crate) fn usage_label(self) -> &'static str { + match self { + Self::Primary => "both client and server", + Self::Secondary => "client only", + } + } } impl fmt::Display for IdentityKind { @@ -78,14 +82,18 @@ mod tests { } #[test] - fn displays_labels_and_help_copy() { + fn keeps_wire_labels_separate_from_user_facing_usage_copy() { assert_eq!(IdentityKind::Primary.to_string(), "primary"); assert_eq!(IdentityKind::Secondary.to_string(), "secondary"); + assert_eq!(IdentityKind::SELECT_PROMPT, "Select usage for this name:"); + assert_eq!( + IdentityKind::Primary.usage_label(), + "both client and server" + ); + assert_eq!(IdentityKind::Secondary.usage_label(), "client only"); assert_eq!( - IdentityKind::SELECT_PROMPT, - "Choose how this identity should be used here." + IdentityKind::USAGE_HELP, + "both client and server\n For a main host, server, desktop, home gateway, or always-on endpoint.\nclient only\n For an additional device, such as a phone, laptop, or temporary endpoint." ); - assert!(IdentityKind::PRIMARY_HELP.contains("main host")); - assert!(IdentityKind::SECONDARY_HELP.contains("additional device")); } } diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index 48d527f..d5b6c61 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -3,45 +3,39 @@ use crossterm::style::Stylize; #[cfg(test)] use super::local::InteractiveInventoryChoice; use super::local::{LocalIdentityStatus, LocalIdentitySummary, LocalInventory, LocalInventoryRoot}; -#[cfg(test)] -use super::target::IdentityLevel; pub(crate) fn render_inventory(inventory: &LocalInventory, ansi: bool) -> String { - let mut lines = Vec::new(); - for group in &inventory.groups { - if let LocalInventoryRoot::Saved(summary) = &group.root { - lines.push(render_line( - compact_identity_label(summary), + inventory_summaries(inventory) + .into_iter() + .map(|summary| { + render_line( + format!("- {}", compact_identity_label(summary)), summary_line_style(summary), ansi, - )); - } - for child in &group.children { - lines.push(render_line( - compact_identity_label(child), - summary_line_style(child), - ansi, - )); - } - } - - lines.join("\n") + ) + }) + .collect::>() + .join("\n") } pub(crate) fn render_verbose_inventory(inventory: &LocalInventory, ansi: bool) -> String { - let mut blocks = Vec::new(); + inventory_summaries(inventory) + .into_iter() + .map(|summary| format_info(summary, ansi)) + .collect::>() + .join("\n\n") +} + +fn inventory_summaries(inventory: &LocalInventory) -> Vec<&LocalIdentitySummary> { + let mut summaries = Vec::new(); for group in &inventory.groups { if let LocalInventoryRoot::Saved(summary) = &group.root { - blocks.push(format_info(summary, ansi)); + summaries.push(summary); } - blocks.extend( - group - .children - .iter() - .map(|summary| format_info(summary, ansi)), - ); + summaries.extend(&group.children); } - blocks.join("\n\n") + summaries.sort_by_key(|summary| !summary.is_default); + summaries } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -51,26 +45,14 @@ pub(crate) enum LineStyle { Dim, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum DefaultIdentityBlock { - None, - NewlySet { name: String }, - Unchanged { name: String }, - Changed { old: String, new: String }, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SavedIdentityAction { - #[cfg(test)] - Created, Applied, } impl SavedIdentityAction { fn verb(self) -> &'static str { match self { - #[cfg(test)] - Self::Created => "Created", Self::Applied => "Applied", } } @@ -122,10 +104,10 @@ pub(crate) fn compact_identity_label_parts( pub(crate) fn status_line_style(status: &LocalIdentityStatus) -> LineStyle { match status { - LocalIdentityStatus::Invalid { .. } | LocalIdentityStatus::Incomplete { .. } => { - LineStyle::Dim - } - LocalIdentityStatus::Ready { .. } | LocalIdentityStatus::Expired { .. } => LineStyle::Plain, + LocalIdentityStatus::Expired { .. } => LineStyle::Dim, + LocalIdentityStatus::Ready { .. } + | LocalIdentityStatus::Invalid { .. } + | LocalIdentityStatus::Incomplete { .. } => LineStyle::Plain, } } @@ -147,18 +129,11 @@ pub(crate) fn format_current_default_suffix( #[cfg(test)] pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: bool) -> String { match choice { - InteractiveInventoryChoice::Saved(summary) => { - let prefix = if matches!(summary.target.level(), IdentityLevel::SubIdentity) { - " " - } else { - "" - }; - render_line( - format!("{prefix}{}", compact_identity_label(summary)), - summary_line_style(summary), - ansi, - ) - } + InteractiveInventoryChoice::Saved(summary) => render_line( + compact_identity_label(summary), + summary_line_style(summary), + ansi, + ), InteractiveInventoryChoice::Organization { target } => render_line( format!("{} (not saved here)", target.short_name()), LineStyle::Dim, @@ -167,48 +142,23 @@ pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: boo } } -pub(crate) fn format_default_identity_sentence(block: &DefaultIdentityBlock) -> String { - match block { - DefaultIdentityBlock::None => "No default identity is set here".to_string(), - DefaultIdentityBlock::NewlySet { name } => format!("Default identity set to {name}"), - DefaultIdentityBlock::Unchanged { name } => format!("Default identity remains {name}"), - DefaultIdentityBlock::Changed { old, new } => { - format!("Default identity changed from {old} to {new}") - } - } -} - -pub(crate) fn format_safekeeping_reminder(ansi: bool) -> String { - render_line( - "Keep this identity material safe".to_string(), - LineStyle::Bold, - ansi, - ) -} - pub(crate) fn format_saved_identity_result( action: SavedIdentityAction, summary: &LocalIdentitySummary, - ansi: bool, + _ansi: bool, ) -> String { - let mut lines = Vec::new(); - lines.push(render_line( - format!( - "{} identity {}", - action.verb(), - compact_identity_label(summary) - ), - summary_line_style(summary), - ansi, - )); - lines.extend(detail_lines(summary)); - lines.join("\n") + let mut result = format!("{} identity {}", action.verb(), summary.target.short_name()); + if let Some(chain) = summary.certificate_chain.as_deref() { + result.push_str(&format!(" as {chain}")); + } + result.push_str(&format!(" at {}", summary.saved_at.display())); + result } pub(crate) fn format_info(summary: &LocalIdentitySummary, ansi: bool) -> String { let mut lines = Vec::new(); lines.push(render_line( - compact_identity_label(summary), + identity_summary_label(summary), summary_line_style(summary), ansi, )); @@ -216,6 +166,20 @@ pub(crate) fn format_info(summary: &LocalIdentitySummary, ansi: bool) -> String lines.join("\n") } +fn identity_summary_label(summary: &LocalIdentitySummary) -> String { + let mut label = summary.target.short_name().to_string(); + if !matches!(summary.status, LocalIdentityStatus::Ready { .. }) { + label.push_str(&format!(" [{}]", summary.status.label())); + } + if let Some(chain) = summary.certificate_chain.as_deref() { + label.push_str(&format!(" as {chain}")); + } + if summary.is_default { + label.push_str(" (default)"); + } + label +} + #[cfg(test)] pub(crate) fn format_default_summary(summary: &LocalIdentitySummary, ansi: bool) -> String { format_info(summary, ansi) @@ -225,7 +189,7 @@ pub(crate) fn format_default_query(summary: &LocalIdentitySummary) -> String { let name = summary.target.short_name(); match summary.status { LocalIdentityStatus::Expired { expired_at } => format!( - "{name}\n\nWARNING: This name expired on {}. Renew it soon, or the name may be released.\nRun `genmeta identity renew {name}` to renew it.", + "{name}\n\nWARNING: This name expired on {}. Renew it in time or the name may be released!\n Run `genmeta identity renew {name}` to renew it.", format_natural_date(expired_at), ), _ => name.to_string(), @@ -253,31 +217,34 @@ fn format_natural_date(timestamp: i64) -> String { format!("{:02} {month} {}", date.day(), date.year()) } +fn format_iso_date(timestamp: i64) -> String { + time::OffsetDateTime::from_unix_timestamp(timestamp) + .expect("certificate timestamp should fit OffsetDateTime") + .date() + .to_string() +} + fn detail_lines(summary: &LocalIdentitySummary) -> Vec { - let mut lines = vec![format!(" status: {}", summary.status.label())]; - if let Some(chain) = summary.certificate_chain.as_deref() { - if let Some((usage, sequence)) = chain.split_once(':') { - lines.push(format!(" usage: {usage}")); - lines.push(format!(" sequence: {sequence}")); - } - lines.push(format!(" chain: {chain}")); + let mut lines = Vec::new(); + if let Some(issuer) = summary.issuer.as_deref() { + lines.push(format!("Issuer: {issuer}")); } - lines.push(format!(" dir: {}", summary.saved_at.display())); - if let (Some(valid_from), Some(expires_at)) = (summary.valid_from, summary.status.expires_at()) - { - lines.push(format!( - " validity: {} - {}", - format_natural_date(valid_from), - format_natural_date(expires_at) - )); + if let Some(valid_from) = summary.valid_from { + lines.push(format!("Valid from: {}", format_iso_date(valid_from))); } - if let Some(issuer) = summary.issuer.as_deref() { - lines.push(format!(" issuer: {issuer}")); + if let Some(expires_at) = summary.status.expires_at() { + let label = if matches!(summary.status, LocalIdentityStatus::Expired { .. }) { + "Expired" + } else { + "Expires" + }; + lines.push(format!("{label}: {}", format_iso_date(expires_at))); } + lines.push(format!("dir: {}", summary.saved_at.display())); if let LocalIdentityStatus::Incomplete { detail } | LocalIdentityStatus::Invalid { detail } = &summary.status { - lines.push(format!(" reason: {detail}")); + lines.push(format!("reason: {detail}")); } lines } @@ -287,10 +254,9 @@ mod tests { use std::path::PathBuf; use super::{ - DefaultIdentityBlock, LineStyle, compact_identity_label, format_current_default_suffix, - format_default_identity_sentence, format_default_query, format_default_summary, - format_info, render_choice_label, render_inventory, render_verbose_inventory, - summary_line_style, + LineStyle, SavedIdentityAction, compact_identity_label, format_current_default_suffix, + format_default_query, format_default_summary, format_info, format_saved_identity_result, + render_choice_label, render_inventory, render_verbose_inventory, summary_line_style, }; use crate::cli::flow::{ local::{ @@ -330,16 +296,30 @@ mod tests { ); let rendered = format_info(&profile, false); - assert!(rendered.contains(" status: ready")); - assert!(rendered.contains(" usage: secondary")); - assert!(rendered.contains(" sequence: 2")); - assert!(rendered.contains(" chain: secondary:2")); - assert!(rendered.contains(" dir: /tmp/phone.alice.smith")); - assert!(rendered.contains(" validity: 14 Nov 2023 - 10 Nov 2026")); - assert!(rendered.contains(" issuer: CN=Genmeta Test CA")); + assert_eq!( + rendered, + "phone.alice.smith as secondary:2 (default)\nIssuer: CN=Genmeta Test CA\nValid from: 2023-11-14\nExpires: 2026-11-10\ndir: /tmp/phone.alice.smith" + ); assert_eq!(format_default_summary(&profile, false), rendered); } + #[test] + fn formats_saved_identity_as_one_line_result() { + let profile = summary( + "alice.smith", + false, + LocalIdentityStatus::Ready { + expires_at: EXPIRES_AT, + }, + Some("primary:0"), + ); + + assert_eq!( + format_saved_identity_result(SavedIdentityAction::Applied, &profile, false), + "Applied identity alice.smith as primary:0 at /tmp/alice.smith" + ); + } + #[test] fn expired_default_query_keeps_name_and_actionable_warning() { let profile = summary( @@ -353,7 +333,7 @@ mod tests { assert_eq!( format_default_query(&profile), - "alice.smith\n\nWARNING: This name expired on 08 Jul 2026. Renew it soon, or the name may be released.\nRun `genmeta identity renew alice.smith` to renew it.", + "alice.smith\n\nWARNING: This name expired on 08 Jul 2026. Renew it in time or the name may be released!\n Run `genmeta identity renew alice.smith` to renew it.", ); } @@ -371,6 +351,20 @@ mod tests { assert_eq!(summary_line_style(&profile), LineStyle::Bold); } + #[test] + fn expired_line_is_dimmed() { + let profile = summary( + "alice.smith", + false, + LocalIdentityStatus::Expired { + expired_at: EXPIRES_AT, + }, + Some("primary:0"), + ); + + assert_eq!(summary_line_style(&profile), LineStyle::Dim); + } + #[test] fn compact_label_hides_ready_status() { let profile = summary( @@ -386,7 +380,7 @@ mod tests { } #[test] - fn invalid_default_line_prefers_dim_over_bold() { + fn invalid_default_line_remains_bold_and_visible() { let profile = summary( "alice.smith", true, @@ -396,7 +390,7 @@ mod tests { None, ); - assert_eq!(summary_line_style(&profile), LineStyle::Dim); + assert_eq!(summary_line_style(&profile), LineStyle::Bold); } #[test] @@ -411,9 +405,9 @@ mod tests { ); let rendered = format_info(&profile, false); - assert!(rendered.contains(" status: invalid")); - assert!(rendered.contains(" dir: /tmp/alice.smith")); - assert!(rendered.contains(" reason: certificate chain metadata is invalid")); + assert!(rendered.starts_with("alice.smith [invalid]")); + assert!(rendered.contains("\ndir: /tmp/alice.smith")); + assert!(rendered.contains("\nreason: certificate chain metadata is invalid")); } #[test] @@ -430,33 +424,6 @@ mod tests { ); } - #[test] - fn formats_default_identity_sentences() { - assert_eq!( - format_default_identity_sentence(&DefaultIdentityBlock::NewlySet { - name: "alice.smith".to_string(), - }), - "Default identity set to alice.smith" - ); - assert_eq!( - format_default_identity_sentence(&DefaultIdentityBlock::Changed { - old: "meng.lin".to_string(), - new: "alice.smith".to_string(), - }), - "Default identity changed from meng.lin to alice.smith" - ); - assert_eq!( - format_default_identity_sentence(&DefaultIdentityBlock::Unchanged { - name: "alice.smith".to_string(), - }), - "Default identity remains alice.smith" - ); - assert_eq!( - format_default_identity_sentence(&DefaultIdentityBlock::None), - "No default identity is set here" - ); - } - #[test] fn renders_flat_compact_inventory_lines() { let inventory = build_inventory(vec![ @@ -495,14 +462,41 @@ mod tests { ]); let expected = "\ -alice.smith (default)\n\ -phone.alice.smith\n\ -tv.alice.smith [incomplete]\n\ -tablet.reimu.scarlet [expired]"; +- alice.smith (default)\n\ +- phone.alice.smith\n\ +- tv.alice.smith [incomplete]\n\ +- tablet.reimu.scarlet [expired]"; assert_eq!(render_inventory(&inventory, false), expected); } + #[test] + fn default_sub_identity_is_the_first_list_entry() { + let inventory = build_inventory(vec![ + summary( + "alice.smith", + false, + LocalIdentityStatus::Ready { + expires_at: EXPIRES_AT, + }, + Some("primary:0"), + ), + summary( + "phone.alice.smith", + true, + LocalIdentityStatus::Ready { + expires_at: EXPIRES_AT, + }, + Some("secondary:1"), + ), + ]); + + assert_eq!( + render_inventory(&inventory, false), + "- phone.alice.smith (default)\n- alice.smith" + ); + } + #[test] fn verbose_inventory_reuses_info_renderer() { let inventory = build_inventory(vec![summary( @@ -550,7 +544,7 @@ tablet.reimu.scarlet [expired]"; labels, vec![ "alice.ma (not saved here)".to_string(), - " shanghai.alice.ma".to_string(), + "shanghai.alice.ma".to_string(), ] ); } @@ -593,7 +587,7 @@ tablet.reimu.scarlet [expired]"; vec![ "alice.smith (default)".to_string(), "reimu.scarlet (not saved here)".to_string(), - " tablet.reimu.scarlet".to_string(), + "tablet.reimu.scarlet".to_string(), ] ); } diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index da9f50d..f75f766 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -1,12 +1,8 @@ use std::future::Future; -use tracing::{Instrument, Span, info_span}; +use tracing::{Instrument, info_span}; use tracing_indicatif::span_ext::IndicatifSpanExt; -pub(crate) fn save_identity_span() -> Span { - info_span!("save_identity", indicatif.pb_show = tracing::field::Empty) -} - pub(crate) async fn run_with_spinner(message: &str, future: Fut) -> Result where Fut: Future>, diff --git a/genmeta-identity/src/cli/flow/recovery.rs b/genmeta-identity/src/cli/flow/recovery.rs index 92529df..a6ec1c0 100644 --- a/genmeta-identity/src/cli/flow/recovery.rs +++ b/genmeta-identity/src/cli/flow/recovery.rs @@ -6,14 +6,22 @@ pub(crate) enum VerificationRecovery { Abort, } +pub(crate) fn format_resend_offer(message: &str) -> String { + format!("{message}\n") +} + pub(crate) fn classify_resend_error(error: &crate::cert_server::Error) -> VerificationRecovery { match error { - crate::cert_server::Error::Api { status, code, .. } + crate::cert_server::Error::Api { + status, + code, + message, + } if *status == reqwest::StatusCode::TOO_MANY_REQUESTS && code == "verify_code_too_frequent" => { VerificationRecovery::StayCurrentStep { - message: "The verification code was sent too recently. To continue, wait a moment and enter the existing code, or retry resend later.".to_string(), + message: message.clone(), } } crate::cert_server::Error::Request { .. } @@ -25,6 +33,9 @@ pub(crate) fn classify_resend_error(error: &crate::cert_server::Error) -> Verifi crate::cert_server::Error::Api { status, .. } if status.is_server_error() => { VerificationRecovery::Abort } + crate::cert_server::Error::Api { message, .. } => VerificationRecovery::BackToEmail { + message: message.clone(), + }, _ => VerificationRecovery::BackToEmail { message: "The current verification session can no longer be used. To continue, enter your email again.".to_string(), }, @@ -45,19 +56,27 @@ pub(crate) fn classify_verify_submit_error( message: message.clone(), } } - crate::cert_server::Error::Api { status, code, .. } + crate::cert_server::Error::Api { + status, + code, + message, + } if *status == reqwest::StatusCode::UNAUTHORIZED && code == "verify_code_invalid" => { VerificationRecovery::StayCurrentStep { - message: "The verification code could not be used. To continue, enter the code again or choose another option.".to_string(), + message: message.clone(), } } - crate::cert_server::Error::Api { status, code, .. } + crate::cert_server::Error::Api { + status, + code, + message, + } if *status == reqwest::StatusCode::TOO_MANY_REQUESTS && code == "verify_code_too_frequent" => { VerificationRecovery::StayCurrentStep { - message: "The verification code was sent too recently. To continue, wait a moment and enter the existing code, or retry resend later.".to_string(), + message: message.clone(), } } crate::cert_server::Error::Api { status, code, message } @@ -65,14 +84,15 @@ pub(crate) fn classify_verify_submit_error( && code == "domain_email_not_matched" => { VerificationRecovery::BackToEmail { - message: format!( - "{message}. To continue, enter the owner email again or choose another option." - ), + message: message.clone(), } } crate::cert_server::Error::Api { status, .. } if status.is_server_error() => { VerificationRecovery::Abort } + crate::cert_server::Error::Api { message, .. } => VerificationRecovery::BackToEmail { + message: message.clone(), + }, _ => VerificationRecovery::BackToEmail { message: "The verification code session needs to be restarted. To continue, enter your email again.".to_string(), }, @@ -81,7 +101,18 @@ pub(crate) fn classify_verify_submit_error( #[cfg(test)] mod tests { - use super::{VerificationRecovery, classify_resend_error, classify_verify_submit_error}; + use super::{ + VerificationRecovery, classify_resend_error, classify_verify_submit_error, + format_resend_offer, + }; + + #[test] + fn resend_offer_leaves_one_blank_line_before_the_confirm_prompt() { + assert_eq!( + format_resend_offer("verification code expired"), + "verification code expired\n" + ); + } #[test] fn resend_rate_limit_stays_on_current_step() { @@ -94,7 +125,23 @@ mod tests { assert_eq!( classify_resend_error(&error), VerificationRecovery::StayCurrentStep { - message: "The verification code was sent too recently. To continue, wait a moment and enter the existing code, or retry resend later.".to_string(), + message: "email code sent too frequently".to_string(), + } + ); + } + + #[test] + fn invalid_code_keeps_the_certserver_problem_message() { + let error = crate::cert_server::Error::Api { + status: reqwest::StatusCode::UNAUTHORIZED, + code: "verify_code_invalid".to_string(), + message: "Your verification code is incorrect. Please try again.".to_string(), + }; + + assert_eq!( + classify_verify_submit_error(&error), + VerificationRecovery::StayCurrentStep { + message: "Your verification code is incorrect. Please try again.".to_string(), } ); } @@ -140,7 +187,7 @@ mod tests { assert_eq!( classify_verify_submit_error(&error), VerificationRecovery::BackToEmail { - message: "email does not match the current owner of the domain. To continue, enter the owner email again or choose another option.".to_string(), + message: "email does not match the current owner of the domain".to_string(), } ); } @@ -156,7 +203,23 @@ mod tests { assert_eq!( classify_verify_submit_error(&error), VerificationRecovery::BackToEmail { - message: "The verification code session needs to be restarted. To continue, enter your email again.".to_string(), + message: "domain not found".to_string(), + } + ); + } + + #[test] + fn resend_business_error_keeps_the_certserver_problem_message() { + let error = crate::cert_server::Error::Api { + status: reqwest::StatusCode::UNAUTHORIZED, + code: "verify_session_invalid".to_string(), + message: "verification session is no longer valid".to_string(), + }; + + assert_eq!( + classify_resend_error(&error), + VerificationRecovery::BackToEmail { + message: "verification session is no longer valid".to_string(), } ); } diff --git a/genmeta-identity/src/cli/flow/registration.rs b/genmeta-identity/src/cli/flow/registration.rs index 0b82f9c..23b442b 100644 --- a/genmeta-identity/src/cli/flow/registration.rs +++ b/genmeta-identity/src/cli/flow/registration.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use snafu::{OptionExt, whatever}; +use snafu::{FromString, OptionExt, whatever}; use super::target::IdentityTarget; use crate::{ @@ -25,6 +25,17 @@ fn is_domain_conflict(error: &crate::cert_server::Error) -> bool { pub(crate) fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { error.is_api_code("subdomain_quota_exceeded") } + +pub(crate) fn create_identity_progress_message() -> &'static str { + "Creating identity..." +} + +fn missing_parent_identity_message(target: &IdentityTarget, parent: &str) -> String { + format!( + "Cannot register {} because its parent identity, {parent}, does not exist.", + target.short_name() + ) +} pub(crate) fn ensure_non_interactive_root_checkout_not_required( target: &IdentityTarget, response: &CreateDomainResponse, @@ -147,21 +158,6 @@ async fn wait_for_invoice_terminal( .await } -pub(crate) async fn ensure_root_identity_exists_with_token( - cert_server: &CertServer, - parent: &dhttp::name::DhttpName<'_>, - access_token: &str, -) -> Result<(), Error> { - let parent_target = IdentityTarget::parse(parent.as_partial())?; - ensure_identity_exists_with_token( - cert_server, - &parent_target, - access_token, - "Creating parent identity...", - ) - .await -} - pub(crate) async fn ensure_identity_exists_with_token( cert_server: &CertServer, target: &IdentityTarget, @@ -183,21 +179,6 @@ pub(crate) async fn ensure_identity_exists_with_token( Ok(()) } -pub(crate) async fn ensure_root_identity_exists_with_token_interactively( - cert_server: &CertServer, - parent: &dhttp::name::DhttpName<'_>, - access_token: &str, -) -> Result<(), Error> { - let parent_target = IdentityTarget::parse(parent.as_partial())?; - ensure_identity_exists_with_token_interactively( - cert_server, - &parent_target, - access_token, - "Creating parent identity...", - ) - .await -} - pub(crate) async fn ensure_identity_exists_with_token_interactively( cert_server: &CertServer, target: &IdentityTarget, @@ -260,27 +241,22 @@ pub(crate) async fn ensure_identity_exists_with_token_interactively( pub(crate) async fn create_sub_identity_with_token( cert_server: &CertServer, - _target: &IdentityTarget, + target: &IdentityTarget, access_token: &str, parent: &dhttp::name::DhttpName<'_>, label: &str, ) -> Result { match super::progress::run_with_spinner( - "Creating sub-identity...", + create_identity_progress_message(), cert_server.create_subdomain(access_token, parent.as_full(), label, None), ) .await { Ok(created) => Ok(created), - Err(error) if is_domain_not_found(&error) => { - ensure_root_identity_exists_with_token(cert_server, parent, access_token).await?; - super::progress::run_with_spinner( - "Creating sub-identity...", - cert_server.create_subdomain(access_token, parent.as_full(), label, None), - ) - .await - .map_err(Error::from) - } + Err(error) if is_domain_not_found(&error) => Err(Error::with_source( + Box::new(error), + missing_parent_identity_message(target, parent.as_partial()), + )), Err(error) => Err(Error::from(error)), } } @@ -294,7 +270,7 @@ pub(crate) async fn create_sub_identity_with_token_interactively( ) -> Result { loop { match super::progress::run_with_spinner( - "Creating sub-identity...", + create_identity_progress_message(), cert_server.create_subdomain_attempt(access_token, parent.as_full(), label, None), ) .await @@ -313,7 +289,7 @@ pub(crate) async fn create_sub_identity_with_token_interactively( loop { let invoice_response = match super::progress::run_with_spinner( - "Creating sub-identity...", + create_identity_progress_message(), cert_server.create_subdomain_attempt( access_token, parent.as_full(), @@ -368,17 +344,35 @@ pub(crate) async fn create_sub_identity_with_token_interactively( } } Err(error) if is_domain_not_found(&error) => { - ensure_root_identity_exists_with_token_interactively( - cert_server, - parent, - access_token, - ) - .await?; - crate::cli::flow::transcript::print_line( - "Parent identity was created. Continuing with sub-identity creation...", - ); + return Err(Error::with_source( + Box::new(error), + missing_parent_identity_message(target, parent.as_partial()), + )); } Err(error) => return Err(Error::from(error)), } } } + +#[cfg(test)] +mod tests { + use super::{ + IdentityTarget, create_identity_progress_message, missing_parent_identity_message, + }; + + #[test] + fn root_and_sub_identity_creation_share_the_approved_progress_copy() { + assert_eq!(create_identity_progress_message(), "Creating identity..."); + } + + #[test] + fn missing_parent_does_not_offer_to_create_a_root_identity() { + let target = IdentityTarget::parse("phone.alice.smith").unwrap(); + let parent = target.parent().unwrap(); + + assert_eq!( + missing_parent_identity_message(&target, parent.as_partial()), + "Cannot register phone.alice.smith because its parent identity, alice.smith, does not exist." + ); + } +} diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 67b2233..9385080 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -2,7 +2,6 @@ use std::io::IsTerminal; use dhttp::home::{DhttpHome, HomeScope}; use snafu::{OptionExt, whatever}; -use tracing::Instrument; use super::local; use crate::{ @@ -17,51 +16,19 @@ enum RenewApprovalPlan { Identity { auth_domain: String }, } -#[derive(Debug, Clone, PartialEq, Eq)] -enum RenewEmailAction { - SwitchVerificationMethod, - ChangeIdentitySelection, -} - -impl RenewEmailAction { - fn label(&self) -> String { - match self { - Self::SwitchVerificationMethod => { - "Switch verification method (go back to verification method selection)".to_string() - } - Self::ChangeIdentitySelection => { - "Change identity (go back to identity selection)".to_string() - } - } - } -} - -fn renew_email_actions() -> Vec { - vec![ - RenewEmailAction::SwitchVerificationMethod, - RenewEmailAction::ChangeIdentitySelection, - ] -} - #[derive(Debug, Clone, PartialEq, Eq)] enum RenewVerifyCodeAction { ResendVerificationCode, ChangeEmail, - SwitchVerificationMethod, - ChangeIdentitySelection, + Cancel, } impl RenewVerifyCodeAction { fn label(&self) -> String { match self { Self::ResendVerificationCode => "Resend verification code".to_string(), - Self::ChangeEmail => "Send code to another email (go back to email)".to_string(), - Self::SwitchVerificationMethod => { - "Switch verification method (go back to verification method selection)".to_string() - } - Self::ChangeIdentitySelection => { - "Change identity (go back to identity selection)".to_string() - } + Self::ChangeEmail => "Change email".to_string(), + Self::Cancel => "Cancel".to_string(), } } } @@ -70,8 +37,7 @@ fn renew_verify_code_actions() -> Vec { vec![ RenewVerifyCodeAction::ResendVerificationCode, RenewVerifyCodeAction::ChangeEmail, - RenewVerifyCodeAction::SwitchVerificationMethod, - RenewVerifyCodeAction::ChangeIdentitySelection, + RenewVerifyCodeAction::Cancel, ] } @@ -97,28 +63,11 @@ impl InteractiveRenewState { } } - fn revisit_target_selection(&mut self) { - self.target = None; - self.approval_plan = None; - self.email = None; - self.email_prompt_required = true; - self.verify_code = None; - self.verification_code_sent_to = None; - } - fn revisit_email(&mut self) { self.email_prompt_required = true; self.verify_code = None; self.verification_code_sent_to = None; } - - fn revisit_verification_method(&mut self) { - self.approval_plan = None; - self.email = None; - self.email_prompt_required = true; - self.verify_code = None; - self.verification_code_sent_to = None; - } } fn apply_verification_recovery( @@ -149,7 +98,9 @@ async fn offer_expired_code_resend( email: &str, message: &str, ) -> Result<(), Error> { - crate::cli::flow::transcript::print_line(message); + crate::cli::flow::transcript::print_block(&crate::cli::flow::recovery::format_resend_offer( + message, + )); let resend = crate::cli::prompt::sync(|| { inquire::Confirm::new("Send a new verification code?") .with_default(true) @@ -170,9 +121,7 @@ async fn offer_expired_code_resend( } fn renew_not_saved_root_message(short_name: &str) -> String { - format!( - "The identity {short_name} is not saved here.\n\nRenew updates an identity already saved here.\nThis identity has not been applied here yet.\n\nApply {short_name} here first, then return to renew." - ) + format!("Failed to renew: {short_name} not found!") } async fn ensure_saved_renew_target( @@ -231,22 +180,6 @@ async fn resolve_email(command: &Renew) -> Result { } } -async fn prompt_renew_email_action() -> Result { - let actions = renew_email_actions(); - let labels = actions - .iter() - .map(RenewEmailAction::label) - .collect::>(); - let selected = crate::cli::prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected renew email action is unavailable") -} - async fn prompt_renew_verify_code_action() -> Result { let actions = renew_verify_code_actions(); let labels = actions @@ -303,25 +236,11 @@ async fn run_interactive( if matches!(approval_plan, RenewApprovalPlan::Email) && (state.email.is_none() || state.email_prompt_required) { - match crate::cli::prompt::prompt_email_with_more_options(state.email.as_deref()) + let email = crate::cli::prompt::prompt_email_with_default(state.email.as_deref()) .await - .require_interactive("--email")? - { - crate::cli::prompt::TextPromptResult::Submitted(email) => { - state.email = Some(email); - state.email_prompt_required = false; - } - crate::cli::prompt::TextPromptResult::MoreOptions => { - match prompt_renew_email_action().await? { - RenewEmailAction::SwitchVerificationMethod => { - state.revisit_verification_method(); - } - RenewEmailAction::ChangeIdentitySelection => { - state.revisit_target_selection(); - } - } - } - } + .require_interactive("--email")?; + state.email = Some(email); + state.email_prompt_required = false; continue; } @@ -385,11 +304,10 @@ async fn run_interactive( } } RenewVerifyCodeAction::ChangeEmail => state.revisit_email(), - RenewVerifyCodeAction::SwitchVerificationMethod => { - state.revisit_verification_method(); - } - RenewVerifyCodeAction::ChangeIdentitySelection => { - state.revisit_target_selection(); + RenewVerifyCodeAction::Cancel => { + whatever!( + "Renew was cancelled.\nNo local identity files were changed." + ); } } } @@ -481,7 +399,6 @@ async fn run_interactive( key_pem.as_bytes(), detail.cert_pem.as_bytes(), ) - .instrument(super::progress::save_identity_span()) .await?; return Ok(()); } @@ -590,8 +507,8 @@ mod tests { use dhttp::home::{DhttpHome, HomeScope}; use super::{ - InteractiveRenewState, RenewApprovalPlan, RenewEmailAction, RenewVerifyCodeAction, - renew_email_actions, renew_not_saved_root_message, renew_verify_code_actions, + InteractiveRenewState, RenewApprovalPlan, RenewVerifyCodeAction, + renew_not_saved_root_message, renew_verify_code_actions, resolve_non_interactive_approval_plan, }; use crate::{auth::AuthMethod, cli::Renew}; @@ -703,15 +620,10 @@ mod tests { } #[test] - fn renew_not_saved_root_message_mentions_apply_and_return() { + fn renew_not_saved_message_matches_the_edited_document() { assert_eq!( renew_not_saved_root_message("alice.ma"), - "The identity alice.ma is not saved here. - -Renew updates an identity already saved here. -This identity has not been applied here yet. - -Apply alice.ma here first, then return to renew." + "Failed to renew: alice.ma not found!" ); } @@ -733,31 +645,7 @@ Apply alice.ma here first, then return to renew." .unwrap_err(); let rendered = error.to_string(); - assert!( - rendered.contains("Apply alice.smith here first"), - "{rendered}" - ); - } - - #[test] - fn renew_email_actions_include_explicit_return_points() { - assert_eq!( - renew_email_actions() - .into_iter() - .map(|action| action.label()) - .collect::>(), - vec![ - "Switch verification method (go back to verification method selection)".to_string(), - "Change identity (go back to identity selection)".to_string(), - ] - ); - assert_eq!( - renew_email_actions(), - vec![ - RenewEmailAction::SwitchVerificationMethod, - RenewEmailAction::ChangeIdentitySelection, - ] - ); + assert_eq!(rendered, "Failed to renew: alice.smith not found!"); } #[test] @@ -769,9 +657,8 @@ Apply alice.ma here first, then return to renew." .collect::>(), vec![ "Resend verification code".to_string(), - "Send code to another email (go back to email)".to_string(), - "Switch verification method (go back to verification method selection)".to_string(), - "Change identity (go back to identity selection)".to_string(), + "Change email".to_string(), + "Cancel".to_string(), ] ); assert_eq!( @@ -779,8 +666,7 @@ Apply alice.ma here first, then return to renew." vec![ RenewVerifyCodeAction::ResendVerificationCode, RenewVerifyCodeAction::ChangeEmail, - RenewVerifyCodeAction::SwitchVerificationMethod, - RenewVerifyCodeAction::ChangeIdentitySelection, + RenewVerifyCodeAction::Cancel, ] ); } diff --git a/genmeta-identity/src/cli/flow/target.rs b/genmeta-identity/src/cli/flow/target.rs index 9acf91e..2d2df25 100644 --- a/genmeta-identity/src/cli/flow/target.rs +++ b/genmeta-identity/src/cli/flow/target.rs @@ -85,21 +85,30 @@ impl fmt::Display for IdentityTarget { #[derive(Debug, Snafu)] #[snafu(module)] pub enum ParseIdentityTargetError { - #[snafu(display("identity name is invalid"))] + #[snafu(display("This name contains unsupported special characters."))] Name { source: InvalidDhttpName }, - #[snafu(display("identity name {identity} has an invalid parent identity"))] + #[snafu(display("This name contains unsupported special characters."))] Parent { identity: String, source: InvalidDhttpName, }, - #[snafu(display( - "identity name {identity} is not supported; use . or .." - ))] + #[snafu(display("This name must match [handle.]your.name."))] UnsupportedDepth { identity: String }, } +impl ParseIdentityTargetError { + pub(crate) fn prompt_message(&self) -> &'static str { + match self { + Self::Name { .. } | Self::Parent { .. } => { + "This name contains unsupported special characters." + } + Self::UnsupportedDepth { .. } => "This name must match [handle.]your.name.", + } + } +} + #[cfg(test)] mod tests { use dhttp::name::DhttpName; @@ -128,11 +137,37 @@ mod tests { fn rejects_unsupported_identity_depths() { for input in ["alice", "one.two.three.four"] { let error = IdentityTarget::parse(input).unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains(input), "{rendered}"); + assert_eq!( + error.to_string(), + "This name must match [handle.]your.name." + ); } } + #[test] + fn direct_name_errors_use_the_same_copy_as_the_prompt() { + assert_eq!( + IdentityTarget::parse("alice!.smith") + .unwrap_err() + .to_string(), + "This name contains unsupported special characters." + ); + } + + #[test] + fn prompt_errors_use_the_approved_name_language() { + assert_eq!( + IdentityTarget::parse("alice").unwrap_err().prompt_message(), + "This name must match [handle.]your.name." + ); + assert_eq!( + IdentityTarget::parse("alice!.smith") + .unwrap_err() + .prompt_message(), + "This name contains unsupported special characters." + ); + } + #[test] fn extracts_sub_identity_label_from_first_label() { let identity = IdentityTarget::parse("phone.alice.smith").unwrap(); diff --git a/genmeta-identity/src/cli/prompt.rs b/genmeta-identity/src/cli/prompt.rs index 1d55797..e451ccd 100644 --- a/genmeta-identity/src/cli/prompt.rs +++ b/genmeta-identity/src/cli/prompt.rs @@ -100,13 +100,43 @@ macro_rules! sync { }; } +pub(crate) fn identity_name_prompt_message() -> &'static str { + "Enter your name:" +} + +pub(crate) fn identity_name_help_message() -> &'static str { + "For example:\n [handle.]your.name" +} + +pub(crate) fn email_prompt_message() -> &'static str { + "Enter your email:" +} + +pub(crate) fn verify_code_prompt_message() -> &'static str { + "Enter verification code:" +} + +pub(crate) fn more_options_help_message() -> &'static str { + "Type ? for more options." +} + pub(crate) async fn prompt_email() -> Result { - sync!( - inquire::Text::new("Enter your email:") + prompt_email_with_default(None).await +} + +pub(crate) async fn prompt_email_with_default( + default: Option<&str>, +) -> Result { + let default = default.map(ToOwned::to_owned); + sync!({ + let mut prompt = inquire::Text::new(email_prompt_message()) .with_validator(inquire::required!("Email address cannot be empty.")) - .with_validator(validator::EmailValidator) - .prompt() - ) + .with_validator(validator::EmailValidator); + if let Some(default) = default.as_deref() { + prompt = prompt.with_default(default); + } + prompt.prompt() + }) } pub(crate) async fn prompt_identity_name( @@ -124,13 +154,16 @@ pub(crate) async fn prompt_identity_name_with_default( } let default = default.map(ToOwned::to_owned); sync!({ - let mut prompt = inquire::Text::new("Enter your name:") + let mut prompt = inquire::Text::new(identity_name_prompt_message()) + .with_help_message(identity_name_help_message()) .with_validator(inquire::required!("Identity name cannot be empty.")) .with_validator(|value: &str| { match crate::cli::flow::target::IdentityTarget::parse(value) { Ok(_) => Ok(inquire::validator::Validation::Valid), Err(error) => Ok(inquire::validator::Validation::Invalid( - inquire::validator::ErrorMessage::Custom(error.to_string()), + inquire::validator::ErrorMessage::Custom( + error.prompt_message().to_string(), + ), )), } }); @@ -150,7 +183,7 @@ pub(crate) async fn prompt_select_string( pub(crate) async fn prompt_verify_code() -> Result { sync!( - inquire::Text::new("Enter the verification code sent to your email:") + inquire::Text::new(verify_code_prompt_message()) .with_validator(inquire::required!("Verification code cannot be empty.")) .with_validator(inquire::length!( 6, @@ -160,35 +193,35 @@ pub(crate) async fn prompt_verify_code() -> Result Result { +pub(crate) async fn prompt_kind() -> Result { prompt_kind_with_cursor(None).await } pub(crate) async fn prompt_kind_with_cursor( selected_kind: Option, -) -> Result { - let prompt = format!( - "{}\n\n{}\n\n{}", - IdentityKind::SELECT_PROMPT, - IdentityKind::PRIMARY_HELP, - IdentityKind::SECONDARY_HELP - ); +) -> Result { let starting_cursor = match selected_kind { Some(IdentityKind::Primary) => Some(0), Some(IdentityKind::Secondary) => Some(1), None => None, }; - sync!( + let selected = sync!( inquire::Select::new( - &prompt, + IdentityKind::SELECT_PROMPT, vec![ - IdentityKind::Primary.to_string(), - IdentityKind::Secondary.to_string() + IdentityKind::Primary.usage_label(), + IdentityKind::Secondary.usage_label() ] ) + .with_help_message(IdentityKind::USAGE_HELP) .with_starting_cursor(starting_cursor.unwrap_or(0)) .prompt() - ) + )?; + Ok(match selected { + "both client and server" => IdentityKind::Primary, + "client only" => IdentityKind::Secondary, + _ => unreachable!("inquire returned an option that was not provided"), + }) } fn text_prompt_result(answer: String) -> TextPromptResult { @@ -199,32 +232,13 @@ fn text_prompt_result(answer: String) -> TextPromptResult { } } -pub(crate) async fn prompt_email_with_more_options( - default: Option<&str>, -) -> Result { - let default = default.map(ToOwned::to_owned); - sync!({ - let mut prompt = inquire::Text::new("Email:") - .with_help_message("Type ? for more options.") - .with_validator(MoreOptionsFriendlyValidator::new(inquire::required!( - "Email address cannot be empty." - ))) - .with_validator(MoreOptionsFriendlyValidator::new(validator::EmailValidator)); - if let Some(default) = default.as_deref() { - prompt = prompt.with_default(default); - } - prompt.prompt() - }) - .map(text_prompt_result) -} - pub(crate) async fn prompt_verify_code_with_more_options( default: Option<&str>, ) -> Result { let default = default.map(ToOwned::to_owned); sync!({ - let mut prompt = inquire::Text::new("Verification code:") - .with_help_message("Type ? for more options.") + let mut prompt = inquire::Text::new(verify_code_prompt_message()) + .with_help_message(more_options_help_message()) .with_validator(MoreOptionsFriendlyValidator::new(inquire::required!( "Verification code cannot be empty." ))) @@ -259,7 +273,22 @@ pub(crate) async fn prompt_select_string_with_cursor( mod tests { use inquire::validator::{StringValidator, Validation}; - use super::MoreOptionsFriendlyValidator; + use super::{ + MoreOptionsFriendlyValidator, email_prompt_message, identity_name_help_message, + identity_name_prompt_message, more_options_help_message, verify_code_prompt_message, + }; + + #[test] + fn prompt_copy_matches_the_approved_document() { + assert_eq!(identity_name_prompt_message(), "Enter your name:"); + assert_eq!( + identity_name_help_message(), + "For example:\n [handle.]your.name" + ); + assert_eq!(email_prompt_message(), "Enter your email:"); + assert_eq!(verify_code_prompt_message(), "Enter verification code:"); + assert_eq!(more_options_help_message(), "Type ? for more options."); + } #[test] fn question_mark_bypasses_inner_validation() { diff --git a/genmeta-identity/src/cli/validator.rs b/genmeta-identity/src/cli/validator.rs index aeec627..2639940 100644 --- a/genmeta-identity/src/cli/validator.rs +++ b/genmeta-identity/src/cli/validator.rs @@ -51,7 +51,9 @@ impl StringValidator for EmailValidator { if input.contains('@') && input.contains('.') { Ok(Validation::Valid) } else { - Ok(validation_failed("Invalid email format.")) + Ok(validation_failed( + "Invalid email address. Please enter a valid email address.", + )) } } } @@ -78,4 +80,14 @@ mod tests { assert_eq!(validate_kind("secondary"), Ok(())); assert!(validate_kind("device").is_err()); } + + #[test] + fn invalid_email_uses_the_approved_actionable_copy() { + assert_eq!( + EmailValidator.validate("not-an-email").unwrap(), + Validation::Invalid(inquire::validator::ErrorMessage::from( + "Invalid email address. Please enter a valid email address." + )) + ); + } } From 1a262b50b430928eeaa6401bd04fb1cdba0c4993 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 13 Jul 2026 17:36:48 +0800 Subject: [PATCH 05/27] fix(identity): make apply registration implicit --- CHANGELOG.md | 3 + genmeta-identity/src/cert_server.rs | 34 ++++ genmeta-identity/src/cli.rs | 32 ++-- genmeta-identity/src/cli/flow/apply.rs | 230 ++++++++++++++++--------- 4 files changed, 202 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcdcf98..dcbb3f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `genmeta identity apply [name]` is now the single create-or-update flow; the former `identity create` command has been removed. +- Missing eligible sub-identities now enter registration naturally during + `identity apply`, without a separate registration flag. Names entered by an + interactive prompt receive an early certserver availability check. - Bare `genmeta identity renew` targets the configured default identity, and renew no longer accepts `--default`. - Identity authentication skips expired, incomplete, and invalid local diff --git a/genmeta-identity/src/cert_server.rs b/genmeta-identity/src/cert_server.rs index 91eafc7..98c2150 100644 --- a/genmeta-identity/src/cert_server.rs +++ b/genmeta-identity/src/cert_server.rs @@ -148,6 +148,12 @@ pub struct DomainLoginResponse { pub token_expires_at: i64, } +#[derive(Debug, Clone, Deserialize)] +pub struct DomainAvailabilityResponse { + pub domain: String, + pub availability: String, +} + #[derive(Debug, Clone, Deserialize)] pub struct CreateDomainResponse { pub domain: String, @@ -438,6 +444,19 @@ impl CertServer { parse_response(response).await } + pub async fn inspect_domain_availability( + &self, + domain: &str, + ) -> Result { + let response = self + .http_client + .get(format!("{}/v2/pricing", self.base_url)) + .query(&[("domain", domain)]) + .send() + .await?; + parse_response(response).await + } + pub async fn login(&self, email: &str, verify_code: &str) -> Result { let response = self .http_client @@ -831,6 +850,21 @@ mod tests { assert_eq!(error.to_string(), "domain access is forbidden"); } + #[test] + fn domain_availability_response_reads_pricing_envelope() { + let payload = r#" + { + "domain":"alice.smith.dhttp.net", + "availability":"conflict", + "currency":"USD", + "prices":[] + } + "#; + let response: DomainAvailabilityResponse = serde_json::from_str(payload).unwrap(); + assert_eq!(response.domain, "alice.smith.dhttp.net"); + assert_eq!(response.availability, "conflict"); + } + #[test] fn create_domain_response_accepts_payment_payload() { let payload = r#" diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index cf79ef7..44bc780 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -328,9 +328,6 @@ pub struct Apply { pub name: Option, #[arg(long)] pub kind: Option, - /// Register a missing sub-identity before applying it. - #[arg(long)] - pub register_if_missing: bool, #[arg(long)] pub replace_local: bool, #[arg(long)] @@ -698,23 +695,20 @@ mod tests { } #[test] - fn apply_accepts_register_if_missing() { + fn apply_does_not_expose_register_if_missing() { let help = Apply::command().render_long_help().to_string(); - assert!( - help.contains("Register a missing sub-identity before applying it"), - "{help}" - ); - assert!( - Options::try_parse_from([ - "genmeta", - "apply", - "phone.alice.smith", - "--kind", - "primary", - "--register-if-missing", - ]) - .is_ok() - ); + assert!(!help.contains("register-if-missing"), "{help}"); + + let error = Options::try_parse_from([ + "genmeta", + "apply", + "phone.alice.smith", + "--kind", + "primary", + "--register-if-missing", + ]) + .unwrap_err(); + assert!(error.to_string().contains("unexpected argument")); } #[test] diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 2a683e5..15d2d69 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -38,12 +38,8 @@ enum MissingTargetAction { fn missing_target_action( target: &IdentityTarget, - register_if_missing: bool, private_test_continuation: bool, ) -> MissingTargetAction { - if !register_if_missing { - return MissingTargetAction::Reject; - } match target.level() { IdentityLevel::SubIdentity => MissingTargetAction::Register, IdentityLevel::Identity if private_test_continuation => MissingTargetAction::Register, @@ -55,18 +51,12 @@ fn private_test_root_registration(command: &Apply) -> bool { command.verify_code.is_some() && matches!(command.auth, Some(AuthMethod::Email)) } -fn missing_target_error(target: &IdentityTarget) -> Error { - let message = match target.level() { - IdentityLevel::Identity => format!( - "{} does not exist yet. Apply can register a missing sub-identity, but not a new root identity.", - target.short_name() - ), - IdentityLevel::SubIdentity => format!( - "{} does not exist yet. To register this sub-identity before applying it, rerun with --register-if-missing.", - target.short_name() - ), - }; - Error::without_source(message) +fn missing_root_target_error(target: &IdentityTarget) -> Error { + debug_assert_eq!(target.level(), IdentityLevel::Identity); + Error::without_source(format!( + "{} does not exist yet. Apply can register a missing sub-identity, but not a new root identity.", + target.short_name() + )) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -258,6 +248,36 @@ fn apply_identity_name_opening() -> &'static str { "Apply an identity here." } +fn interactive_name_check_progress_message() -> &'static str { + "Checking the validity of this name..." +} + +fn interactive_name_unavailable_message() -> &'static str { + "Sorry, this name is not available. Please try another one." +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InteractiveNameAvailability { + Applicable, + Retry, +} + +fn classify_interactive_name_availability( + target: &IdentityTarget, + availability: &str, +) -> Result { + match availability { + "conflict" => Ok(InteractiveNameAvailability::Applicable), + "available" if target.level() == IdentityLevel::SubIdentity => { + Ok(InteractiveNameAvailability::Applicable) + } + "available" | "reserved" | "unavailable" => Ok(InteractiveNameAvailability::Retry), + other => Err(Error::without_source(format!( + "cert server returned unsupported domain availability: {other}" + ))), + } +} + fn explicit_target_from_command( command: &Apply, ) -> Result>, Error> { @@ -268,17 +288,63 @@ fn explicit_target_from_command( .transpose() } -async fn prompt_apply_target() -> Result, Error> { - let identity = crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) +async fn prompt_apply_target_with_opening( + opening: &'static str, +) -> Result, Error> { + let identity = crate::cli::prompt::prompt_identity_name(opening) .await .require_interactive("IDENTITY")?; cli::parse_identity_name(&identity) } -async fn resolve_target(command: &Apply) -> Result, Error> { +async fn prompt_apply_target() -> Result, Error> { + prompt_apply_target_with_opening(apply_identity_name_opening()).await +} + +async fn prompt_apply_target_with_online_validation( + cert_server: &CertServer, +) -> Result, Error> { + let mut opening = apply_identity_name_opening(); + loop { + let name = prompt_apply_target_with_opening(opening).await?; + opening = ""; + let target = IdentityTarget::parse(name.as_partial())?; + let response = match super::progress::run_with_spinner( + interactive_name_check_progress_message(), + cert_server.inspect_domain_availability(name.as_full()), + ) + .await + { + Ok(response) => response, + Err(error) if error.is_api_code("domain_invalid") => { + crate::cli::flow::transcript::print_err_block( + interactive_name_unavailable_message(), + ); + continue; + } + Err(error) => return Err(error.into()), + }; + match classify_interactive_name_availability(&target, &response.availability)? { + InteractiveNameAvailability::Applicable => return Ok(name), + InteractiveNameAvailability::Retry => { + crate::cli::flow::transcript::print_err_block( + interactive_name_unavailable_message(), + ); + } + } + } +} + +async fn resolve_target( + command: &Apply, + interactive_cert_server: Option<&CertServer>, +) -> Result, Error> { match explicit_target_from_command(command)? { Some(name) => Ok(name), - None => prompt_apply_target().await, + None => match interactive_cert_server { + Some(cert_server) => prompt_apply_target_with_online_validation(cert_server).await, + None => prompt_apply_target().await, + }, } } @@ -441,7 +507,7 @@ async fn run_interactive_with_policy( loop { if state.target.is_none() { - state.target = Some(prompt_apply_target().await?); + state.target = Some(prompt_apply_target_with_online_validation(cert_server).await?); continue; } @@ -617,13 +683,10 @@ async fn run_interactive_with_policy( { Ok(detail) => detail, Err(error) if is_domain_not_found(&error) => { - if missing_target_action( - &target, - command.register_if_missing, - private_test_root_registration(command), - ) == MissingTargetAction::Reject + if missing_target_action(&target, private_test_root_registration(command)) + == MissingTargetAction::Reject { - return Err(missing_target_error(&target)); + return Err(missing_root_target_error(&target)); } if let Err(error) = ensure_identity_exists_after_apply_login( &target, @@ -707,13 +770,10 @@ async fn run_interactive_with_policy( { Ok(detail) => detail, Err(error) if is_domain_not_found(&error) => { - if missing_target_action( - &target, - command.register_if_missing, - private_test_root_registration(command), - ) == MissingTargetAction::Reject + if missing_target_action(&target, private_test_root_registration(command)) + == MissingTargetAction::Reject { - return Err(missing_target_error(&target)); + return Err(missing_root_target_error(&target)); } if target.level() != IdentityLevel::SubIdentity { crate::cli::flow::transcript::print_block(&format!( @@ -822,7 +882,7 @@ pub(crate) async fn run_with_policy( let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? .and_then(|config| config.settings().default_identity_name().cloned()); - let domain = resolve_target(command).await?; + let domain = resolve_target(command, is_interactive.then_some(cert_server)).await?; let target = IdentityTarget::parse(domain.as_partial())?; let kind = resolve_kind(command).await?; let device_name = @@ -891,13 +951,10 @@ pub(crate) async fn run_with_policy( { Ok(detail) => detail, Err(error) if is_domain_not_found(&error) => { - if missing_target_action( - &target, - command.register_if_missing, - private_test_root_registration(command), - ) == MissingTargetAction::Reject + if missing_target_action(&target, private_test_root_registration(command)) + == MissingTargetAction::Reject { - return Err(missing_target_error(&target)); + return Err(missing_root_target_error(&target)); } ensure_identity_exists_after_apply_login( &target, @@ -941,13 +998,10 @@ pub(crate) async fn run_with_policy( { Ok(detail) => detail, Err(error) if is_domain_not_found(&error) => { - if missing_target_action( - &target, - command.register_if_missing, - private_test_root_registration(command), - ) == MissingTargetAction::Reject + if missing_target_action(&target, private_test_root_registration(command)) + == MissingTargetAction::Reject { - return Err(missing_target_error(&target)); + return Err(missing_root_target_error(&target)); } if target.level() != IdentityLevel::SubIdentity { whatever!( @@ -1019,11 +1073,14 @@ pub(crate) async fn run( #[cfg(test)] mod tests { use super::{ - ApplyApprovalPlan, ApplyVerifyCodeAction, InteractiveApplyState, MissingTargetAction, - apply_identity_name_opening, apply_verify_code_actions, classify_apply_email_issue_error, - explicit_target_from_command, missing_target_action, missing_target_error, - new_identity_confirmation_message, preserve_apply_email_issue_error, - preserve_apply_registration_error, resolve_non_interactive_approval_plan, + ApplyApprovalPlan, ApplyVerifyCodeAction, InteractiveApplyState, + InteractiveNameAvailability, MissingTargetAction, apply_identity_name_opening, + apply_verify_code_actions, classify_apply_email_issue_error, + classify_interactive_name_availability, explicit_target_from_command, + interactive_name_check_progress_message, interactive_name_unavailable_message, + missing_root_target_error, missing_target_action, new_identity_confirmation_message, + preserve_apply_email_issue_error, preserve_apply_registration_error, + resolve_non_interactive_approval_plan, }; use crate::{ auth::AuthMethod, @@ -1036,7 +1093,6 @@ mod tests { &Apply { name: Some("alice.smith".to_string()), kind: Some("primary".to_string()), - register_if_missing: false, replace_local: false, device_name: None, email: Some("alice@example.test".to_string()), @@ -1065,7 +1121,6 @@ mod tests { &Apply { name: Some("alice.smith".to_string()), kind: Some("primary".to_string()), - register_if_missing: false, replace_local: false, device_name: None, email: Some("alice@example.test".to_string()), @@ -1125,7 +1180,6 @@ mod tests { let target = explicit_target_from_command(&Apply { name: None, kind: None, - register_if_missing: false, replace_local: false, device_name: None, email: None, @@ -1202,28 +1256,59 @@ mod tests { } #[test] - fn newly_registered_identity_uses_the_approved_confirmation() { + fn interactive_name_availability_respects_target_level() { + let root = IdentityTarget::parse("alice.smith").unwrap(); + let child = IdentityTarget::parse("phone.alice.smith").unwrap(); + assert_eq!( - new_identity_confirmation_message(), - "This new name is yours now." + classify_interactive_name_availability(&root, "conflict").unwrap(), + InteractiveNameAvailability::Applicable + ); + assert_eq!( + classify_interactive_name_availability(&child, "available").unwrap(), + InteractiveNameAvailability::Applicable + ); + assert_eq!( + classify_interactive_name_availability(&root, "available").unwrap(), + InteractiveNameAvailability::Retry + ); + assert_eq!( + classify_interactive_name_availability(&child, "reserved").unwrap(), + InteractiveNameAvailability::Retry + ); + assert_eq!( + classify_interactive_name_availability(&child, "unavailable").unwrap(), + InteractiveNameAvailability::Retry ); + assert!(classify_interactive_name_availability(&child, "future-status").is_err()); } #[test] - fn explicit_registration_flag_allows_registration() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); + fn interactive_name_check_copy_matches_spec() { assert_eq!( - missing_target_action(&target, true, false), - MissingTargetAction::Register + interactive_name_check_progress_message(), + "Checking the validity of this name..." + ); + assert_eq!( + interactive_name_unavailable_message(), + "Sorry, this name is not available. Please try another one." ); } #[test] - fn missing_target_requires_the_explicit_flag_in_every_mode() { + fn newly_registered_identity_uses_the_approved_confirmation() { + assert_eq!( + new_identity_confirmation_message(), + "This new name is yours now." + ); + } + + #[test] + fn missing_sub_identity_registration_is_implicit() { let target = IdentityTarget::parse("phone.alice.smith").unwrap(); assert_eq!( - missing_target_action(&target, false, false), - MissingTargetAction::Reject + missing_target_action(&target, false), + MissingTargetAction::Register ); } @@ -1232,32 +1317,21 @@ mod tests { let target = IdentityTarget::parse("alice.smith").unwrap(); assert_eq!( - missing_target_action(&target, true, false), + missing_target_action(&target, false), MissingTargetAction::Reject ); assert_eq!( - missing_target_action(&target, true, true), + missing_target_action(&target, true), MissingTargetAction::Register ); } - #[test] - fn non_interactive_missing_target_error_names_explicit_flag() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - let rendered = missing_target_error(&target).to_string(); - - assert_eq!( - rendered, - "phone.alice.smith does not exist yet. To register this sub-identity before applying it, rerun with --register-if-missing." - ); - } - #[test] fn missing_root_error_does_not_advertise_private_root_registration() { let target = IdentityTarget::parse("alice.smith").unwrap(); assert_eq!( - missing_target_error(&target).to_string(), + missing_root_target_error(&target).to_string(), "alice.smith does not exist yet. Apply can register a missing sub-identity, but not a new root identity." ); } From ab63452f6f84e9b489080ad91ef900054e5679e2 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 13 Jul 2026 17:52:22 +0800 Subject: [PATCH 06/27] fix(identity): keep recovery at safe boundaries --- CHANGELOG.md | 9 ++- genmeta-identity/src/auth.rs | 34 +++++++++- genmeta-identity/src/cli/flow/recovery.rs | 83 +++++++++++++++++++---- 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcbb3f8..545459b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 interactive prompt receive an early certserver availability check. - Bare `genmeta identity renew` targets the configured default identity, and renew no longer accepts `--default`. -- Identity authentication skips expired, incomplete, and invalid local - certificates with an actionable warning, then tries the direct parent when - available before falling back to email verification. +- Identity authentication selects the first usable proof from the target, + direct parent, and email sequence. It skips expired, incomplete, and invalid + local certificates with an actionable warning, while transport and server + failures remain terminal instead of changing proof. +- Verification-code recovery keeps attempt limits at the code prompt without + resending automatically, and treats blocked accounts as terminal. - Compact identity output uses `(default)` and retains abnormal states. Detail output keeps the chain in the summary, then shows `Issuer`, `Valid from`, `Expires`/`Expired`, `dir`, and an invalid/incomplete reason when available. diff --git a/genmeta-identity/src/auth.rs b/genmeta-identity/src/auth.rs index 773d361..668bb38 100644 --- a/genmeta-identity/src/auth.rs +++ b/genmeta-identity/src/auth.rs @@ -21,6 +21,11 @@ pub enum AuthFailureKind { ServerError, } +/// Returns whether an automatic flow may use email after a stable +/// authentication failure. +/// +/// The caller must already have established that the server permits email as +/// an alternate proof. Transport and server failures never change proof. pub fn should_fallback_to_email( method: Option, can_get_email_credentials: bool, @@ -35,7 +40,6 @@ pub fn is_email_fallback_failure(failure: AuthFailureKind) -> bool { AuthFailureKind::MissingIdentity | AuthFailureKind::MtlsRejected | AuthFailureKind::DomainForbidden - | AuthFailureKind::TransportUnavailable ) } @@ -68,7 +72,7 @@ mod tests { use super::*; #[test] - fn auto_interactive_falls_back_for_auth_only_failures() { + fn auto_interactive_falls_back_for_credential_failures() { assert!(should_fallback_to_email( None, true, @@ -84,7 +88,11 @@ mod tests { true, AuthFailureKind::DomainForbidden )); - assert!(should_fallback_to_email( + } + + #[test] + fn transport_unavailability_does_not_fallback() { + assert!(!should_fallback_to_email( None, true, AuthFailureKind::TransportUnavailable @@ -144,4 +152,24 @@ mod tests { classify_api_error(&error) )); } + + #[test] + fn classifier_keeps_server_and_unknown_client_errors_terminal() { + for error in [ + crate::cert_server::Error::Api { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + code: "internal_error".to_string(), + message: "internal error".to_string(), + }, + crate::cert_server::Error::Api { + status: reqwest::StatusCode::CONFLICT, + code: "future_conflict".to_string(), + message: "future conflict".to_string(), + }, + ] { + let failure = classify_api_error(&error); + assert_eq!(failure, AuthFailureKind::ServerError); + assert!(!should_fallback_to_email(None, true, failure)); + } + } } diff --git a/genmeta-identity/src/cli/flow/recovery.rs b/genmeta-identity/src/cli/flow/recovery.rs index a6ec1c0..e8b4e21 100644 --- a/genmeta-identity/src/cli/flow/recovery.rs +++ b/genmeta-identity/src/cli/flow/recovery.rs @@ -18,7 +18,12 @@ pub(crate) fn classify_resend_error(error: &crate::cert_server::Error) -> Verifi message, } if *status == reqwest::StatusCode::TOO_MANY_REQUESTS - && code == "verify_code_too_frequent" => + && matches!( + code.as_str(), + "verify_code_too_frequent" + | "verify_code_attempt_exceeded" + | "verify_code_rate_limited" + ) => { VerificationRecovery::StayCurrentStep { message: message.clone(), @@ -87,6 +92,22 @@ pub(crate) fn classify_verify_submit_error( message: message.clone(), } } + crate::cert_server::Error::Api { + status, + code, + message, + } if *status == reqwest::StatusCode::TOO_MANY_REQUESTS + && code == "verify_code_attempt_exceeded" => + { + VerificationRecovery::StayCurrentStep { + message: message.clone(), + } + } + crate::cert_server::Error::Api { status, code, .. } + if *status == reqwest::StatusCode::FORBIDDEN && code == "user_blocked" => + { + VerificationRecovery::Abort + } crate::cert_server::Error::Api { status, .. } if status.is_server_error() => { VerificationRecovery::Abort } @@ -115,19 +136,25 @@ mod tests { } #[test] - fn resend_rate_limit_stays_on_current_step() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::TOO_MANY_REQUESTS, - code: "verify_code_too_frequent".to_string(), - message: "email code sent too frequently".to_string(), - }; + fn resend_rate_limits_stay_on_current_step() { + for code in [ + "verify_code_too_frequent", + "verify_code_attempt_exceeded", + "verify_code_rate_limited", + ] { + let error = crate::cert_server::Error::Api { + status: reqwest::StatusCode::TOO_MANY_REQUESTS, + code: code.to_string(), + message: "email verification is temporarily rate limited".to_string(), + }; - assert_eq!( - classify_resend_error(&error), - VerificationRecovery::StayCurrentStep { - message: "email code sent too frequently".to_string(), - } - ); + assert_eq!( + classify_resend_error(&error), + VerificationRecovery::StayCurrentStep { + message: "email verification is temporarily rate limited".to_string(), + } + ); + } } #[test] @@ -160,6 +187,36 @@ mod tests { ); } + #[test] + fn blocked_user_aborts_verification() { + let error = crate::cert_server::Error::Api { + status: reqwest::StatusCode::FORBIDDEN, + code: "user_blocked".to_string(), + message: "user is blocked".to_string(), + }; + + assert_eq!( + classify_verify_submit_error(&error), + VerificationRecovery::Abort + ); + } + + #[test] + fn verification_attempt_limit_stays_at_code_input() { + let error = crate::cert_server::Error::Api { + status: reqwest::StatusCode::TOO_MANY_REQUESTS, + code: "verify_code_attempt_exceeded".to_string(), + message: "too many failed verification attempts, try again later".to_string(), + }; + + assert_eq!( + classify_verify_submit_error(&error), + VerificationRecovery::StayCurrentStep { + message: "too many failed verification attempts, try again later".to_string(), + } + ); + } + #[test] fn expired_code_offers_resend_with_server_message() { let error = crate::cert_server::Error::Api { From 56831bd81ad6803c59ec2d2cd5a39587926bba8d Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 00:29:24 +0800 Subject: [PATCH 07/27] feat(identity): finalize command and progress contract --- genmeta-identity/src/auth.rs | 45 +--- genmeta-identity/src/cli.rs | 231 ++++++------------ genmeta-identity/src/cli/flow/apply.rs | 198 ++++----------- .../src/cli/flow/default_identity.rs | 4 +- genmeta-identity/src/cli/flow/progress.rs | 111 +++++++-- genmeta-identity/src/cli/flow/renew.rs | 131 +++------- genmeta-identity/src/cli/prompt.rs | 6 +- 7 files changed, 262 insertions(+), 464 deletions(-) diff --git a/genmeta-identity/src/auth.rs b/genmeta-identity/src/auth.rs index 668bb38..80f7d48 100644 --- a/genmeta-identity/src/auth.rs +++ b/genmeta-identity/src/auth.rs @@ -1,11 +1,3 @@ -use clap::ValueEnum; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum AuthMethod { - Identity, - Email, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthFailureKind { MissingIdentity, @@ -26,12 +18,8 @@ pub enum AuthFailureKind { /// /// The caller must already have established that the server permits email as /// an alternate proof. Transport and server failures never change proof. -pub fn should_fallback_to_email( - method: Option, - can_get_email_credentials: bool, - failure: AuthFailureKind, -) -> bool { - method.is_none() && can_get_email_credentials && is_email_fallback_failure(failure) +pub fn should_fallback_to_email(can_get_email_credentials: bool, failure: AuthFailureKind) -> bool { + can_get_email_credentials && is_email_fallback_failure(failure) } pub fn is_email_fallback_failure(failure: AuthFailureKind) -> bool { @@ -74,17 +62,14 @@ mod tests { #[test] fn auto_interactive_falls_back_for_credential_failures() { assert!(should_fallback_to_email( - None, true, AuthFailureKind::MissingIdentity )); assert!(should_fallback_to_email( - None, true, AuthFailureKind::MtlsRejected )); assert!(should_fallback_to_email( - None, true, AuthFailureKind::DomainForbidden )); @@ -93,7 +78,6 @@ mod tests { #[test] fn transport_unavailability_does_not_fallback() { assert!(!should_fallback_to_email( - None, true, AuthFailureKind::TransportUnavailable )); @@ -110,28 +94,13 @@ mod tests { AuthFailureKind::PaymentRequired, AuthFailureKind::ServerError, ] { - assert!(!should_fallback_to_email(None, true, failure)); + assert!(!should_fallback_to_email(true, failure)); } } - #[test] - fn identity_and_email_policy_never_auto_fallback() { - assert!(!should_fallback_to_email( - Some(AuthMethod::Identity), - true, - AuthFailureKind::MissingIdentity - )); - assert!(!should_fallback_to_email( - Some(AuthMethod::Email), - true, - AuthFailureKind::MissingIdentity - )); - } - #[test] fn non_interactive_auto_does_not_prompt_fallback() { assert!(!should_fallback_to_email( - None, false, AuthFailureKind::MissingIdentity )); @@ -146,11 +115,7 @@ mod tests { }; assert_eq!(classify_api_error(&error), AuthFailureKind::ChainNotFound); - assert!(!should_fallback_to_email( - None, - true, - classify_api_error(&error) - )); + assert!(!should_fallback_to_email(true, classify_api_error(&error))); } #[test] @@ -169,7 +134,7 @@ mod tests { ] { let failure = classify_api_error(&error); assert_eq!(failure, AuthFailureKind::ServerError); - assert!(!should_fallback_to_email(None, true, failure)); + assert!(!should_fallback_to_email(true, failure)); } } } diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 44bc780..dceb9ec 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -126,15 +126,11 @@ fn certificate_chain_key_from_identity( fn generate_private_key_and_csr( name: &Name<'_>, ) -> Result<(impl Deref + use<>, String), Error> { - let key_pem = flow::progress::run_with_retained_progress( - "Generating secp384r1 ECC key pair locally...", - "Generated secp384r1 ECC key pair locally.", - || { - rankey::generate_secp384r1_key() - .map_err(|e| Box::new(e) as Box) - .context(GenerateKeySnafu) - }, - )?; + let key_pem = flow::progress::run_sync(flow::progress::GENERATE_KEY, || { + rankey::generate_secp384r1_key() + .map_err(|e| Box::new(e) as Box) + .context(GenerateKeySnafu) + })?; let csr = rankey::generate_csr(&key_pem, "CN", name.as_full(), &[name.as_full()]) .map_err(|e| Box::new(e) as Box) .context(GenerateCsrSnafu)?; @@ -164,10 +160,6 @@ fn save_identity_progress_message() -> &'static str { "Saving identity..." } -fn save_default_identity_progress_message() -> &'static str { - "Saving default identity..." -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LocalIdentitySave { New, @@ -224,11 +216,7 @@ async fn save_settings(default_config: &DhttpSettingsFile) -> Result<(), Error> })?; } - flow::progress::run_with_spinner( - save_default_identity_progress_message(), - default_config.save(), - ) - .await?; + flow::progress::run(flow::progress::SAVE_DEFAULT, default_config.save()).await?; Ok(()) } @@ -247,20 +235,16 @@ async fn resolve_default_target_name(dhttp_home: &DhttpHome) -> Result, - replace_local: bool, + force: bool, ) -> Result { let profile_exists = dhttp_home .identity_profile_exists_exactly(name.clone()) .await; - match local_replacement_decision( - profile_exists, - replace_local, - std::io::stdin().is_terminal(), - ) { + match local_replacement_decision(profile_exists, force, std::io::stdin().is_terminal()) { LocalReplacementDecision::New => Ok(LocalIdentitySave::New), LocalReplacementDecision::Replace => Ok(LocalIdentitySave::Replace), LocalReplacementDecision::RequireFlag => Err(prompt::Error::NotInteractive { - hint: "--replace-local".into(), + hint: "--force".into(), } .into()), } @@ -274,8 +258,8 @@ async fn acquire_verify_code( match flow::email::EmailVerificationAction::from_verify_code(provided) { flow::email::EmailVerificationAction::ReuseProvidedCode(code) => Ok(code), flow::email::EmailVerificationAction::SendAndPrompt => { - flow::progress::run_with_spinner( - "Sending verification code...", + flow::progress::run( + flow::progress::SEND_CODE, cert_server.send_email_verification(email), ) .await?; @@ -305,15 +289,15 @@ async fn login_with_email( }; let verify_code = acquire_verify_code(cert_server, &email, verify_code).await?; if let Some(domain) = domain { - Ok(flow::progress::run_with_spinner( - "Verifying with email...", + Ok(flow::progress::run( + flow::progress::VERIFY_EMAIL, cert_server.domain_login(domain.as_full(), &email, &verify_code), ) .await? .access_token) } else { - Ok(flow::progress::run_with_spinner( - "Verifying with email...", + Ok(flow::progress::run( + flow::progress::VERIFY_EMAIL, cert_server.login(&email, &verify_code), ) .await? @@ -328,18 +312,14 @@ pub struct Apply { pub name: Option, #[arg(long)] pub kind: Option, - #[arg(long)] - pub replace_local: bool, + #[arg(short, long)] + pub force: bool, #[arg(long)] pub device_name: Option, #[arg(short, long)] pub email: Option, - #[arg(long, conflicts_with = "verify_code")] - pub send_code: bool, #[arg(long, value_name = "VERIFY_CODE", hide = true)] pub verify_code: Option, - #[arg(long, value_enum)] - pub auth: Option, } /// Renew identities @@ -347,16 +327,14 @@ pub struct Apply { pub struct Renew { #[arg(value_name = "IDENTITY")] pub name: Option, + #[arg(short, long)] + pub force: bool, #[arg(long)] pub device_name: Option, #[arg(short, long)] pub email: Option, - #[arg(long, conflicts_with = "verify_code")] - pub send_code: bool, #[arg(long, value_name = "VERIFY_CODE", hide = true)] pub verify_code: Option, - #[arg(long, value_enum)] - pub auth: Option, } /// Set default identity @@ -366,8 +344,8 @@ pub struct Default { pub name: Option, #[arg(short, long)] pub verbose: bool, - #[arg(long)] - pub allow_nonready: bool, + #[arg(short, long, requires = "name", conflicts_with = "verbose")] + pub force: bool, } impl Default { @@ -601,7 +579,7 @@ mod tests { use super::{ Apply, Cli, Default, Info, LocalReplacementDecision, Options, cert_server_base_url, certificate_chain_key_from_identity, local_replacement_decision, - save_default_identity_progress_message, save_identity_progress_message, + save_identity_progress_message, }; use crate::CERT_SERVER_BASE_URL; @@ -622,6 +600,60 @@ mod tests { assert_eq!(url, CERT_SERVER_BASE_URL); } + #[test] + fn removed_public_options_do_not_parse_or_appear_in_help() { + let help = Cli::command().render_long_help().to_string(); + for removed in [ + "--auth", + "--send-code", + "--replace-local", + "--allow-nonready", + "--register-if-missing", + ] { + assert!( + !help.contains(removed), + "{removed} leaked into help:\n{help}" + ); + } + + for argv in [ + vec!["genmeta", "apply", "alice.smith", "--auth", "email"], + vec!["genmeta", "apply", "alice.smith", "--send-code"], + vec!["genmeta", "apply", "alice.smith", "--replace-local"], + vec!["genmeta", "default", "alice.smith", "--allow-nonready"], + ] { + assert!(Options::try_parse_from(argv).is_err()); + } + } + + #[test] + fn force_is_scoped_to_apply_renew_and_named_default() { + assert!(Options::try_parse_from(["genmeta", "apply", "alice.smith", "--force"]).is_ok()); + assert!(Options::try_parse_from(["genmeta", "renew", "alice.smith", "-f"]).is_ok()); + assert!(Options::try_parse_from(["genmeta", "default", "alice.smith", "--force"]).is_ok()); + assert!(Options::try_parse_from(["genmeta", "default", "-v", "--force"]).is_err()); + } + + #[test] + fn hidden_verify_code_still_parses_without_help_exposure() { + let help = Apply::command().render_long_help().to_string(); + assert!(!help.contains("--verify-code"), "{help}"); + assert!( + Options::try_parse_from([ + "genmeta", + "apply", + "alice.smith", + "--kind", + "primary", + "--email", + "alice@example.test", + "--verify-code", + "000000", + ]) + .is_ok() + ); + } + fn local_identity_with_dhttp_ski() -> Identity { Identity::new( Name::try_from("client.example.com.dhttp.net").unwrap(), @@ -652,48 +684,6 @@ mod tests { assert_eq!(chain_key.to_string(), "primary:0"); } - #[test] - fn apply_rejects_auth_auto() { - let error = Options::try_parse_from([ - "genmeta", - "apply", - "alice.smith", - "--kind", - "primary", - "--auth", - "auto", - ]) - .unwrap_err(); - - let rendered = error.to_string(); - assert!(rendered.contains("--auth"), "{rendered}"); - assert!(rendered.contains("auto"), "{rendered}"); - } - - #[test] - fn verify_code_is_hidden_from_help_but_still_parses() { - let mut command = Apply::command(); - let help = command.render_long_help().to_string(); - assert!(!help.contains("--verify-code"), "{help}"); - - assert!( - Options::try_parse_from([ - "genmeta", - "apply", - "alice.smith", - "--kind", - "primary", - "--auth", - "email", - "--email", - "user@example.com", - "--verify-code", - "000000", - ]) - .is_ok() - ); - } - #[test] fn apply_does_not_expose_register_if_missing() { let help = Apply::command().render_long_help().to_string(); @@ -711,14 +701,6 @@ mod tests { assert!(error.to_string().contains("unexpected argument")); } - #[test] - fn default_accepts_allow_nonready() { - assert!( - Options::try_parse_from(["genmeta", "default", "alice.smith", "--allow-nonready",]) - .is_ok() - ); - } - #[test] fn create_subcommand_is_removed() { let error = Options::try_parse_from(["genmeta", "create", "alice.smith"]) @@ -775,21 +757,6 @@ mod tests { assert!(rendered.contains("--sequence"), "{rendered}"); } - #[test] - fn apply_accepts_replace_local_flag() { - assert!( - Options::try_parse_from([ - "genmeta", - "apply", - "alice.smith", - "--kind", - "primary", - "--replace-local", - ]) - .is_ok() - ); - } - #[test] fn local_replacement_has_no_deleted_confirmation_copy() { assert_eq!( @@ -813,10 +780,6 @@ mod tests { #[test] fn save_progress_uses_user_task_copy() { assert_eq!(save_identity_progress_message(), "Saving identity..."); - assert_eq!( - save_default_identity_progress_message(), - "Saving default identity..." - ); } #[test] @@ -829,44 +792,6 @@ mod tests { assert!(Options::try_parse_from(["genmeta", "renew", "--default"]).is_err()); } - #[test] - fn send_code_is_available_and_mutually_exclusive_with_verify_code() { - assert!( - Options::try_parse_from([ - "genmeta", - "apply", - "alice.smith", - "--kind", - "primary", - "--auth", - "email", - "--email", - "user@example.com", - "--send-code", - ]) - .is_ok() - ); - - let error = Options::try_parse_from([ - "genmeta", - "apply", - "alice.smith", - "--kind", - "primary", - "--auth", - "email", - "--email", - "user@example.com", - "--send-code", - "--verify-code", - "000000", - ]) - .unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("--send-code"), "{rendered}"); - assert!(rendered.contains("--verify-code"), "{rendered}"); - } - #[tokio::test] async fn save_settings_creates_missing_home_directory() { let home_path = unique_test_home_path("save-settings"); @@ -912,7 +837,7 @@ mod tests { let command = Default { name: Some("alice.smith".to_string()), verbose: false, - allow_nonready: false, + force: false, }; let error = command @@ -938,7 +863,7 @@ mod tests { let command = Default { name: Some("alice.smith".to_string()), verbose: false, - allow_nonready: true, + force: true, }; command diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 15d2d69..3722c01 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -8,7 +8,6 @@ use super::{ target::{IdentityLevel, IdentityTarget}, }; use crate::{ - auth::AuthMethod, cert_server::CertServer, cli::{self, Apply, Error, prompt::InquireResultExt}, }; @@ -48,7 +47,7 @@ fn missing_target_action( } fn private_test_root_registration(command: &Apply) -> bool { - command.verify_code.is_some() && matches!(command.auth, Some(AuthMethod::Email)) + command.verify_code.is_some() } fn missing_root_target_error(target: &IdentityTarget) -> Error { @@ -170,8 +169,8 @@ async fn offer_expired_code_resend( .await .require_interactive("interactive input")?; if resend { - super::progress::run_with_spinner( - "Sending verification code...", + super::progress::run( + super::progress::SEND_CODE, cert_server.send_email_verification(email), ) .await?; @@ -215,43 +214,19 @@ fn preserve_apply_registration_error(error: Error) -> Error { error } -fn resolve_non_interactive_approval_plan( - target: &str, - requested_auth: Option, - identity_auth_domain: Option<&str>, -) -> Result { - match requested_auth { - Some(AuthMethod::Email) => Ok(ApplyApprovalPlan::Email), - Some(AuthMethod::Identity) => { - let Some(auth_domain) = identity_auth_domain else { - whatever!( - "applying {} with --auth identity requires a ready local identity that can approve this apply flow", - target - ); - }; - Ok(ApplyApprovalPlan::DirectIdentity { - auth_domain: auth_domain.to_string(), - }) - } - None => { - if let Some(auth_domain) = identity_auth_domain { - return Ok(ApplyApprovalPlan::DirectIdentity { - auth_domain: auth_domain.to_string(), - }); - } - Ok(ApplyApprovalPlan::Email) - } +fn resolve_non_interactive_approval_plan(identity_auth_domain: Option<&str>) -> ApplyApprovalPlan { + if let Some(auth_domain) = identity_auth_domain { + return ApplyApprovalPlan::DirectIdentity { + auth_domain: auth_domain.to_string(), + }; } + ApplyApprovalPlan::Email } fn apply_identity_name_opening() -> &'static str { "Apply an identity here." } -fn interactive_name_check_progress_message() -> &'static str { - "Checking the validity of this name..." -} - fn interactive_name_unavailable_message() -> &'static str { "Sorry, this name is not available. Please try another one." } @@ -309,8 +284,8 @@ async fn prompt_apply_target_with_online_validation( let name = prompt_apply_target_with_opening(opening).await?; opening = ""; let target = IdentityTarget::parse(name.as_partial())?; - let response = match super::progress::run_with_spinner( - interactive_name_check_progress_message(), + let response = match super::progress::run( + super::progress::CHECK_NAME, cert_server.inspect_domain_availability(name.as_full()), ) .await @@ -532,10 +507,8 @@ async fn run_interactive_with_policy( crate::cli::flow::transcript::print_err_block(warning); } state.approval_plan = Some(resolve_non_interactive_approval_plan( - target.short_name(), - command.auth, auth_plan.first_identity_full_name(), - )?); + )); continue; } @@ -560,8 +533,8 @@ async fn run_interactive_with_policy( .clone() .whatever_context::<_, Error>("interactive apply email is unavailable")?; if state.verification_code_sent_to.as_deref() != Some(email.as_str()) { - match super::progress::run_with_spinner( - "Sending verification code...", + match super::progress::run( + super::progress::SEND_CODE, cert_server.send_email_verification(&email), ) .await @@ -594,8 +567,8 @@ async fn run_interactive_with_policy( crate::cli::prompt::TextPromptResult::MoreOptions => { match prompt_apply_verify_code_action().await? { ApplyVerifyCodeAction::ResendVerificationCode => { - match super::progress::run_with_spinner( - "Sending verification code...", + match super::progress::run( + super::progress::SEND_CODE, cert_server.send_email_verification(&email), ) .await @@ -638,8 +611,8 @@ async fn run_interactive_with_policy( let verify_code = state.verify_code.as_deref().whatever_context::<_, Error>( "interactive apply verification code is unavailable", )?; - let token = match super::progress::run_with_spinner( - "Verifying with email...", + let token = match super::progress::run( + super::progress::VERIFY_EMAIL, cert_server.login(&email, verify_code), ) .await @@ -668,8 +641,8 @@ async fn run_interactive_with_policy( return Err(Error::from(error)); } }; - match super::progress::run_with_spinner( - "Applying identity...", + match super::progress::run( + super::progress::REQUEST_CERT, cert_server.issue_cert( &token, domain.as_full(), @@ -701,8 +674,8 @@ async fn run_interactive_with_policy( crate::cli::flow::transcript::print_line( new_identity_confirmation_message(), ); - let detail = super::progress::run_with_spinner( - "Applying identity...", + let detail = super::progress::run( + super::progress::REQUEST_CERT, cert_server.issue_cert( &token, domain.as_full(), @@ -716,7 +689,7 @@ async fn run_interactive_with_policy( let local_identity_save = cli::ensure_replace_local_allowed( dhttp_home, domain.borrow(), - command.replace_local, + command.force, ) .await?; cli::save_identity( @@ -755,8 +728,8 @@ async fn run_interactive_with_policy( } } ApplyApprovalPlan::DirectIdentity { auth_domain } => { - match super::progress::run_with_spinner( - "Applying identity...", + match super::progress::run( + super::progress::REQUEST_CERT, cert_server.issue_cert_with_identity( &auth_domain, domain.as_full(), @@ -810,8 +783,8 @@ async fn run_interactive_with_policy( crate::cli::flow::transcript::print_line( new_identity_confirmation_message(), ); - super::progress::run_with_spinner( - "Applying identity...", + super::progress::run( + super::progress::REQUEST_CERT, cert_server.issue_cert_with_identity( &auth_domain, domain.as_full(), @@ -829,8 +802,7 @@ async fn run_interactive_with_policy( }; let local_identity_save = - cli::ensure_replace_local_allowed(dhttp_home, domain.borrow(), command.replace_local) - .await?; + cli::ensure_replace_local_allowed(dhttp_home, domain.borrow(), command.force).await?; cli::save_identity( dhttp_home, &domain, @@ -865,7 +837,7 @@ pub(crate) async fn run_with_policy( post_save: ApplyPostSavePolicy, ) -> Result<(), Error> { let is_interactive = std::io::stdin().is_terminal(); - if is_interactive && !command.send_code { + if is_interactive { return match run_interactive_with_policy( command, dhttp_home, @@ -891,28 +863,10 @@ pub(crate) async fn run_with_policy( for warning in &auth_plan.warnings { crate::cli::flow::transcript::print_err_block(warning); } - let approval_plan = resolve_non_interactive_approval_plan( - target.short_name(), - command.auth, - auth_plan.first_identity_full_name(), - )?; - - if command.send_code { - if !matches!(command.auth, Some(AuthMethod::Email)) { - whatever!("--send-code requires --auth email"); - } - let email = resolve_email(command).await?; - super::progress::run_with_spinner( - "Sending verification code...", - cert_server.send_email_verification(&email), - ) - .await?; - return Ok(()); - } + let approval_plan = resolve_non_interactive_approval_plan(auth_plan.first_identity_full_name()); let local_identity_save = - cli::ensure_replace_local_allowed(dhttp_home, domain.borrow(), command.replace_local) - .await?; + cli::ensure_replace_local_allowed(dhttp_home, domain.borrow(), command.force).await?; let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; let detail = match approval_plan { ApplyApprovalPlan::Email => { @@ -920,8 +874,8 @@ pub(crate) async fn run_with_policy( let verify_code = match command.verify_code.clone() { Some(code) => code, None => { - super::progress::run_with_spinner( - "Sending verification code...", + super::progress::run( + super::progress::SEND_CODE, cert_server.send_email_verification(&email), ) .await?; @@ -930,14 +884,14 @@ pub(crate) async fn run_with_policy( .require_interactive("--verify-code")? } }; - let token = super::progress::run_with_spinner( - "Verifying with email...", + let token = super::progress::run( + super::progress::VERIFY_EMAIL, cert_server.login(&email, &verify_code), ) .await? .access_token; - match super::progress::run_with_spinner( - "Applying identity...", + match super::progress::run( + super::progress::REQUEST_CERT, cert_server.issue_cert( &token, domain.as_full(), @@ -965,8 +919,8 @@ pub(crate) async fn run_with_policy( .await .map_err(preserve_apply_registration_error)?; crate::cli::flow::transcript::print_line(new_identity_confirmation_message()); - super::progress::run_with_spinner( - "Applying identity...", + super::progress::run( + super::progress::REQUEST_CERT, cert_server.issue_cert( &token, domain.as_full(), @@ -983,8 +937,8 @@ pub(crate) async fn run_with_policy( } } ApplyApprovalPlan::DirectIdentity { auth_domain } => { - match super::progress::run_with_spinner( - "Applying identity...", + match super::progress::run( + super::progress::REQUEST_CERT, cert_server.issue_cert_with_identity( &auth_domain, domain.as_full(), @@ -1005,15 +959,15 @@ pub(crate) async fn run_with_policy( } if target.level() != IdentityLevel::SubIdentity { whatever!( - "registering {} requires email verification; rerun with --auth email", + "registering {} requires interactive email verification", target.short_name() ); } ensure_sub_identity_exists_with_identity(&target, cert_server, &auth_domain) .await?; crate::cli::flow::transcript::print_line(new_identity_confirmation_message()); - super::progress::run_with_spinner( - "Applying identity...", + super::progress::run( + super::progress::REQUEST_CERT, cert_server.issue_cert_with_identity( &auth_domain, domain.as_full(), @@ -1077,15 +1031,11 @@ mod tests { InteractiveNameAvailability, MissingTargetAction, apply_identity_name_opening, apply_verify_code_actions, classify_apply_email_issue_error, classify_interactive_name_availability, explicit_target_from_command, - interactive_name_check_progress_message, interactive_name_unavailable_message, - missing_root_target_error, missing_target_action, new_identity_confirmation_message, - preserve_apply_email_issue_error, preserve_apply_registration_error, - resolve_non_interactive_approval_plan, - }; - use crate::{ - auth::AuthMethod, - cli::{Apply, flow::target::IdentityTarget}, + interactive_name_unavailable_message, missing_root_target_error, missing_target_action, + new_identity_confirmation_message, preserve_apply_email_issue_error, + preserve_apply_registration_error, resolve_non_interactive_approval_plan, }; + use crate::cli::{Apply, flow::target::IdentityTarget}; #[test] fn stay_recovery_keeps_apply_verify_state() { @@ -1093,12 +1043,10 @@ mod tests { &Apply { name: Some("alice.smith".to_string()), kind: Some("primary".to_string()), - replace_local: false, + force: false, device_name: None, email: Some("alice@example.test".to_string()), - send_code: false, verify_code: None, - auth: None, }, None, ) @@ -1121,12 +1069,10 @@ mod tests { &Apply { name: Some("alice.smith".to_string()), kind: Some("primary".to_string()), - replace_local: false, + force: false, device_name: None, email: Some("alice@example.test".to_string()), - send_code: false, verify_code: None, - auth: None, }, None, ) @@ -1180,12 +1126,10 @@ mod tests { let target = explicit_target_from_command(&Apply { name: None, kind: None, - replace_local: false, + force: false, device_name: None, email: None, - send_code: false, verify_code: None, - auth: None, }) .unwrap(); @@ -1195,7 +1139,7 @@ mod tests { #[test] fn root_apply_without_local_auth_defaults_to_email_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan("alice.smith", None, None).unwrap(), + resolve_non_interactive_approval_plan(None), ApplyApprovalPlan::Email, ); } @@ -1203,36 +1147,7 @@ mod tests { #[test] fn root_apply_prefers_ready_local_auth_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan("alice.smith", None, Some("alice.smith")) - .unwrap(), - ApplyApprovalPlan::DirectIdentity { - auth_domain: "alice.smith".to_string(), - }, - ); - } - - #[test] - fn apply_identity_auth_requires_ready_local_identity_or_parent() { - let error = resolve_non_interactive_approval_plan( - "phone.alice.smith", - Some(AuthMethod::Identity), - None, - ) - .unwrap_err(); - let rendered = error.to_string(); - assert!(rendered.contains("ready local identity"), "{rendered}"); - assert!(rendered.contains("phone.alice.smith"), "{rendered}"); - } - - #[test] - fn sub_identity_apply_can_use_ready_parent_identity() { - assert_eq!( - resolve_non_interactive_approval_plan( - "phone.alice.smith", - Some(AuthMethod::Identity), - Some("alice.smith"), - ) - .unwrap(), + resolve_non_interactive_approval_plan(Some("alice.smith")), ApplyApprovalPlan::DirectIdentity { auth_domain: "alice.smith".to_string(), }, @@ -1242,8 +1157,7 @@ mod tests { #[test] fn sub_identity_apply_automatically_uses_ready_parent() { assert_eq!( - resolve_non_interactive_approval_plan("phone.alice.smith", None, Some("alice.smith"),) - .unwrap(), + resolve_non_interactive_approval_plan(Some("alice.smith")), ApplyApprovalPlan::DirectIdentity { auth_domain: "alice.smith".to_string(), }, @@ -1285,10 +1199,6 @@ mod tests { #[test] fn interactive_name_check_copy_matches_spec() { - assert_eq!( - interactive_name_check_progress_message(), - "Checking the validity of this name..." - ); assert_eq!( interactive_name_unavailable_message(), "Sorry, this name is not available. Please try another one." diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index a08a1de..093951f 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -69,9 +69,9 @@ pub(crate) async fn run( whatever!("{}", default_not_found_message(target.short_name())); }; - if !summary.status.is_ready() && !command.allow_nonready { + if !summary.status.is_ready() && !command.force { whatever!( - "{} is {} and cannot be set as the default identity without --allow-nonready", + "{} is {} and cannot be set as the default identity without --force", summary.target.short_name(), summary.status.label(), ); diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index f75f766..c9e20d6 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -3,31 +3,82 @@ use std::future::Future; use tracing::{Instrument, info_span}; use tracing_indicatif::span_ext::IndicatifSpanExt; -pub(crate) async fn run_with_spinner(message: &str, future: Fut) -> Result -where - Fut: Future>, -{ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProgressCopy { + pub(crate) running: &'static str, + pub(crate) success: &'static str, +} + +impl ProgressCopy { + pub(crate) const fn new(running: &'static str, success: &'static str) -> Self { + Self { running, success } + } +} + +pub(crate) const CHECK_NAME: ProgressCopy = ProgressCopy::new( + "Checking the validity of this name...", + "Checked the validity of this name.", +); +pub(crate) const SEND_CODE: ProgressCopy = + ProgressCopy::new("Sending verification code...", "Sent verification code."); +pub(crate) const VERIFY_EMAIL: ProgressCopy = + ProgressCopy::new("Verifying with email...", "Verified with email."); +pub(crate) const GENERATE_KEY: ProgressCopy = ProgressCopy::new( + "Generating secp384r1 ECC key pair locally...", + "Generated secp384r1 ECC key pair locally.", +); +pub(crate) const REQUEST_CERT: ProgressCopy = ProgressCopy::new( + "Generating CSR and requesting certificate...", + "Generated CSR and requested certificate.", +); +pub(crate) const RENEW_IDENTITY: ProgressCopy = + ProgressCopy::new("Renewing identity...", "Renewed identity."); +pub(crate) const SAVE_DEFAULT: ProgressCopy = + ProgressCopy::new("Saving default identity...", "Saved default identity."); + +pub(crate) async fn run( + copy: ProgressCopy, + future: impl Future>, +) -> Result { let span = info_span!("cli_progress", indicatif.pb_show = tracing::field::Empty); - span.pb_set_message(message); + span.pb_set_message(copy.running); span.pb_start(); let result = future.instrument(span.clone()).await; + if result.is_ok() { + span.pb_set_finish_message(copy.success); + } drop(span); result } -pub(crate) fn run_with_retained_progress( - message: &str, - finish_message: &str, +pub(crate) fn run_sync( + copy: ProgressCopy, operation: impl FnOnce() -> Result, ) -> Result { let span = info_span!("cli_progress", indicatif.pb_show = tracing::field::Empty); - span.pb_set_message(message); - span.pb_set_finish_message(finish_message); + span.pb_set_message(copy.running); span.pb_start(); let result = { let _entered = span.enter(); operation() }; + if result.is_ok() { + span.pb_set_finish_message(copy.success); + } + drop(span); + result +} + +/// Transitional helper for call sites that do not yet have approved retained +/// completion copy. New flow code should use [`run`] with a named copy pair. +pub(crate) async fn run_with_spinner(message: &str, future: Fut) -> Result +where + Fut: Future>, +{ + let span = info_span!("cli_progress", indicatif.pb_show = tracing::field::Empty); + span.pb_set_message(message); + span.pb_start(); + let result = future.instrument(span.clone()).await; drop(span); result } @@ -43,7 +94,33 @@ mod tests { use tracing_indicatif::filter::IndicatifFilter; use tracing_subscriber::{Layer, layer::SubscriberExt, registry::LookupSpan}; - use super::{run_with_retained_progress, run_with_spinner}; + use super::{ + CHECK_NAME, GENERATE_KEY, ProgressCopy, RENEW_IDENTITY, REQUEST_CERT, SAVE_DEFAULT, + SEND_CODE, VERIFY_EMAIL, run_with_spinner, + }; + + #[test] + fn progress_pairs_are_stable() { + assert_eq!( + CHECK_NAME, + ProgressCopy::new( + "Checking the validity of this name...", + "Checked the validity of this name.", + ) + ); + assert_eq!(SEND_CODE.success, "Sent verification code."); + assert_eq!(VERIFY_EMAIL.success, "Verified with email."); + assert_eq!( + GENERATE_KEY.success, + "Generated secp384r1 ECC key pair locally." + ); + assert_eq!( + REQUEST_CERT.success, + "Generated CSR and requested certificate." + ); + assert_eq!(RENEW_IDENTITY.success, "Renewed identity."); + assert_eq!(SAVE_DEFAULT.success, "Saved default identity."); + } #[tokio::test] async fn run_with_spinner_returns_inner_result() { @@ -56,18 +133,6 @@ mod tests { assert_eq!(value, "ok"); } - #[test] - fn retained_progress_returns_inner_result() { - let value = run_with_retained_progress( - "Generating secp384r1 ECC key pair locally...", - "Generated secp384r1 ECC key pair locally.", - || Ok::<_, std::io::Error>("ok"), - ) - .unwrap(); - - assert_eq!(value, "ok"); - } - #[derive(Clone, Default)] struct CountLayer(Arc); diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 9385080..59bb2eb 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -5,7 +5,6 @@ use snafu::{OptionExt, whatever}; use super::local; use crate::{ - auth::AuthMethod, cert_server::CertServer, cli::{self, Error, Renew, prompt::InquireResultExt}, }; @@ -109,8 +108,8 @@ async fn offer_expired_code_resend( .await .require_interactive("interactive input")?; if resend { - super::progress::run_with_spinner( - "Sending verification code...", + super::progress::run( + super::progress::SEND_CODE, cert_server.send_email_verification(email), ) .await?; @@ -138,26 +137,12 @@ async fn ensure_saved_renew_target( whatever!("{}", renew_not_saved_root_message(name.as_partial())); } -fn resolve_non_interactive_approval_plan( - target: &str, - requested_auth: Option, - ready_identity: Option<&str>, -) -> Result { - match requested_auth { - Some(AuthMethod::Email) => Ok(RenewApprovalPlan::Email), - Some(AuthMethod::Identity) => ready_identity - .map(|auth_domain| RenewApprovalPlan::Identity { - auth_domain: auth_domain.to_string(), - }) - .whatever_context::<_, Error>(format!( - "renewing {target} cannot use local identity verification because neither it nor its parent has a ready local certificate; use --auth email" - )), - None => Ok(match ready_identity { - Some(auth_domain) => RenewApprovalPlan::Identity { - auth_domain: auth_domain.to_string(), - }, - None => RenewApprovalPlan::Email, - }), +fn resolve_non_interactive_approval_plan(ready_identity: Option<&str>) -> RenewApprovalPlan { + match ready_identity { + Some(auth_domain) => RenewApprovalPlan::Identity { + auth_domain: auth_domain.to_string(), + }, + None => RenewApprovalPlan::Email, } } @@ -171,15 +156,6 @@ async fn resolve_target( } } -async fn resolve_email(command: &Renew) -> Result { - match command.email.clone() { - Some(email) => Ok(email), - None => Ok(crate::cli::prompt::prompt_email() - .await - .require_interactive("--email")?), - } -} - async fn prompt_renew_verify_code_action() -> Result { let actions = renew_verify_code_actions(); let labels = actions @@ -222,10 +198,8 @@ async fn run_interactive( crate::cli::flow::transcript::print_err_block(warning); } state.approval_plan = Some(resolve_non_interactive_approval_plan( - domain.as_partial(), - command.auth, auth_plan.first_identity_full_name(), - )?); + )); continue; } @@ -250,8 +224,8 @@ async fn run_interactive( .clone() .whatever_context::<_, Error>("interactive renew email is unavailable")?; if state.verification_code_sent_to.as_deref() != Some(email.as_str()) { - match super::progress::run_with_spinner( - "Sending verification code...", + match super::progress::run( + super::progress::SEND_CODE, cert_server.send_email_verification(&email), ) .await @@ -284,8 +258,8 @@ async fn run_interactive( crate::cli::prompt::TextPromptResult::MoreOptions => { match prompt_renew_verify_code_action().await? { RenewVerifyCodeAction::ResendVerificationCode => { - match super::progress::run_with_spinner( - "Sending verification code...", + match super::progress::run( + super::progress::SEND_CODE, cert_server.send_email_verification(&email), ) .await @@ -334,8 +308,8 @@ async fn run_interactive( let verify_code = state.verify_code.as_deref().whatever_context::<_, Error>( "interactive renew verification code is unavailable", )?; - let token = match super::progress::run_with_spinner( - "Verifying with email...", + let token = match super::progress::run( + super::progress::VERIFY_EMAIL, cert_server.domain_login(domain.as_full(), &email, verify_code), ) .await @@ -364,8 +338,8 @@ async fn run_interactive( return Err(Error::from(error)); } }; - super::progress::run_with_spinner( - "Renewing identity...", + super::progress::run( + super::progress::RENEW_IDENTITY, cert_server.renew_cert( &token, domain.as_full(), @@ -378,8 +352,8 @@ async fn run_interactive( .await? } RenewApprovalPlan::Identity { auth_domain } => { - super::progress::run_with_spinner( - "Renewing identity...", + super::progress::run( + super::progress::RENEW_IDENTITY, cert_server.renew_cert_with_identity( &auth_domain, domain.as_full(), @@ -411,7 +385,7 @@ pub(crate) async fn run( cert_server: &CertServer, ) -> Result<(), Error> { let is_interactive = std::io::stdin().is_terminal(); - if is_interactive && !command.send_code { + if is_interactive { return run_interactive(command, dhttp_home, home_scope, cert_server).await; } let domain = resolve_target(command, dhttp_home).await?; @@ -421,11 +395,7 @@ pub(crate) async fn run( for warning in &auth_plan.warnings { crate::cli::flow::transcript::print_err_block(warning); } - let approval_plan = resolve_non_interactive_approval_plan( - domain.as_partial(), - command.auth, - auth_plan.first_identity_full_name(), - )?; + let approval_plan = resolve_non_interactive_approval_plan(auth_plan.first_identity_full_name()); let identity_profile = dhttp_home.resolve_identity_profile(domain.borrow()).await?; let local_identity = identity_profile.load_identity().await?; let chain_key = cli::certificate_chain_key_from_identity(&local_identity)? @@ -435,19 +405,6 @@ pub(crate) async fn run( let device_name = super::device::resolve_device_name(command.device_name.as_deref(), home_scope); - if command.send_code { - if !matches!(command.auth, Some(AuthMethod::Email)) { - whatever!("--send-code requires --auth email"); - } - let email = resolve_email(command).await?; - super::progress::run_with_spinner( - "Sending verification code...", - cert_server.send_email_verification(&email), - ) - .await?; - return Ok(()); - } - let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; let detail = match approval_plan { RenewApprovalPlan::Email => { @@ -458,8 +415,8 @@ pub(crate) async fn run( command.verify_code.clone(), ) .await?; - super::progress::run_with_spinner( - "Renewing identity...", + super::progress::run( + super::progress::RENEW_IDENTITY, cert_server.renew_cert( &token, domain.as_full(), @@ -472,8 +429,8 @@ pub(crate) async fn run( .await? } RenewApprovalPlan::Identity { auth_domain } => { - super::progress::run_with_spinner( - "Renewing identity...", + super::progress::run( + super::progress::RENEW_IDENTITY, cert_server.renew_cert_with_identity( &auth_domain, domain.as_full(), @@ -511,7 +468,7 @@ mod tests { renew_not_saved_root_message, renew_verify_code_actions, resolve_non_interactive_approval_plan, }; - use crate::{auth::AuthMethod, cli::Renew}; + use crate::cli::Renew; fn unique_test_home_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() @@ -534,11 +491,10 @@ mod tests { let mut state = InteractiveRenewState::from_command( &Renew { name: Some("alice.smith".to_string()), + force: false, device_name: None, email: Some("alice@example.test".to_string()), - send_code: false, verify_code: None, - auth: None, }, None, ); @@ -559,11 +515,10 @@ mod tests { let mut state = InteractiveRenewState::from_command( &Renew { name: Some("alice.smith".to_string()), + force: false, device_name: None, email: Some("alice@example.test".to_string()), - send_code: false, verify_code: None, - auth: None, }, None, ); @@ -583,12 +538,7 @@ mod tests { #[test] fn renew_prefers_ready_identity_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan( - "alice.smith", - None, - Some("alice.smith.dhttp.net") - ) - .unwrap(), + resolve_non_interactive_approval_plan(Some("alice.smith.dhttp.net")), RenewApprovalPlan::Identity { auth_domain: "alice.smith.dhttp.net".to_string() } @@ -596,25 +546,9 @@ mod tests { } #[test] - fn renew_identity_auth_is_allowed() { - assert_eq!( - resolve_non_interactive_approval_plan( - "alice.smith", - Some(AuthMethod::Identity), - Some("alice.smith.dhttp.net") - ) - .unwrap(), - RenewApprovalPlan::Identity { - auth_domain: "alice.smith.dhttp.net".to_string() - }, - ); - } - - #[test] - fn renew_email_auth_is_allowed() { + fn renew_without_ready_identity_uses_email_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan("alice.smith", Some(AuthMethod::Email), None) - .unwrap(), + resolve_non_interactive_approval_plan(None), RenewApprovalPlan::Email, ); } @@ -633,11 +567,10 @@ mod tests { let dhttp_home = DhttpHome::new(home_path); let command = Renew { name: Some("alice.smith".to_string()), + force: false, device_name: None, email: None, - send_code: false, verify_code: None, - auth: None, }; let error = super::run(&command, &dhttp_home, HomeScope::User, &dummy_cert_server()) diff --git a/genmeta-identity/src/cli/prompt.rs b/genmeta-identity/src/cli/prompt.rs index e451ccd..e580645 100644 --- a/genmeta-identity/src/cli/prompt.rs +++ b/genmeta-identity/src/cli/prompt.rs @@ -105,7 +105,7 @@ pub(crate) fn identity_name_prompt_message() -> &'static str { } pub(crate) fn identity_name_help_message() -> &'static str { - "For example:\n [handle.]your.name" + "Name format: [handle.]your.name" } pub(crate) fn email_prompt_message() -> &'static str { @@ -279,11 +279,11 @@ mod tests { }; #[test] - fn prompt_copy_matches_the_approved_document() { + fn prompt_copy_matches_the_approved_transcript() { assert_eq!(identity_name_prompt_message(), "Enter your name:"); assert_eq!( identity_name_help_message(), - "For example:\n [handle.]your.name" + "Name format: [handle.]your.name" ); assert_eq!(email_prompt_message(), "Enter your email:"); assert_eq!(verify_code_prompt_message(), "Enter verification code:"); From 7b343d603e9ad7a34245bfd65406525fd207df13 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 00:38:56 +0800 Subject: [PATCH 08/27] feat(identity): render exact local identity state --- genmeta-identity/src/cli.rs | 9 +- genmeta-identity/src/cli/flow/auth_plan.rs | 14 +- .../src/cli/flow/default_identity.rs | 12 +- genmeta-identity/src/cli/flow/epilogue.rs | 4 +- genmeta-identity/src/cli/flow/local.rs | 157 ++-- genmeta-identity/src/cli/flow/output.rs | 699 ++++++++---------- genmeta-identity/src/cli/flow/renew.rs | 2 +- 7 files changed, 437 insertions(+), 460 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index dceb9ec..a4fc7d4 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -387,7 +387,11 @@ impl List { } else { let ansi = std::io::stdout().is_terminal(); let rendered = if self.verbose { - flow::output::render_verbose_inventory(&inventory, ansi) + flow::output::render_verbose_inventory( + &inventory, + flow::local::now_unix_timestamp(), + ansi, + ) } else { flow::output::render_inventory(&inventory, ansi) }; @@ -423,7 +427,7 @@ impl Info { ), }, }; - let Some(summary) = flow::local::try_load_summary( + let Some(summary) = flow::local::try_load_summary_exact( dhttp_home, name.borrow(), default_name.as_ref().map(|default| default.borrow()), @@ -438,6 +442,7 @@ impl Info { }; flow::transcript::print_block(&flow::output::format_info( &summary, + flow::local::now_unix_timestamp(), std::io::stdout().is_terminal(), )); diff --git a/genmeta-identity/src/cli/flow/auth_plan.rs b/genmeta-identity/src/cli/flow/auth_plan.rs index 41a4b70..5bc15f3 100644 --- a/genmeta-identity/src/cli/flow/auth_plan.rs +++ b/genmeta-identity/src/cli/flow/auth_plan.rs @@ -97,10 +97,11 @@ pub(crate) async fn load_apply_auth_plan( dhttp_home: &DhttpHome, target: &IdentityTarget, ) -> Result { - let target_summary = local::try_load_summary(dhttp_home, target.dhttp_name(), None).await?; + let target_summary = + local::try_load_summary_exact(dhttp_home, target.dhttp_name(), None).await?; let parent_summary = if target.level() == IdentityLevel::SubIdentity { match target.parent() { - Some(parent) => local::try_load_summary(dhttp_home, parent, None).await?, + Some(parent) => local::try_load_summary_exact(dhttp_home, parent, None).await?, None => None, } } else { @@ -118,18 +119,19 @@ mod tests { use super::*; use crate::cli::flow::{ - local::{LocalIdentityStatus, LocalIdentitySummary}, + local::{IdentityUsage, LocalIdentityStatus, LocalIdentitySummary}, target::IdentityTarget, }; fn summary(name: &str, status: LocalIdentityStatus) -> LocalIdentitySummary { LocalIdentitySummary { target: IdentityTarget::parse(name).unwrap(), - certificate_chain: Some("primary:0".to_string()), + usage: Some(IdentityUsage::BothClientAndServer), + sequence: Some(0), valid_from: Some(1_600_000_000), - issuer: Some("CN=Genmeta Test CA".to_string()), + expires_at: Some(1_900_000_000), status, - saved_at: PathBuf::from(format!("/tmp/{name}")), + dir: PathBuf::from(format!("/tmp/{name}")), is_default: false, } } diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index 093951f..0b09fe7 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -46,18 +46,22 @@ pub(crate) async fn run( ), }; let summary = - local::load_summary(dhttp_home, name, configured_default_name.clone()).await?; + local::load_summary_exact(dhttp_home, name, configured_default_name.clone()).await?; let rendered = if command.verbose { - output::format_info(&summary, std::io::stdout().is_terminal()) + output::format_info( + &summary, + local::now_unix_timestamp(), + std::io::stdout().is_terminal(), + ) } else { - output::format_default_query(&summary) + output::format_default_query(&summary, local::now_unix_timestamp()) }; crate::cli::flow::transcript::print_block(&rendered); return Ok(()); }; let target = IdentityTarget::parse(name)?; - let Some(summary) = local::try_load_summary( + let Some(summary) = local::try_load_summary_exact( dhttp_home, target.dhttp_name(), configured_default_name diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index f3087e3..ccaf4ba 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -51,7 +51,7 @@ pub(crate) async fn current_default_summary( return Ok(None); }; - let status = match local::try_load_summary(dhttp_home, name.borrow(), None).await? { + let status = match local::try_load_summary_exact(dhttp_home, name.borrow(), None).await? { Some(summary) => summary.status, None => local::LocalIdentityStatus::Invalid { detail: "identity is not saved here".to_string(), @@ -90,7 +90,7 @@ pub(crate) async fn run_lifecycle_epilogue( let ansi = std::io::stdout().is_terminal(); let default_after = current_default_name(dhttp_home).await?; let current_default = current_default_summary(dhttp_home).await?; - let summary = local::load_summary( + let summary = local::load_summary_exact( dhttp_home, name.clone(), default_after.as_ref().map(|default| default.borrow()), diff --git a/genmeta-identity/src/cli/flow/local.rs b/genmeta-identity/src/cli/flow/local.rs index 0b425f1..65b9012 100644 --- a/genmeta-identity/src/cli/flow/local.rs +++ b/genmeta-identity/src/cli/flow/local.rs @@ -18,10 +18,25 @@ pub(crate) enum LocalIdentityMaterialState { pub(crate) struct LocalIdentityAssessment { pub(crate) certificate: LocalIdentityMaterialState, pub(crate) private_key: LocalIdentityMaterialState, - pub(crate) certificate_chain: Option, + pub(crate) usage: Option, + pub(crate) sequence: Option, pub(crate) valid_from: Option, pub(crate) expires_at: Option, - pub(crate) issuer: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IdentityUsage { + BothClientAndServer, + ClientOnly, +} + +impl IdentityUsage { + pub(crate) fn label(self) -> &'static str { + match self { + Self::BothClientAndServer => "both client and server", + Self::ClientOnly => "client only", + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -45,24 +60,17 @@ impl LocalIdentityStatus { pub(crate) fn is_ready(&self) -> bool { matches!(self, Self::Ready { .. }) } - - pub(crate) fn expires_at(&self) -> Option { - match self { - Self::Ready { expires_at } => Some(*expires_at), - Self::Expired { expired_at } => Some(*expired_at), - Self::Incomplete { .. } | Self::Invalid { .. } => None, - } - } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LocalIdentitySummary { pub(crate) target: IdentityTarget, - pub(crate) certificate_chain: Option, + pub(crate) usage: Option, + pub(crate) sequence: Option, pub(crate) valid_from: Option, - pub(crate) issuer: Option, + pub(crate) expires_at: Option, pub(crate) status: LocalIdentityStatus, - pub(crate) saved_at: PathBuf, + pub(crate) dir: PathBuf, pub(crate) is_default: bool, } @@ -138,11 +146,11 @@ pub(crate) fn classify_status( LocalIdentityMaterialState::Present => {} } - let Some(_) = assessment.certificate_chain.as_ref() else { + if assessment.usage.is_none() || assessment.sequence.is_none() { return LocalIdentityStatus::Invalid { detail: "certificate chain metadata is invalid".to_string(), }; - }; + } let Some(expires_at) = assessment.expires_at else { return LocalIdentityStatus::Invalid { detail: "certificate expiry is unavailable".to_string(), @@ -158,6 +166,13 @@ pub(crate) fn classify_status( } } +pub(crate) fn is_near_expiry(valid_from: i64, expires_at: i64, now: i64) -> bool { + now < expires_at + && now + >= valid_from + .saturating_add((expires_at.saturating_sub(valid_from).saturating_mul(2)) / 3) +} + pub(crate) fn build_inventory(mut summaries: Vec) -> LocalInventory { summaries.sort_by(summary_sort_key); @@ -238,42 +253,45 @@ pub(crate) async fn load_inventory( }; let mut summaries = Vec::with_capacity(names.len()); for name in names { - summaries.push(load_summary(dhttp_home, name.borrow(), default_name.clone()).await?); + summaries.push(load_summary_exact(dhttp_home, name.borrow(), default_name.clone()).await?); } Ok(build_inventory(summaries)) } -pub(crate) async fn try_load_summary( +pub(crate) async fn try_load_summary_exact( dhttp_home: &DhttpHome, name: DhttpName<'_>, default_name: Option>, ) -> Result, Error> { - match load_summary(dhttp_home, name, default_name).await { + match load_summary_exact(dhttp_home, name, default_name).await { Ok(summary) => Ok(Some(summary)), Err(Error::ResolveIdentityProfile { - source: dhttp::home::identity::ssl::ResolveIdentityProfileError::NotFound { .. }, + source: dhttp::home::identity::ssl::ResolveIdentityProfileError::ExactNotFound { .. }, }) => Ok(None), Err(error) => Err(error), } } -pub(crate) async fn load_summary( +pub(crate) async fn load_summary_exact( dhttp_home: &DhttpHome, name: DhttpName<'_>, default_name: Option>, ) -> Result { let target = IdentityTarget::parse(name.as_partial())?; - let profile = dhttp_home.resolve_identity_profile(name.clone()).await?; + let profile = dhttp_home + .resolve_identity_profile_exactly(name.clone()) + .await?; let assessment = assess_profile(&profile).await; let status = classify_status(&assessment, now_unix_timestamp()); Ok(LocalIdentitySummary { target, - certificate_chain: assessment.certificate_chain, + usage: assessment.usage, + sequence: assessment.sequence, valid_from: assessment.valid_from, - issuer: assessment.issuer, + expires_at: assessment.expires_at, status, - saved_at: profile.path().to_path_buf(), + dir: profile.path().to_path_buf(), is_default: default_name .as_ref() .map(|default| default.as_partial() == name.as_partial()) @@ -345,7 +363,7 @@ fn summary_sort_key( .then_with(|| left.target.short_name().cmp(right.target.short_name())) } -fn now_unix_timestamp() -> i64 { +pub(crate) fn now_unix_timestamp() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock should be after unix epoch") @@ -361,10 +379,10 @@ async fn assess_profile( return LocalIdentityAssessment { certificate: certificate_state_from_error(&error), private_key: LocalIdentityMaterialState::Present, - certificate_chain: None, + usage: None, + sequence: None, valid_from: None, expires_at: None, - issuer: None, }; } }; @@ -379,10 +397,10 @@ async fn assess_profile( let mut assessment = LocalIdentityAssessment { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, - certificate_chain: None, + usage: None, + sequence: None, valid_from: None, expires_at: None, - issuer: None, }; match profile.load_key().await { @@ -423,7 +441,6 @@ async fn assess_profile( Ok((_, certificate)) => { assessment.valid_from = Some(certificate.validity().not_before.timestamp()); assessment.expires_at = Some(certificate.validity().not_after.timestamp()); - assessment.issuer = Some(certificate.issuer().to_string()); } Err(_) => { assessment.certificate = @@ -434,7 +451,13 @@ async fn assess_profile( match extract_dhttp_subject_key_identifier(&certs) { Ok(ski) => { - assessment.certificate_chain = Some(ski.chain().to_string()); + assessment.usage = Some(match ski.chain().kind() { + dhttp::certificate::CertificateChainKind::Primary => { + IdentityUsage::BothClientAndServer + } + dhttp::certificate::CertificateChainKind::Secondary => IdentityUsage::ClientOnly, + }); + assessment.sequence = Some(ski.chain().sequence().get()); } Err(_) => { assessment.certificate = LocalIdentityMaterialState::Invalid( @@ -483,10 +506,10 @@ mod tests { use dhttp::{home::DhttpHome, name::DhttpName}; use super::{ - InteractiveInventoryChoice, LocalIdentityAssessment, LocalIdentityMaterialState, - LocalIdentityStatus, LocalIdentitySummary, LocalInventoryRoot, + IdentityUsage, InteractiveInventoryChoice, LocalIdentityAssessment, + LocalIdentityMaterialState, LocalIdentityStatus, LocalIdentitySummary, LocalInventoryRoot, build_default_inventory_choices, build_inventory, build_renew_inventory_choices, - classify_status, + classify_status, is_near_expiry, try_load_summary_exact, }; use crate::cli::flow::target::IdentityTarget; @@ -504,29 +527,68 @@ mod tests { } fn ready_summary(name: &str, is_default: bool, chain: &str) -> LocalIdentitySummary { + let (usage, sequence) = match chain.split_once(':').unwrap() { + ("primary", sequence) => ( + IdentityUsage::BothClientAndServer, + sequence.parse().unwrap(), + ), + ("secondary", sequence) => (IdentityUsage::ClientOnly, sequence.parse().unwrap()), + _ => panic!("unsupported test certificate chain"), + }; LocalIdentitySummary { target: IdentityTarget::parse(name).unwrap(), - certificate_chain: Some(chain.to_string()), + usage: Some(usage), + sequence: Some(sequence), valid_from: Some(NOW - 300), - issuer: Some("CN=Genmeta Test CA".to_string()), + expires_at: Some(NOW + 300), status: LocalIdentityStatus::Ready { expires_at: NOW + 300, }, - saved_at: PathBuf::from(format!("/tmp/{name}")), + dir: PathBuf::from(format!("/tmp/{name}")), is_default, } } + #[test] + fn near_expiry_starts_at_two_thirds_of_the_validity_window() { + let valid_from = 1_000; + let expires_at = 1_900; + assert!(!is_near_expiry(valid_from, expires_at, 1_599)); + assert!(is_near_expiry(valid_from, expires_at, 1_600)); + assert!(is_near_expiry(valid_from, expires_at, 1_899)); + assert!(!is_near_expiry(valid_from, expires_at, 1_900)); + } + + #[tokio::test] + async fn exact_summary_never_falls_back_to_a_wildcard_profile() { + let home_path = unique_test_home_path("exact-only"); + let home = DhttpHome::new(home_path.clone()); + let requested = DhttpName::try_from("phone.alice.smith").unwrap(); + let wildcard = requested.clone().to_wildcard(); + tokio::fs::create_dir_all(home.identity_profile(wildcard.borrow()).ssl_dir()) + .await + .unwrap(); + + assert!( + try_load_summary_exact(&home, requested.borrow(), None) + .await + .unwrap() + .is_none() + ); + + tokio::fs::remove_dir_all(home_path).await.unwrap(); + } + #[test] fn classifies_ready_expired_incomplete_and_invalid() { let ready = classify_status( &LocalIdentityAssessment { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, - certificate_chain: Some("primary:0".to_string()), + usage: Some(IdentityUsage::BothClientAndServer), + sequence: Some(0), valid_from: Some(NOW - 300), expires_at: Some(NOW + 300), - issuer: Some("CN=Genmeta Test CA".to_string()), }, NOW, ); @@ -541,10 +603,10 @@ mod tests { &LocalIdentityAssessment { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, - certificate_chain: Some("secondary:2".to_string()), + usage: Some(IdentityUsage::ClientOnly), + sequence: Some(2), valid_from: Some(NOW - 300), expires_at: Some(NOW - 1), - issuer: Some("CN=Genmeta Test CA".to_string()), }, NOW, ); @@ -559,10 +621,10 @@ mod tests { &LocalIdentityAssessment { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Missing("private key missing"), - certificate_chain: Some("secondary:3".to_string()), + usage: Some(IdentityUsage::ClientOnly), + sequence: Some(3), valid_from: Some(NOW - 300), expires_at: Some(NOW + 300), - issuer: Some("CN=Genmeta Test CA".to_string()), }, NOW, ); @@ -579,10 +641,10 @@ mod tests { "certificate does not match local key".to_string(), ), private_key: LocalIdentityMaterialState::Present, - certificate_chain: Some("secondary:4".to_string()), + usage: Some(IdentityUsage::ClientOnly), + sequence: Some(4), valid_from: Some(NOW - 300), expires_at: Some(NOW + 300), - issuer: Some("CN=Genmeta Test CA".to_string()), }, NOW, ); @@ -600,7 +662,8 @@ mod tests { tv.status = LocalIdentityStatus::Incomplete { detail: "private key missing".to_string(), }; - tv.certificate_chain = None; + tv.usage = None; + tv.sequence = None; let inventory = build_inventory(vec![ ready_summary("tablet.reimu.scarlet", false, "secondary:1"), @@ -701,7 +764,7 @@ mod tests { let dhttp_home = DhttpHome::new(home_path); let name = DhttpName::try_from("alice.smith").unwrap(); - let summary = super::try_load_summary(&dhttp_home, name.borrow(), None) + let summary = super::try_load_summary_exact(&dhttp_home, name.borrow(), None) .await .unwrap(); diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index d5b6c61..5d823ef 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -1,16 +1,50 @@ -use crossterm::style::Stylize; - #[cfg(test)] use super::local::InteractiveInventoryChoice; -use super::local::{LocalIdentityStatus, LocalIdentitySummary, LocalInventory, LocalInventoryRoot}; +use super::local::{ + LocalIdentityStatus, LocalIdentitySummary, LocalInventory, LocalInventoryRoot, is_near_expiry, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BlockStyle { + bold: bool, + dim: bool, +} + +fn style_for(summary: &LocalIdentitySummary) -> BlockStyle { + BlockStyle { + bold: summary.is_default, + dim: !matches!(summary.status, LocalIdentityStatus::Ready { .. }), + } +} + +fn render_block(text: String, style: BlockStyle, ansi: bool) -> String { + if !ansi || (!style.bold && !style.dim) { + return text; + } + + let mut rendered = String::new(); + if style.bold { + rendered.push_str("\u{1b}[1m"); + } + if style.dim { + rendered.push_str("\u{1b}[2m"); + } + rendered.push_str(&text); + rendered.push_str("\u{1b}[0m"); + rendered +} + +fn marker(summary: &LocalIdentitySummary) -> char { + if summary.is_default { '*' } else { '-' } +} pub(crate) fn render_inventory(inventory: &LocalInventory, ansi: bool) -> String { inventory_summaries(inventory) .into_iter() .map(|summary| { - render_line( - format!("- {}", compact_identity_label(summary)), - summary_line_style(summary), + render_block( + format!("{} {}", marker(summary), compact_identity_label(summary)), + style_for(summary), ansi, ) }) @@ -18,10 +52,10 @@ pub(crate) fn render_inventory(inventory: &LocalInventory, ansi: bool) -> String .join("\n") } -pub(crate) fn render_verbose_inventory(inventory: &LocalInventory, ansi: bool) -> String { +pub(crate) fn render_verbose_inventory(inventory: &LocalInventory, now: i64, ansi: bool) -> String { inventory_summaries(inventory) .into_iter() - .map(|summary| format_info(summary, ansi)) + .map(|summary| format_info(summary, now, ansi)) .collect::>() .join("\n\n") } @@ -34,51 +68,15 @@ fn inventory_summaries(inventory: &LocalInventory) -> Vec<&LocalIdentitySummary> } summaries.extend(&group.children); } - summaries.sort_by_key(|summary| !summary.is_default); + summaries.sort_by(|left, right| { + right + .is_default + .cmp(&left.is_default) + .then_with(|| left.target.short_name().cmp(right.target.short_name())) + }); summaries } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum LineStyle { - Plain, - Bold, - Dim, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SavedIdentityAction { - Applied, -} - -impl SavedIdentityAction { - fn verb(self) -> &'static str { - match self { - Self::Applied => "Applied", - } - } -} - -pub(crate) fn summary_line_style(summary: &LocalIdentitySummary) -> LineStyle { - match status_line_style(&summary.status) { - LineStyle::Dim => LineStyle::Dim, - LineStyle::Plain if summary.is_default => LineStyle::Bold, - LineStyle::Plain => LineStyle::Plain, - LineStyle::Bold => LineStyle::Bold, - } -} - -fn render_line(text: String, style: LineStyle, ansi: bool) -> String { - if !ansi { - return text; - } - - match style { - LineStyle::Plain => text, - LineStyle::Bold => text.bold().to_string(), - LineStyle::Dim => text.dim().to_string(), - } -} - pub(crate) fn compact_identity_label(summary: &LocalIdentitySummary) -> String { compact_identity_label_parts( summary.target.short_name(), @@ -102,26 +100,20 @@ pub(crate) fn compact_identity_label_parts( label } -pub(crate) fn status_line_style(status: &LocalIdentityStatus) -> LineStyle { - match status { - LocalIdentityStatus::Expired { .. } => LineStyle::Dim, - LocalIdentityStatus::Ready { .. } - | LocalIdentityStatus::Invalid { .. } - | LocalIdentityStatus::Incomplete { .. } => LineStyle::Plain, - } -} - pub(crate) fn format_current_default_suffix( name: &str, status: &LocalIdentityStatus, ansi: bool, ) -> String { - render_line( + render_block( format!( "(current: {})", compact_identity_label_parts(name, status, false) ), - LineStyle::Dim, + BlockStyle { + bold: false, + dim: true, + }, ansi, ) } @@ -129,70 +121,111 @@ pub(crate) fn format_current_default_suffix( #[cfg(test)] pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: bool) -> String { match choice { - InteractiveInventoryChoice::Saved(summary) => render_line( - compact_identity_label(summary), - summary_line_style(summary), - ansi, - ), - InteractiveInventoryChoice::Organization { target } => render_line( + InteractiveInventoryChoice::Saved(summary) => { + render_block(compact_identity_label(summary), style_for(summary), ansi) + } + InteractiveInventoryChoice::Organization { target } => render_block( format!("{} (not saved here)", target.short_name()), - LineStyle::Dim, + BlockStyle { + bold: false, + dim: true, + }, ansi, ), } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SavedIdentityAction { + Applied, +} + +impl SavedIdentityAction { + fn verb(self) -> &'static str { + match self { + Self::Applied => "Applied", + } + } +} + pub(crate) fn format_saved_identity_result( action: SavedIdentityAction, summary: &LocalIdentitySummary, _ansi: bool, ) -> String { - let mut result = format!("{} identity {}", action.verb(), summary.target.short_name()); - if let Some(chain) = summary.certificate_chain.as_deref() { - result.push_str(&format!(" as {chain}")); - } - result.push_str(&format!(" at {}", summary.saved_at.display())); - result -} - -pub(crate) fn format_info(summary: &LocalIdentitySummary, ansi: bool) -> String { - let mut lines = Vec::new(); - lines.push(render_line( - identity_summary_label(summary), - summary_line_style(summary), - ansi, - )); - lines.extend(detail_lines(summary)); - lines.join("\n") + format!( + "{} identity {} at {}", + action.verb(), + summary.target.short_name(), + summary.dir.display() + ) } -fn identity_summary_label(summary: &LocalIdentitySummary) -> String { - let mut label = summary.target.short_name().to_string(); - if !matches!(summary.status, LocalIdentityStatus::Ready { .. }) { - label.push_str(&format!(" [{}]", summary.status.label())); +pub(crate) fn format_info(summary: &LocalIdentitySummary, now: i64, ansi: bool) -> String { + let mut lines = vec![format!( + "{} {}:", + marker(summary), + compact_identity_label(summary) + )]; + if let Some(usage) = summary.usage { + lines.push(format!(" usage: {}", usage.label())); + } + if let Some(sequence) = summary.sequence { + lines.push(format!(" sequence: {sequence}")); + } + lines.push(format!(" dir: {}", summary.dir.display())); + if let (Some(valid_from), Some(expires_at)) = (summary.valid_from, summary.expires_at) { + lines.push(format!( + " validity: {}~{}", + format_slash_date(valid_from), + format_slash_date(expires_at) + )); } - if let Some(chain) = summary.certificate_chain.as_deref() { - label.push_str(&format!(" as {chain}")); + if let LocalIdentityStatus::Incomplete { detail } | LocalIdentityStatus::Invalid { detail } = + &summary.status + { + lines.push(format!(" reason: {detail}")); } - if summary.is_default { - label.push_str(" (default)"); + if let Some(warning) = warning(summary, now) { + lines.push(format!(" warn: {warning}")); } - label + + render_block(lines.join("\n"), style_for(summary), ansi) } -#[cfg(test)] -pub(crate) fn format_default_summary(summary: &LocalIdentitySummary, ansi: bool) -> String { - format_info(summary, ansi) +pub(crate) fn format_default_query(summary: &LocalIdentitySummary, now: i64) -> String { + let mut lines = vec![compact_identity_label_parts( + summary.target.short_name(), + &summary.status, + false, + )]; + if let Some(warning) = warning(summary, now) { + lines.push(format!("warn: {warning}")); + } + lines.join("\n") } -pub(crate) fn format_default_query(summary: &LocalIdentitySummary) -> String { - let name = summary.target.short_name(); +fn warning(summary: &LocalIdentitySummary, now: i64) -> Option { match summary.status { - LocalIdentityStatus::Expired { expired_at } => format!( - "{name}\n\nWARNING: This name expired on {}. Renew it in time or the name may be released!\n Run `genmeta identity renew {name}` to renew it.", - format_natural_date(expired_at), - ), - _ => name.to_string(), + LocalIdentityStatus::Expired { expired_at } => Some(format!( + "this name expired on {}, renew it in time or the name may be released", + format_natural_date(expired_at) + )), + LocalIdentityStatus::Invalid { .. } => { + Some("the certificate for this name is invalid".to_string()) + } + LocalIdentityStatus::Incomplete { .. } => { + Some("the local identity for this name is incomplete".to_string()) + } + LocalIdentityStatus::Ready { .. } => match (summary.valid_from, summary.expires_at) { + (Some(valid_from), Some(expires_at)) if is_near_expiry(valid_from, expires_at, now) => { + Some(format!( + "this name will expire on {}, renew it in time", + format_natural_date(expires_at) + )) + } + _ => None, + }, } } @@ -217,36 +250,16 @@ fn format_natural_date(timestamp: i64) -> String { format!("{:02} {month} {}", date.day(), date.year()) } -fn format_iso_date(timestamp: i64) -> String { - time::OffsetDateTime::from_unix_timestamp(timestamp) +fn format_slash_date(timestamp: i64) -> String { + let date = time::OffsetDateTime::from_unix_timestamp(timestamp) .expect("certificate timestamp should fit OffsetDateTime") - .date() - .to_string() -} - -fn detail_lines(summary: &LocalIdentitySummary) -> Vec { - let mut lines = Vec::new(); - if let Some(issuer) = summary.issuer.as_deref() { - lines.push(format!("Issuer: {issuer}")); - } - if let Some(valid_from) = summary.valid_from { - lines.push(format!("Valid from: {}", format_iso_date(valid_from))); - } - if let Some(expires_at) = summary.status.expires_at() { - let label = if matches!(summary.status, LocalIdentityStatus::Expired { .. }) { - "Expired" - } else { - "Expires" - }; - lines.push(format!("{label}: {}", format_iso_date(expires_at))); - } - lines.push(format!("dir: {}", summary.saved_at.display())); - if let LocalIdentityStatus::Incomplete { detail } | LocalIdentityStatus::Invalid { detail } = - &summary.status - { - lines.push(format!("reason: {detail}")); - } - lines + .date(); + format!( + "{:04}/{:02}/{:02}", + date.year(), + u8::from(date.month()), + date.day() + ) } #[cfg(test)] @@ -254,341 +267,231 @@ mod tests { use std::path::PathBuf; use super::{ - LineStyle, SavedIdentityAction, compact_identity_label, format_current_default_suffix, - format_default_query, format_default_summary, format_info, format_saved_identity_result, - render_choice_label, render_inventory, render_verbose_inventory, summary_line_style, + SavedIdentityAction, format_current_default_suffix, format_default_query, format_info, + format_saved_identity_result, render_choice_label, render_inventory, + render_verbose_inventory, }; use crate::cli::flow::{ local::{ - InteractiveInventoryChoice, LocalIdentityStatus, LocalIdentitySummary, build_inventory, + IdentityUsage, InteractiveInventoryChoice, LocalIdentityStatus, LocalIdentitySummary, + build_inventory, }, target::IdentityTarget, }; - const EXPIRES_AT: i64 = 1_794_298_364; + const NOW: i64 = 1_784_026_800; // 14 Jul 2026 UTC + + fn timestamp(year: i32, month: time::Month, day: u8) -> i64 { + time::Date::from_calendar_date(year, month, day) + .unwrap() + .midnight() + .assume_utc() + .unix_timestamp() + } fn summary( name: &str, is_default: bool, status: LocalIdentityStatus, - chain: Option<&str>, + usage: Option, + sequence: Option, + valid_from: Option, + expires_at: Option, ) -> LocalIdentitySummary { LocalIdentitySummary { target: IdentityTarget::parse(name).unwrap(), - certificate_chain: chain.map(ToOwned::to_owned), - valid_from: Some(1_700_000_000), - issuer: Some("CN=Genmeta Test CA".to_string()), + usage, + sequence, + valid_from, + expires_at, status, - saved_at: PathBuf::from(format!("/tmp/{name}")), + dir: PathBuf::from(format!("/tmp/{name}")), is_default, } } - #[test] - fn formats_info_with_dir_detail() { - let profile = summary( - "phone.alice.smith", - true, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("secondary:2"), - ); - - let rendered = format_info(&profile, false); - assert_eq!( - rendered, - "phone.alice.smith as secondary:2 (default)\nIssuer: CN=Genmeta Test CA\nValid from: 2023-11-14\nExpires: 2026-11-10\ndir: /tmp/phone.alice.smith" - ); - assert_eq!(format_default_summary(&profile, false), rendered); - } - - #[test] - fn formats_saved_identity_as_one_line_result() { - let profile = summary( - "alice.smith", - false, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("primary:0"), - ); - - assert_eq!( - format_saved_identity_result(SavedIdentityAction::Applied, &profile, false), - "Applied identity alice.smith as primary:0 at /tmp/alice.smith" - ); - } - - #[test] - fn expired_default_query_keeps_name_and_actionable_warning() { - let profile = summary( + fn ready_default() -> LocalIdentitySummary { + let valid_from = timestamp(2026, time::Month::July, 8); + let expires_at = timestamp(2027, time::Month::July, 8); + summary( "alice.smith", true, - LocalIdentityStatus::Expired { - expired_at: 1_783_478_400, - }, - Some("primary:0"), - ); - - assert_eq!( - format_default_query(&profile), - "alice.smith\n\nWARNING: This name expired on 08 Jul 2026. Renew it in time or the name may be released!\n Run `genmeta identity renew alice.smith` to renew it.", - ); - } - - #[test] - fn ready_default_line_prefers_bold() { - let profile = summary( + LocalIdentityStatus::Ready { expires_at }, + Some(IdentityUsage::BothClientAndServer), + Some(0), + Some(valid_from), + Some(expires_at), + ) + } + + fn near_expiry() -> LocalIdentitySummary { + let valid_from = timestamp(2025, time::Month::September, 1); + let expires_at = timestamp(2026, time::Month::September, 1); + summary( "alice.smith", true, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("primary:0"), - ); - - assert_eq!(summary_line_style(&profile), LineStyle::Bold); - } - - #[test] - fn expired_line_is_dimmed() { - let profile = summary( + LocalIdentityStatus::Ready { expires_at }, + Some(IdentityUsage::BothClientAndServer), + Some(0), + Some(valid_from), + Some(expires_at), + ) + } + + fn expired(is_default: bool) -> LocalIdentitySummary { + let valid_from = timestamp(2025, time::Month::July, 8); + let expires_at = timestamp(2026, time::Month::July, 8); + summary( "alice.smith", - false, + is_default, LocalIdentityStatus::Expired { - expired_at: EXPIRES_AT, + expired_at: expires_at, }, - Some("primary:0"), - ); - - assert_eq!(summary_line_style(&profile), LineStyle::Dim); + Some(IdentityUsage::BothClientAndServer), + Some(0), + Some(valid_from), + Some(expires_at), + ) } - #[test] - fn compact_label_hides_ready_status() { - let profile = summary( - "alice.smith", - false, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("primary:0"), - ); - - assert_eq!(compact_identity_label(&profile), "alice.smith"); - } - - #[test] - fn invalid_default_line_remains_bold_and_visible() { - let profile = summary( - "alice.smith", - true, + fn invalid(name: &str, is_default: bool) -> LocalIdentitySummary { + summary( + name, + is_default, LocalIdentityStatus::Invalid { detail: "certificate is unreadable".to_string(), }, None, - ); - - assert_eq!(summary_line_style(&profile), LineStyle::Bold); + None, + None, + None, + ) } - #[test] - fn formats_invalid_identity_with_reason_detail() { - let profile = summary( - "alice.smith", + fn incomplete(name: &str) -> LocalIdentitySummary { + let valid_from = timestamp(2026, time::Month::July, 8); + let expires_at = timestamp(2027, time::Month::July, 8); + summary( + name, false, - LocalIdentityStatus::Invalid { - detail: "certificate chain metadata is invalid".to_string(), + LocalIdentityStatus::Incomplete { + detail: "private key is missing".to_string(), }, - None, - ); - - let rendered = format_info(&profile, false); - assert!(rendered.starts_with("alice.smith [invalid]")); - assert!(rendered.contains("\ndir: /tmp/alice.smith")); - assert!(rendered.contains("\nreason: certificate chain metadata is invalid")); + Some(IdentityUsage::BothClientAndServer), + Some(0), + Some(valid_from), + Some(expires_at), + ) } #[test] - fn current_default_suffix_uses_compact_label_text() { + fn detailed_renderer_matches_ready_near_expired_invalid_and_incomplete_contract() { assert_eq!( - format_current_default_suffix( - "meng.lin", - &LocalIdentityStatus::Invalid { - detail: "certificate is unreadable".to_string(), - }, - false, - ), - "(current: meng.lin [invalid])" + format_info(&ready_default(), NOW, false), + "* alice.smith (default):\n usage: both client and server\n sequence: 0\n dir: /tmp/alice.smith\n validity: 2026/07/08~2027/07/08" ); + assert!( + format_info(&near_expiry(), NOW, false) + .ends_with(" warn: this name will expire on 01 Sep 2026, renew it in time") + ); + assert!(format_info(&expired(false), NOW, false).ends_with( + " warn: this name expired on 08 Jul 2026, renew it in time or the name may be released" + )); + assert_eq!( + format_info(&invalid("alice.smith", false), NOW, false), + "- alice.smith [invalid]:\n dir: /tmp/alice.smith\n reason: certificate is unreadable\n warn: the certificate for this name is invalid" + ); + assert!(format_info(&incomplete("alice.smith"), NOW, false).contains( + " reason: private key is missing\n warn: the local identity for this name is incomplete" + )); } #[test] - fn renders_flat_compact_inventory_lines() { - let inventory = build_inventory(vec![ - summary( - "tablet.reimu.scarlet", - false, - LocalIdentityStatus::Expired { - expired_at: EXPIRES_AT, - }, - Some("secondary:1"), - ), - summary( - "tv.alice.smith", - false, - LocalIdentityStatus::Incomplete { - detail: "private key missing".to_string(), - }, - None, - ), - summary( - "phone.alice.smith", - false, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("secondary:2"), - ), - summary( - "alice.smith", - true, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("primary:0"), - ), - ]); - - let expected = "\ -- alice.smith (default)\n\ -- phone.alice.smith\n\ -- tv.alice.smith [incomplete]\n\ -- tablet.reimu.scarlet [expired]"; - - assert_eq!(render_inventory(&inventory, false), expected); - } - - #[test] - fn default_sub_identity_is_the_first_list_entry() { + fn compact_inventory_is_default_first_then_canonical_name() { + let mut expired = expired(false); + expired.target = IdentityTarget::parse("bruce.lee").unwrap(); + expired.dir = PathBuf::from("/tmp/bruce.lee"); let inventory = build_inventory(vec![ + invalid("phone.alice.smith", false), summary( - "alice.smith", + "luffy.monkey", false, LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, + expires_at: timestamp(2027, time::Month::July, 8), }, - Some("primary:0"), - ), - summary( - "phone.alice.smith", - true, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("secondary:1"), + Some(IdentityUsage::BothClientAndServer), + Some(0), + Some(timestamp(2026, time::Month::July, 8)), + Some(timestamp(2027, time::Month::July, 8)), ), + incomplete("tablet.alice.smith"), + expired, + ready_default(), ]); assert_eq!( render_inventory(&inventory, false), - "- phone.alice.smith (default)\n- alice.smith" + "* alice.smith (default)\n- bruce.lee [expired]\n- luffy.monkey\n- phone.alice.smith [invalid]\n- tablet.alice.smith [incomplete]" ); } #[test] - fn verbose_inventory_reuses_info_renderer() { - let inventory = build_inventory(vec![summary( - "alice.smith", - true, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("primary:0"), - )]); - - let summary = &match &inventory.groups[0].root { - crate::cli::flow::local::LocalInventoryRoot::Saved(summary) => summary, - crate::cli::flow::local::LocalInventoryRoot::Organization { .. } => unreachable!(), - }; - assert_eq!( - render_verbose_inventory(&inventory, false), - format_info(summary, false) + fn default_and_abnormal_styles_wrap_the_complete_detail_block() { + let default = format_info(&ready_default(), NOW, true); + assert!(default.starts_with("\u{1b}[1m"), "{default:?}"); + assert!(default.ends_with("\u{1b}[0m"), "{default:?}"); + assert_eq!(default.matches("\u{1b}[1m").count(), 1, "{default:?}"); + + let abnormal_default = format_info(&expired(true), NOW, true); + assert!( + abnormal_default.contains("\u{1b}[1m"), + "{abnormal_default:?}" + ); + assert!( + abnormal_default.contains("\u{1b}[2m"), + "{abnormal_default:?}" ); + assert!( + abnormal_default.contains(" validity:"), + "{abnormal_default:?}" + ); + assert_eq!(abnormal_default.matches("\u{1b}[0m").count(), 1); } #[test] - fn renew_chain_key_labels_include_parent_root_before_child() { - let labels = vec![ - render_choice_label( - &InteractiveInventoryChoice::Organization { - target: IdentityTarget::parse("alice.ma").unwrap(), - }, - false, - ), - render_choice_label( - &InteractiveInventoryChoice::Saved(summary( - "shanghai.alice.ma", - false, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("secondary:1"), - )), - false, - ), - ]; - + fn default_query_uses_compact_warning_fields() { assert_eq!( - labels, - vec![ - "alice.ma (not saved here)".to_string(), - "shanghai.alice.ma".to_string(), - ] + format_default_query(&expired(false), NOW), + "alice.smith [expired]\nwarn: this name expired on 08 Jul 2026, renew it in time or the name may be released" + ); + assert_eq!( + format_default_query(&invalid("alice.smith", false), NOW), + "alice.smith [invalid]\nwarn: the certificate for this name is invalid" ); } #[test] - fn renders_choice_labels_without_ansi_effects() { - let labels = vec![ - render_choice_label( - &InteractiveInventoryChoice::Saved(summary( - "alice.smith", - true, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("primary:0"), - )), - false, - ), - render_choice_label( - &InteractiveInventoryChoice::Organization { - target: IdentityTarget::parse("reimu.scarlet").unwrap(), - }, - false, - ), - render_choice_label( - &InteractiveInventoryChoice::Saved(summary( - "tablet.reimu.scarlet", - false, - LocalIdentityStatus::Ready { - expires_at: EXPIRES_AT, - }, - Some("secondary:1"), - )), - false, - ), - ]; + fn verbose_inventory_reuses_info_renderer() { + let inventory = build_inventory(vec![ready_default(), incomplete("tablet.alice.smith")]); + let rendered = render_verbose_inventory(&inventory, NOW, false); + assert!(rendered.starts_with("* alice.smith (default):")); + assert!(rendered.contains("\n\n- tablet.alice.smith [incomplete]:")); + } + #[test] + fn saved_result_and_prompt_helpers_remain_stable() { + let profile = ready_default(); + assert_eq!( + format_saved_identity_result(SavedIdentityAction::Applied, &profile, false), + "Applied identity alice.smith at /tmp/alice.smith" + ); + assert_eq!( + format_current_default_suffix("alice.smith", &profile.status, false), + "(current: alice.smith)" + ); assert_eq!( - labels, - vec![ - "alice.smith (default)".to_string(), - "reimu.scarlet (not saved here)".to_string(), - "tablet.reimu.scarlet".to_string(), - ] + render_choice_label(&InteractiveInventoryChoice::Saved(profile), false), + "alice.smith (default)" ); } } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 59bb2eb..e24e671 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -127,7 +127,7 @@ async fn ensure_saved_renew_target( dhttp_home: &DhttpHome, name: dhttp::name::DhttpName<'_>, ) -> Result<(), Error> { - if local::try_load_summary(dhttp_home, name.borrow(), None) + if local::try_load_summary_exact(dhttp_home, name.borrow(), None) .await? .is_some() { From 2eb7d2044f6d3634beb4c129423a74194585deeb Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 00:44:23 +0800 Subject: [PATCH 09/27] feat(identity): resolve apply targets before usage --- genmeta-identity/src/cli.rs | 64 +---- genmeta-identity/src/cli/flow/apply.rs | 310 +++++++++++++++--------- genmeta-identity/src/cli/flow/target.rs | 131 +++++++++- genmeta-identity/src/cli/prompt.rs | 19 +- 4 files changed, 346 insertions(+), 178 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index a4fc7d4..4cef22b 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -172,27 +172,6 @@ impl LocalIdentitySave { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LocalReplacementDecision { - New, - Replace, - RequireFlag, -} - -fn local_replacement_decision( - profile_exists: bool, - replace_local: bool, - interactive: bool, -) -> LocalReplacementDecision { - if !profile_exists { - LocalReplacementDecision::New - } else if replace_local || interactive { - LocalReplacementDecision::Replace - } else { - LocalReplacementDecision::RequireFlag - } -} - #[tracing::instrument()] async fn load_current_settings(dhttp_home: &DhttpHome) -> Result, Error> { match dhttp_home.load_settings().await { @@ -232,24 +211,6 @@ async fn resolve_default_target_name(dhttp_home: &DhttpHome) -> Result, - force: bool, -) -> Result { - let profile_exists = dhttp_home - .identity_profile_exists_exactly(name.clone()) - .await; - match local_replacement_decision(profile_exists, force, std::io::stdin().is_terminal()) { - LocalReplacementDecision::New => Ok(LocalIdentitySave::New), - LocalReplacementDecision::Replace => Ok(LocalIdentitySave::Replace), - LocalReplacementDecision::RequireFlag => Err(prompt::Error::NotInteractive { - hint: "--force".into(), - } - .into()), - } -} - async fn acquire_verify_code( cert_server: &CertServer, email: &str, @@ -582,9 +543,8 @@ mod tests { use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use super::{ - Apply, Cli, Default, Info, LocalReplacementDecision, Options, cert_server_base_url, - certificate_chain_key_from_identity, local_replacement_decision, - save_identity_progress_message, + Apply, Cli, Default, Info, Options, cert_server_base_url, + certificate_chain_key_from_identity, save_identity_progress_message, }; use crate::CERT_SERVER_BASE_URL; @@ -762,26 +722,6 @@ mod tests { assert!(rendered.contains("--sequence"), "{rendered}"); } - #[test] - fn local_replacement_has_no_deleted_confirmation_copy() { - assert_eq!( - local_replacement_decision(true, false, true), - LocalReplacementDecision::Replace - ); - assert_eq!( - local_replacement_decision(true, false, false), - LocalReplacementDecision::RequireFlag - ); - assert_eq!( - local_replacement_decision(true, true, false), - LocalReplacementDecision::Replace - ); - assert_eq!( - local_replacement_decision(false, false, true), - LocalReplacementDecision::New - ); - } - #[test] fn save_progress_uses_user_task_copy() { assert_eq!(save_identity_progress_message(), "Saving identity..."); diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 3722c01..55e79ce 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -5,7 +5,10 @@ use snafu::{FromString, OptionExt, whatever}; use super::{ kind::IdentityKind, - target::{IdentityLevel, IdentityTarget}, + target::{ + IdentityLevel, IdentityTarget, RemoteTargetState, ReplacementRequirement, + ResolvedApplyTarget, remote_state_from_availability, replacement_requirement, + }, }; use crate::{ cert_server::CertServer, @@ -224,105 +227,131 @@ fn resolve_non_interactive_approval_plan(identity_auth_domain: Option<&str>) -> } fn apply_identity_name_opening() -> &'static str { - "Apply an identity here." + "Applying identity, generating ECC key pair locally, then requesting and deploying certificate." } fn interactive_name_unavailable_message() -> &'static str { "Sorry, this name is not available. Please try another one." } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InteractiveNameAvailability { - Applicable, - Retry, -} - -fn classify_interactive_name_availability( - target: &IdentityTarget, - availability: &str, -) -> Result { - match availability { - "conflict" => Ok(InteractiveNameAvailability::Applicable), - "available" if target.level() == IdentityLevel::SubIdentity => { - Ok(InteractiveNameAvailability::Applicable) - } - "available" | "reserved" | "unavailable" => Ok(InteractiveNameAvailability::Retry), - other => Err(Error::without_source(format!( - "cert server returned unsupported domain availability: {other}" - ))), - } -} - -fn explicit_target_from_command( - command: &Apply, -) -> Result>, Error> { +fn explicit_target_from_command(command: &Apply) -> Result, Error> { command .name .as_deref() - .map(cli::parse_identity_name) + .map(IdentityTarget::parse) .transpose() + .map_err(Error::from) } -async fn prompt_apply_target_with_opening( - opening: &'static str, -) -> Result, Error> { - let identity = crate::cli::prompt::prompt_identity_name(opening) +async fn prompt_apply_target() -> Result { + let identity = crate::cli::prompt::prompt_identity_name("") .await .require_interactive("IDENTITY")?; - cli::parse_identity_name(&identity) -} - -async fn prompt_apply_target() -> Result, Error> { - prompt_apply_target_with_opening(apply_identity_name_opening()).await + Ok(IdentityTarget::parse(&identity)?) } async fn prompt_apply_target_with_online_validation( + dhttp_home: &DhttpHome, cert_server: &CertServer, -) -> Result, Error> { - let mut opening = apply_identity_name_opening(); +) -> Result { loop { - let name = prompt_apply_target_with_opening(opening).await?; - opening = ""; - let target = IdentityTarget::parse(name.as_partial())?; - let response = match super::progress::run( - super::progress::CHECK_NAME, - cert_server.inspect_domain_availability(name.as_full()), - ) - .await - { - Ok(response) => response, - Err(error) if error.is_api_code("domain_invalid") => { + let target = prompt_apply_target().await?; + let inspected = super::progress::run(super::progress::CHECK_NAME, async { + let local = + super::local::try_load_summary_exact(dhttp_home, target.dhttp_name(), None).await?; + let remote = cert_server + .inspect_domain_availability(target.full_name()) + .await?; + Ok::<_, Error>((local, remote)) + }) + .await; + let (local, response) = match inspected { + Ok(inspected) => inspected, + Err(Error::CertServer { source }) if source.is_api_code("domain_invalid") => { crate::cli::flow::transcript::print_err_block( interactive_name_unavailable_message(), ); continue; } - Err(error) => return Err(error.into()), + Err(error) => return Err(error), }; - match classify_interactive_name_availability(&target, &response.availability)? { - InteractiveNameAvailability::Applicable => return Ok(name), - InteractiveNameAvailability::Retry => { + let remote = remote_state_from_availability(&response.availability) + .map_err(|error| Error::without_source(error.to_string()))?; + match remote { + RemoteTargetState::Exists | RemoteTargetState::Missing => { + return Ok(ResolvedApplyTarget { + target, + remote, + local, + }); + } + RemoteTargetState::Unavailable => { crate::cli::flow::transcript::print_err_block( interactive_name_unavailable_message(), ); } + RemoteTargetState::Unknown => unreachable!("pricing inspection returns known state"), } } } -async fn resolve_target( +async fn resolve_apply_target( command: &Apply, - interactive_cert_server: Option<&CertServer>, -) -> Result, Error> { + dhttp_home: &DhttpHome, + cert_server: &CertServer, + interactive: bool, +) -> Result { match explicit_target_from_command(command)? { - Some(name) => Ok(name), - None => match interactive_cert_server { - Some(cert_server) => prompt_apply_target_with_online_validation(cert_server).await, - None => prompt_apply_target().await, - }, + Some(target) => { + let local = super::progress::run( + super::progress::CHECK_NAME, + super::local::try_load_summary_exact(dhttp_home, target.dhttp_name(), None), + ) + .await?; + Ok(ResolvedApplyTarget { + target, + remote: RemoteTargetState::Unknown, + local, + }) + } + None if interactive => { + prompt_apply_target_with_online_validation(dhttp_home, cert_server).await + } + None => Err(crate::cli::prompt::Error::NotInteractive { + hint: "IDENTITY".into(), + } + .into()), } } +async fn authorize_local_replacement( + resolved: &ResolvedApplyTarget, + force: bool, + interactive: bool, +) -> Result { + let save = if resolved.local.is_some() { + cli::LocalIdentitySave::Replace + } else { + cli::LocalIdentitySave::New + }; + if replacement_requirement(resolved.local.as_ref()) == ReplacementRequirement::None || force { + return Ok(save); + } + if !interactive { + return Err(crate::cli::prompt::Error::NotInteractive { + hint: "--force".into(), + } + .into()); + } + if !crate::cli::prompt::prompt_local_replacement() + .await + .require_interactive("--force")? + { + whatever!("apply was cancelled"); + } + Ok(save) +} + async fn resolve_kind(command: &Apply) -> Result { match command.kind.as_deref() { Some(kind) => Ok(kind.parse::()?), @@ -477,15 +506,15 @@ async fn run_interactive_with_policy( let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? .and_then(|config| config.settings().default_identity_name().cloned()); - let initial_target = explicit_target_from_command(command)?; - let mut state = InteractiveApplyState::from_command(command, initial_target)?; + let resolved = resolve_apply_target(command, dhttp_home, cert_server, true).await?; + let local_identity_save = authorize_local_replacement(&resolved, command.force, true).await?; + let _remote = resolved.remote; + let mut state = InteractiveApplyState::from_command( + command, + Some(resolved.target.clone().into_dhttp_name()), + )?; loop { - if state.target.is_none() { - state.target = Some(prompt_apply_target_with_online_validation(cert_server).await?); - continue; - } - if state.kind.is_none() || state.kind_prompt_required { state.kind = Some( crate::cli::prompt::prompt_kind_with_cursor(state.kind) @@ -686,12 +715,6 @@ async fn run_interactive_with_policy( ), ) .await?; - let local_identity_save = cli::ensure_replace_local_allowed( - dhttp_home, - domain.borrow(), - command.force, - ) - .await?; cli::save_identity( dhttp_home, &domain, @@ -801,8 +824,6 @@ async fn run_interactive_with_policy( } }; - let local_identity_save = - cli::ensure_replace_local_allowed(dhttp_home, domain.borrow(), command.force).await?; cli::save_identity( dhttp_home, &domain, @@ -836,6 +857,7 @@ pub(crate) async fn run_with_policy( cert_server: &CertServer, post_save: ApplyPostSavePolicy, ) -> Result<(), Error> { + crate::cli::flow::transcript::print_block(apply_identity_name_opening()); let is_interactive = std::io::stdin().is_terminal(); if is_interactive { return match run_interactive_with_policy( @@ -854,8 +876,11 @@ pub(crate) async fn run_with_policy( let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? .and_then(|config| config.settings().default_identity_name().cloned()); - let domain = resolve_target(command, is_interactive.then_some(cert_server)).await?; - let target = IdentityTarget::parse(domain.as_partial())?; + let resolved = resolve_apply_target(command, dhttp_home, cert_server, false).await?; + let local_identity_save = authorize_local_replacement(&resolved, command.force, false).await?; + let _remote = resolved.remote; + let target = resolved.target; + let domain = target.dhttp_name().into_owned(); let kind = resolve_kind(command).await?; let device_name = super::device::resolve_device_name(command.device_name.as_deref(), home_scope); @@ -865,8 +890,6 @@ pub(crate) async fn run_with_policy( } let approval_plan = resolve_non_interactive_approval_plan(auth_plan.first_identity_full_name()); - let local_identity_save = - cli::ensure_replace_local_allowed(dhttp_home, domain.borrow(), command.force).await?; let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; let detail = match approval_plan { ApplyApprovalPlan::Email => { @@ -1027,15 +1050,99 @@ pub(crate) async fn run( #[cfg(test)] mod tests { use super::{ - ApplyApprovalPlan, ApplyVerifyCodeAction, InteractiveApplyState, - InteractiveNameAvailability, MissingTargetAction, apply_identity_name_opening, - apply_verify_code_actions, classify_apply_email_issue_error, - classify_interactive_name_availability, explicit_target_from_command, + ApplyApprovalPlan, ApplyVerifyCodeAction, InteractiveApplyState, MissingTargetAction, + apply_identity_name_opening, apply_verify_code_actions, authorize_local_replacement, + classify_apply_email_issue_error, explicit_target_from_command, interactive_name_unavailable_message, missing_root_target_error, missing_target_action, new_identity_confirmation_message, preserve_apply_email_issue_error, - preserve_apply_registration_error, resolve_non_interactive_approval_plan, + preserve_apply_registration_error, resolve_apply_target, + resolve_non_interactive_approval_plan, + }; + use crate::cli::{ + Apply, LocalIdentitySave, + flow::{ + local::{IdentityUsage, LocalIdentityStatus, LocalIdentitySummary}, + target::{IdentityTarget, RemoteTargetState, ResolvedApplyTarget}, + }, }; - use crate::cli::{Apply, flow::target::IdentityTarget}; + + fn command(name: &str) -> Apply { + Apply { + name: Some(name.to_string()), + kind: Some("primary".to_string()), + force: false, + device_name: None, + email: None, + verify_code: None, + } + } + + fn resolved_with(status: Option) -> ResolvedApplyTarget { + ResolvedApplyTarget { + target: IdentityTarget::parse("alice.smith").unwrap(), + remote: RemoteTargetState::Unknown, + local: status.map(|status| LocalIdentitySummary { + target: IdentityTarget::parse("alice.smith").unwrap(), + usage: Some(IdentityUsage::BothClientAndServer), + sequence: Some(0), + valid_from: Some(1_700_000_000), + expires_at: Some(1_900_000_000), + status, + dir: std::path::PathBuf::from("/tmp/alice.smith"), + is_default: false, + }), + } + } + + #[tokio::test] + async fn replacement_authorization_prevents_noninteractive_side_effects_without_force() { + let error = authorize_local_replacement( + &resolved_with(Some(LocalIdentityStatus::Ready { + expires_at: 1_900_000_000, + })), + false, + false, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("--force"), "{error}"); + + assert_eq!( + authorize_local_replacement( + &resolved_with(Some(LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".to_string(), + })), + false, + false, + ) + .await + .unwrap(), + LocalIdentitySave::Replace + ); + assert_eq!( + authorize_local_replacement(&resolved_with(None), false, false) + .await + .unwrap(), + LocalIdentitySave::New + ); + } + + #[tokio::test] + async fn explicit_target_skips_advisory_remote_inspection() { + _ = rustls::crypto::ring::default_provider().install_default(); + let home = dhttp::home::DhttpHome::new(std::env::temp_dir().join(format!( + "genmeta-identity-explicit-target-{}", + std::process::id() + ))); + let server = crate::cert_server::CertServer::new("http://127.0.0.1:1").unwrap(); + + let resolved = resolve_apply_target(&command("alice.smith"), &home, &server, false) + .await + .unwrap(); + + assert_eq!(resolved.remote, RemoteTargetState::Unknown); + assert!(resolved.local.is_none()); + } #[test] fn stay_recovery_keeps_apply_verify_state() { @@ -1166,35 +1273,10 @@ mod tests { #[test] fn apply_identity_name_opening_matches_spec_copy() { - assert_eq!(apply_identity_name_opening(), "Apply an identity here."); - } - - #[test] - fn interactive_name_availability_respects_target_level() { - let root = IdentityTarget::parse("alice.smith").unwrap(); - let child = IdentityTarget::parse("phone.alice.smith").unwrap(); - - assert_eq!( - classify_interactive_name_availability(&root, "conflict").unwrap(), - InteractiveNameAvailability::Applicable - ); - assert_eq!( - classify_interactive_name_availability(&child, "available").unwrap(), - InteractiveNameAvailability::Applicable - ); - assert_eq!( - classify_interactive_name_availability(&root, "available").unwrap(), - InteractiveNameAvailability::Retry - ); - assert_eq!( - classify_interactive_name_availability(&child, "reserved").unwrap(), - InteractiveNameAvailability::Retry - ); assert_eq!( - classify_interactive_name_availability(&child, "unavailable").unwrap(), - InteractiveNameAvailability::Retry + apply_identity_name_opening(), + "Applying identity, generating ECC key pair locally, then requesting and deploying certificate." ); - assert!(classify_interactive_name_availability(&child, "future-status").is_err()); } #[test] diff --git a/genmeta-identity/src/cli/flow/target.rs b/genmeta-identity/src/cli/flow/target.rs index 2d2df25..42129cc 100644 --- a/genmeta-identity/src/cli/flow/target.rs +++ b/genmeta-identity/src/cli/flow/target.rs @@ -3,6 +3,62 @@ use std::fmt; use dhttp::name::{DhttpName, InvalidDhttpName}; use snafu::{ResultExt, Snafu}; +use super::local::{LocalIdentityStatus, LocalIdentitySummary}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteTargetState { + Unknown, + Exists, + Missing, + Unavailable, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedApplyTarget { + pub(crate) target: IdentityTarget, + pub(crate) remote: RemoteTargetState, + pub(crate) local: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReplacementRequirement { + None, + Confirm, +} + +#[derive(Debug, Snafu)] +#[snafu(display("cert server returned unsupported domain availability: {availability}"))] +pub(crate) struct UnsupportedRemoteTargetState { + availability: String, +} + +pub(crate) fn remote_state_from_availability( + availability: &str, +) -> Result { + match availability { + "conflict" => Ok(RemoteTargetState::Exists), + "available" => Ok(RemoteTargetState::Missing), + "reserved" | "unavailable" => Ok(RemoteTargetState::Unavailable), + availability => Err(UnsupportedRemoteTargetState { + availability: availability.to_string(), + }), + } +} + +pub(crate) fn replacement_requirement( + summary: Option<&LocalIdentitySummary>, +) -> ReplacementRequirement { + match summary.map(|summary| &summary.status) { + Some(LocalIdentityStatus::Ready { .. } | LocalIdentityStatus::Expired { .. }) => { + ReplacementRequirement::Confirm + } + None + | Some(LocalIdentityStatus::Invalid { .. } | LocalIdentityStatus::Incomplete { .. }) => { + ReplacementRequirement::None + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum IdentityLevel { Identity, @@ -111,9 +167,82 @@ impl ParseIdentityTargetError { #[cfg(test)] mod tests { + use std::path::PathBuf; + use dhttp::name::DhttpName; - use super::{IdentityLevel, IdentityTarget}; + use super::{ + IdentityLevel, IdentityTarget, RemoteTargetState, ReplacementRequirement, + remote_state_from_availability, replacement_requirement, + }; + use crate::cli::flow::local::{IdentityUsage, LocalIdentityStatus, LocalIdentitySummary}; + + fn summary(status: LocalIdentityStatus) -> LocalIdentitySummary { + LocalIdentitySummary { + target: IdentityTarget::parse("alice.smith").unwrap(), + usage: Some(IdentityUsage::BothClientAndServer), + sequence: Some(0), + valid_from: Some(1_700_000_000), + expires_at: Some(1_900_000_000), + status, + dir: PathBuf::from("/tmp/alice.smith"), + is_default: false, + } + } + + #[test] + fn availability_maps_existing_and_missing_targets_without_rejecting_apply() { + assert_eq!( + remote_state_from_availability("conflict").unwrap(), + RemoteTargetState::Exists + ); + assert_eq!( + remote_state_from_availability("available").unwrap(), + RemoteTargetState::Missing + ); + assert_eq!( + remote_state_from_availability("reserved").unwrap(), + RemoteTargetState::Unavailable + ); + assert_eq!( + remote_state_from_availability("unavailable").unwrap(), + RemoteTargetState::Unavailable + ); + } + + #[test] + fn replacement_decision_only_blocks_ready_and_expired_material() { + let ready = summary(LocalIdentityStatus::Ready { + expires_at: 1_900_000_000, + }); + let expired = summary(LocalIdentityStatus::Expired { + expired_at: 1_600_000_000, + }); + let invalid = summary(LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".to_string(), + }); + let incomplete = summary(LocalIdentityStatus::Incomplete { + detail: "private key is missing".to_string(), + }); + + assert_eq!(replacement_requirement(None), ReplacementRequirement::None); + assert_eq!( + replacement_requirement(Some(&ready)), + ReplacementRequirement::Confirm + ); + assert_eq!( + replacement_requirement(Some(&expired)), + ReplacementRequirement::Confirm + ); + assert_eq!( + replacement_requirement(Some(&invalid)), + ReplacementRequirement::None + ); + assert_eq!( + replacement_requirement(Some(&incomplete)), + ReplacementRequirement::None + ); + } #[test] fn parses_short_and_full_identity_names() { diff --git a/genmeta-identity/src/cli/prompt.rs b/genmeta-identity/src/cli/prompt.rs index e580645..006d2b5 100644 --- a/genmeta-identity/src/cli/prompt.rs +++ b/genmeta-identity/src/cli/prompt.rs @@ -120,6 +120,18 @@ pub(crate) fn more_options_help_message() -> &'static str { "Type ? for more options." } +pub(crate) fn local_replacement_prompt_message() -> &'static str { + "This identity already exists locally. Continue and replace it?" +} + +pub(crate) async fn prompt_local_replacement() -> Result { + sync!( + inquire::Confirm::new(local_replacement_prompt_message()) + .with_default(false) + .prompt() + ) +} + pub(crate) async fn prompt_email() -> Result { prompt_email_with_default(None).await } @@ -275,7 +287,8 @@ mod tests { use super::{ MoreOptionsFriendlyValidator, email_prompt_message, identity_name_help_message, - identity_name_prompt_message, more_options_help_message, verify_code_prompt_message, + identity_name_prompt_message, local_replacement_prompt_message, more_options_help_message, + verify_code_prompt_message, }; #[test] @@ -288,6 +301,10 @@ mod tests { assert_eq!(email_prompt_message(), "Enter your email:"); assert_eq!(verify_code_prompt_message(), "Enter verification code:"); assert_eq!(more_options_help_message(), "Type ? for more options."); + assert_eq!( + local_replacement_prompt_message(), + "This identity already exists locally. Continue and replace it?" + ); } #[test] From 688e77ec18dce4061c55394786e112defd3df649 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 00:52:44 +0800 Subject: [PATCH 10/27] feat(identity): load authentication candidates lazily --- genmeta-identity/src/auth.rs | 166 +++---- genmeta-identity/src/cli/flow/apply.rs | 71 +-- genmeta-identity/src/cli/flow/auth_plan.rs | 451 ++++++++++++-------- genmeta-identity/src/cli/flow/renew.rs | 60 +-- genmeta-identity/src/cli/flow/transcript.rs | 13 + 5 files changed, 418 insertions(+), 343 deletions(-) diff --git a/genmeta-identity/src/auth.rs b/genmeta-identity/src/auth.rs index 80f7d48..5f97a9b 100644 --- a/genmeta-identity/src/auth.rs +++ b/genmeta-identity/src/auth.rs @@ -1,57 +1,26 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthFailureKind { - MissingIdentity, - MtlsRejected, - DomainForbidden, - TransportUnavailable, - CsrInvalid, - SequenceInvalid, - KindInvalid, - ChainNotFound, - SubscriptionInactive, - PaymentRequired, - ServerError, +pub(crate) enum AuthAttemptDisposition { + TryNext, + ReplanMissingTarget, + Terminal, } -/// Returns whether an automatic flow may use email after a stable -/// authentication failure. -/// -/// The caller must already have established that the server permits email as -/// an alternate proof. Transport and server failures never change proof. -pub fn should_fallback_to_email(can_get_email_credentials: bool, failure: AuthFailureKind) -> bool { - can_get_email_credentials && is_email_fallback_failure(failure) -} - -pub fn is_email_fallback_failure(failure: AuthFailureKind) -> bool { - matches!( - failure, - AuthFailureKind::MissingIdentity - | AuthFailureKind::MtlsRejected - | AuthFailureKind::DomainForbidden - ) -} - -pub fn classify_api_error(error: &crate::cert_server::Error) -> AuthFailureKind { +pub(crate) fn classify_identity_attempt( + error: &crate::cert_server::Error, +) -> AuthAttemptDisposition { match error { - crate::cert_server::Error::Api { status, code, .. } => match code.as_str() { - "unauthorized" => AuthFailureKind::MtlsRejected, - "domain_forbidden" => AuthFailureKind::DomainForbidden, - "csr_invalid" => AuthFailureKind::CsrInvalid, - "sequence_invalid" => AuthFailureKind::SequenceInvalid, - "kind_invalid" => AuthFailureKind::KindInvalid, - "cert_sequence_not_found" => AuthFailureKind::ChainNotFound, - "domain_not_found" => AuthFailureKind::SubscriptionInactive, - "payment_required" => AuthFailureKind::PaymentRequired, - _ if status.is_server_error() => AuthFailureKind::ServerError, - _ => AuthFailureKind::ServerError, + crate::cert_server::Error::Api { code, .. } => match code.as_str() { + "unauthorized" | "domain_forbidden" => AuthAttemptDisposition::TryNext, + "domain_not_found" => AuthAttemptDisposition::ReplanMissingTarget, + _ => AuthAttemptDisposition::Terminal, }, crate::cert_server::Error::Request { .. } | crate::cert_server::Error::DhttpEndpoint { .. } | crate::cert_server::Error::DhttpRequest { .. } - | crate::cert_server::Error::DhttpRead { .. } => AuthFailureKind::TransportUnavailable, - crate::cert_server::Error::IdentityFallbackUnavailable + | crate::cert_server::Error::DhttpRead { .. } + | crate::cert_server::Error::IdentityFallbackUnavailable | crate::cert_server::Error::Json { .. } - | crate::cert_server::Error::Whatever { .. } => AuthFailureKind::ServerError, + | crate::cert_server::Error::Whatever { .. } => AuthAttemptDisposition::Terminal, } } @@ -59,82 +28,49 @@ pub fn classify_api_error(error: &crate::cert_server::Error) -> AuthFailureKind mod tests { use super::*; - #[test] - fn auto_interactive_falls_back_for_credential_failures() { - assert!(should_fallback_to_email( - true, - AuthFailureKind::MissingIdentity - )); - assert!(should_fallback_to_email( - true, - AuthFailureKind::MtlsRejected - )); - assert!(should_fallback_to_email( - true, - AuthFailureKind::DomainForbidden - )); - } - - #[test] - fn transport_unavailability_does_not_fallback() { - assert!(!should_fallback_to_email( - true, - AuthFailureKind::TransportUnavailable - )); - } - - #[test] - fn auto_does_not_fallback_for_business_failures() { - for failure in [ - AuthFailureKind::CsrInvalid, - AuthFailureKind::SequenceInvalid, - AuthFailureKind::KindInvalid, - AuthFailureKind::ChainNotFound, - AuthFailureKind::SubscriptionInactive, - AuthFailureKind::PaymentRequired, - AuthFailureKind::ServerError, - ] { - assert!(!should_fallback_to_email(true, failure)); + fn api(status: reqwest::StatusCode, code: &str) -> crate::cert_server::Error { + crate::cert_server::Error::Api { + status, + code: code.to_string(), + message: code.to_string(), } } #[test] - fn non_interactive_auto_does_not_prompt_fallback() { - assert!(!should_fallback_to_email( - false, - AuthFailureKind::MissingIdentity - )); - } - - #[test] - fn classifier_keeps_business_errors_out_of_auth_fallback() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::NOT_FOUND, - code: "cert_sequence_not_found".to_string(), - message: "certificate sequence not found".to_string(), - }; - - assert_eq!(classify_api_error(&error), AuthFailureKind::ChainNotFound); - assert!(!should_fallback_to_email(true, classify_api_error(&error))); + fn only_explicit_auth_rejection_can_try_the_next_proof() { + assert_eq!( + classify_identity_attempt(&api(reqwest::StatusCode::UNAUTHORIZED, "unauthorized")), + AuthAttemptDisposition::TryNext + ); + assert_eq!( + classify_identity_attempt(&api(reqwest::StatusCode::FORBIDDEN, "domain_forbidden")), + AuthAttemptDisposition::TryNext + ); + assert_eq!( + classify_identity_attempt(&api( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "internal_error" + )), + AuthAttemptDisposition::Terminal + ); + assert_eq!( + classify_identity_attempt(&api(reqwest::StatusCode::CONFLICT, "future_code")), + AuthAttemptDisposition::Terminal + ); } #[test] - fn classifier_keeps_server_and_unknown_client_errors_terminal() { - for error in [ - crate::cert_server::Error::Api { - status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, - code: "internal_error".to_string(), - message: "internal error".to_string(), - }, - crate::cert_server::Error::Api { - status: reqwest::StatusCode::CONFLICT, - code: "future_conflict".to_string(), - message: "future conflict".to_string(), - }, - ] { - let failure = classify_api_error(&error); - assert_eq!(failure, AuthFailureKind::ServerError); - assert!(!should_fallback_to_email(true, failure)); - } + fn missing_apply_target_is_replanned_separately() { + assert_eq!( + classify_identity_attempt(&api(reqwest::StatusCode::NOT_FOUND, "domain_not_found")), + AuthAttemptDisposition::ReplanMissingTarget + ); + assert_eq!( + classify_identity_attempt(&api( + reqwest::StatusCode::NOT_FOUND, + "cert_sequence_not_found" + )), + AuthAttemptDisposition::Terminal + ); } } diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 55e79ce..49683ef 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -4,6 +4,7 @@ use dhttp::home::{DhttpHome, HomeScope}; use snafu::{FromString, OptionExt, whatever}; use super::{ + auth_plan::CandidateEvent, kind::IdentityKind, target::{ IdentityLevel, IdentityTarget, RemoteTargetState, ReplacementRequirement, @@ -184,7 +185,10 @@ async fn offer_expired_code_resend( } fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { - error.is_api_code("domain_not_found") + matches!( + crate::auth::classify_identity_attempt(error), + crate::auth::AuthAttemptDisposition::ReplanMissingTarget + ) } fn classify_apply_email_issue_error( @@ -217,13 +221,19 @@ fn preserve_apply_registration_error(error: Error) -> Error { error } -fn resolve_non_interactive_approval_plan(identity_auth_domain: Option<&str>) -> ApplyApprovalPlan { - if let Some(auth_domain) = identity_auth_domain { - return ApplyApprovalPlan::DirectIdentity { - auth_domain: auth_domain.to_string(), - }; +fn approval_plan_from_candidate(candidate: CandidateEvent) -> Result { + match candidate { + CandidateEvent::Identity { full_name, .. } => Ok(ApplyApprovalPlan::DirectIdentity { + auth_domain: full_name, + }), + CandidateEvent::Email => Ok(ApplyApprovalPlan::Email), + CandidateEvent::Warning(_) => { + unreachable!("first_auth_candidate consumes warnings") + } + CandidateEvent::Exhausted => { + whatever!("no authentication candidate is available") + } } - ApplyApprovalPlan::Email } fn apply_identity_name_opening() -> &'static str { @@ -508,7 +518,7 @@ async fn run_interactive_with_policy( .and_then(|config| config.settings().default_identity_name().cloned()); let resolved = resolve_apply_target(command, dhttp_home, cert_server, true).await?; let local_identity_save = authorize_local_replacement(&resolved, command.force, true).await?; - let _remote = resolved.remote; + let remote_target_state = resolved.remote; let mut state = InteractiveApplyState::from_command( command, Some(resolved.target.clone().into_dhttp_name()), @@ -531,13 +541,10 @@ async fn run_interactive_with_policy( .whatever_context::<_, Error>("interactive apply target is unavailable")?; let target = IdentityTarget::parse(domain.as_partial())?; if state.approval_plan.is_none() { - let auth_plan = super::auth_plan::load_apply_auth_plan(dhttp_home, &target).await?; - for warning in &auth_plan.warnings { - crate::cli::flow::transcript::print_err_block(warning); - } - state.approval_plan = Some(resolve_non_interactive_approval_plan( - auth_plan.first_identity_full_name(), - )); + let candidate = + super::auth_plan::first_auth_candidate(dhttp_home, &target, remote_target_state) + .await?; + state.approval_plan = Some(approval_plan_from_candidate(candidate)?); continue; } @@ -878,17 +885,15 @@ pub(crate) async fn run_with_policy( .and_then(|config| config.settings().default_identity_name().cloned()); let resolved = resolve_apply_target(command, dhttp_home, cert_server, false).await?; let local_identity_save = authorize_local_replacement(&resolved, command.force, false).await?; - let _remote = resolved.remote; + let remote_target_state = resolved.remote; let target = resolved.target; let domain = target.dhttp_name().into_owned(); let kind = resolve_kind(command).await?; let device_name = super::device::resolve_device_name(command.device_name.as_deref(), home_scope); - let auth_plan = super::auth_plan::load_apply_auth_plan(dhttp_home, &target).await?; - for warning in &auth_plan.warnings { - crate::cli::flow::transcript::print_err_block(warning); - } - let approval_plan = resolve_non_interactive_approval_plan(auth_plan.first_identity_full_name()); + let approval_plan = approval_plan_from_candidate( + super::auth_plan::first_auth_candidate(dhttp_home, &target, remote_target_state).await?, + )?; let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; let detail = match approval_plan { @@ -1050,13 +1055,13 @@ pub(crate) async fn run( #[cfg(test)] mod tests { use super::{ - ApplyApprovalPlan, ApplyVerifyCodeAction, InteractiveApplyState, MissingTargetAction, - apply_identity_name_opening, apply_verify_code_actions, authorize_local_replacement, + ApplyApprovalPlan, ApplyVerifyCodeAction, CandidateEvent, InteractiveApplyState, + MissingTargetAction, apply_identity_name_opening, apply_verify_code_actions, + approval_plan_from_candidate, authorize_local_replacement, classify_apply_email_issue_error, explicit_target_from_command, interactive_name_unavailable_message, missing_root_target_error, missing_target_action, new_identity_confirmation_message, preserve_apply_email_issue_error, preserve_apply_registration_error, resolve_apply_target, - resolve_non_interactive_approval_plan, }; use crate::cli::{ Apply, LocalIdentitySave, @@ -1246,7 +1251,7 @@ mod tests { #[test] fn root_apply_without_local_auth_defaults_to_email_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan(None), + approval_plan_from_candidate(CandidateEvent::Email).unwrap(), ApplyApprovalPlan::Email, ); } @@ -1254,9 +1259,13 @@ mod tests { #[test] fn root_apply_prefers_ready_local_auth_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan(Some("alice.smith")), + approval_plan_from_candidate(CandidateEvent::Identity { + short_name: "alice.smith".to_string(), + full_name: "alice.smith.dhttp.net".to_string(), + }) + .unwrap(), ApplyApprovalPlan::DirectIdentity { - auth_domain: "alice.smith".to_string(), + auth_domain: "alice.smith.dhttp.net".to_string(), }, ); } @@ -1264,9 +1273,13 @@ mod tests { #[test] fn sub_identity_apply_automatically_uses_ready_parent() { assert_eq!( - resolve_non_interactive_approval_plan(Some("alice.smith")), + approval_plan_from_candidate(CandidateEvent::Identity { + short_name: "alice.smith".to_string(), + full_name: "alice.smith.dhttp.net".to_string(), + }) + .unwrap(), ApplyApprovalPlan::DirectIdentity { - auth_domain: "alice.smith".to_string(), + auth_domain: "alice.smith.dhttp.net".to_string(), }, ); } diff --git a/genmeta-identity/src/cli/flow/auth_plan.rs b/genmeta-identity/src/cli/flow/auth_plan.rs index 5bc15f3..5a8b37a 100644 --- a/genmeta-identity/src/cli/flow/auth_plan.rs +++ b/genmeta-identity/src/cli/flow/auth_plan.rs @@ -1,127 +1,232 @@ -use dhttp::home::DhttpHome; +use std::collections::VecDeque; + +use dhttp::{home::DhttpHome, name::DhttpName}; use super::{ local::{self, LocalIdentityStatus, LocalIdentitySummary}, - target::{IdentityLevel, IdentityTarget}, + target::{IdentityLevel, IdentityTarget, RemoteTargetState}, }; use crate::cli::Error; #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum AuthCandidate { +pub(crate) enum AuthCandidateSpec { + Identity(DhttpName<'static>), + Email, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CandidateEvent { Identity { short_name: String, full_name: String, }, + Warning(String), Email, + Exhausted, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct AuthPlan { - pub(crate) candidates: Vec, - pub(crate) warnings: Vec, +pub(crate) fn candidate_specs( + target: &IdentityTarget, + remote: RemoteTargetState, +) -> Vec { + let mut candidates = Vec::new(); + match (target.level(), remote) { + (IdentityLevel::Identity, RemoteTargetState::Missing) => {} + (IdentityLevel::SubIdentity, RemoteTargetState::Missing) => { + if let Some(parent) = target.parent() { + candidates.push(AuthCandidateSpec::Identity(parent.into_owned())); + } + } + (IdentityLevel::Identity, _) => { + candidates.push(AuthCandidateSpec::Identity( + target.dhttp_name().into_owned(), + )); + } + (IdentityLevel::SubIdentity, _) => { + candidates.push(AuthCandidateSpec::Identity( + target.dhttp_name().into_owned(), + )); + if let Some(parent) = target.parent() { + candidates.push(AuthCandidateSpec::Identity(parent.into_owned())); + } + } + } + candidates.push(AuthCandidateSpec::Email); + candidates } -impl AuthPlan { - pub(crate) fn first_identity_full_name(&self) -> Option<&str> { - self.candidates - .iter() - .find_map(|candidate| match candidate { - AuthCandidate::Identity { full_name, .. } => Some(full_name.as_str()), - AuthCandidate::Email => None, - }) +pub(crate) trait ExactIdentityLoader { + async fn load_exact( + &mut self, + name: DhttpName<'_>, + ) -> Result, Error>; +} + +pub(crate) struct HomeExactIdentityLoader<'a> { + home: &'a DhttpHome, +} + +impl<'a> HomeExactIdentityLoader<'a> { + pub(crate) fn new(home: &'a DhttpHome) -> Self { + Self { home } } } -fn unavailable_reason(summary: &LocalIdentitySummary) -> String { - match &summary.status { - LocalIdentityStatus::Expired { .. } => "its local certificate has expired".to_string(), - LocalIdentityStatus::Incomplete { detail } => { - format!("its local identity is incomplete: {detail}") +impl ExactIdentityLoader for HomeExactIdentityLoader<'_> { + async fn load_exact( + &mut self, + name: DhttpName<'_>, + ) -> Result, Error> { + let Some(mut summary) = + local::try_load_summary_exact(self.home, name.clone(), None).await? + else { + return Ok(None); + }; + if !summary.status.is_ready() { + return Ok(Some(summary)); } - LocalIdentityStatus::Invalid { detail } => { - format!("its local identity is invalid: {detail}") + + let profile = match self.home.resolve_identity_profile_exactly(name).await { + Ok(profile) => profile, + Err(dhttp::home::identity::ssl::ResolveIdentityProfileError::ExactNotFound { + .. + }) => return Ok(None), + Err(error) => return Err(error.into()), + }; + if let Err(error) = profile.load_identity().await { + summary.status = LocalIdentityStatus::Invalid { + detail: format!("local credentials could not be loaded: {error}"), + }; } - LocalIdentityStatus::Ready { .. } => unreachable!("ready identities are available"), + Ok(Some(summary)) } } -pub(crate) fn plan_auth_candidates( - target: Option<&LocalIdentitySummary>, - parent: Option<&LocalIdentitySummary>, -) -> AuthPlan { - let summaries = [target, parent]; - let mut candidates = Vec::new(); - let mut warnings = Vec::new(); +pub(crate) struct AuthCandidateRunner { + loader: L, + pending: VecDeque, +} - for (index, summary) in summaries.iter().enumerate() { - let Some(summary) = summary else { - continue; - }; - if summary.status.is_ready() { - candidates.push(AuthCandidate::Identity { - short_name: summary.target.short_name().to_string(), - full_name: summary.target.full_name().to_string(), - }); - continue; +impl AuthCandidateRunner +where + L: ExactIdentityLoader, +{ + pub(crate) fn new(loader: L, candidates: Vec) -> Self { + Self { + loader, + pending: candidates.into(), } + } - let next_ready = summaries[index + 1..] - .iter() - .flatten() - .find(|candidate| candidate.status.is_ready()); - let problem = format!( - "Cannot verify with {} because {}.", - summary.target.short_name(), - unavailable_reason(summary), - ); - let later_saved_candidate_exists = summaries[index + 1..].iter().flatten().next().is_some(); - let warning = match next_ready { - Some(next) => format!( - "{problem}\nTrying its parent identity, {}.", - next.target.short_name() - ), - None if later_saved_candidate_exists => problem, - None => format!("{problem}\nFalling back to email verification."), - }; - warnings.push(warning); + #[cfg(test)] + pub(crate) fn loader(&self) -> &L { + &self.loader + } + + pub(crate) async fn next(&mut self) -> Result { + loop { + let Some(candidate) = self.pending.pop_front() else { + return Ok(CandidateEvent::Exhausted); + }; + match candidate { + AuthCandidateSpec::Email => return Ok(CandidateEvent::Email), + AuthCandidateSpec::Identity(name) => { + let Some(summary) = self.loader.load_exact(name.borrow()).await? else { + continue; + }; + if summary.status.is_ready() { + return Ok(CandidateEvent::Identity { + short_name: summary.target.short_name().to_string(), + full_name: summary.target.full_name().to_string(), + }); + } + return Ok(CandidateEvent::Warning(self.warning_for(&summary))); + } + } + } } - candidates.push(AuthCandidate::Email); - AuthPlan { - candidates, - warnings, + fn warning_for(&self, summary: &LocalIdentitySummary) -> String { + let reason = match summary.status { + LocalIdentityStatus::Expired { .. } => "its local certificate has expired", + LocalIdentityStatus::Incomplete { .. } => "its local identity is incomplete", + LocalIdentityStatus::Invalid { .. } => "its local identity is invalid", + LocalIdentityStatus::Ready { .. } => unreachable!("ready identity is usable"), + }; + let continuation = match self.pending.front() { + Some(AuthCandidateSpec::Identity(name)) => { + format!("trying {}", name.as_partial()) + } + Some(AuthCandidateSpec::Email) | None => { + "falling back to email verification".to_string() + } + }; + format!( + "WARN: Cannot authenticate with {} because {reason}; {continuation}", + summary.target.short_name() + ) } } -pub(crate) async fn load_apply_auth_plan( +pub(crate) async fn first_auth_candidate( dhttp_home: &DhttpHome, target: &IdentityTarget, -) -> Result { - let target_summary = - local::try_load_summary_exact(dhttp_home, target.dhttp_name(), None).await?; - let parent_summary = if target.level() == IdentityLevel::SubIdentity { - match target.parent() { - Some(parent) => local::try_load_summary_exact(dhttp_home, parent, None).await?, - None => None, + remote: RemoteTargetState, +) -> Result { + let mut runner = AuthCandidateRunner::new( + HomeExactIdentityLoader::new(dhttp_home), + candidate_specs(target, remote), + ); + loop { + match runner.next().await? { + CandidateEvent::Warning(warning) => { + super::transcript::print_warning(&warning); + } + selected => return Ok(selected), } - } else { - None - }; - Ok(plan_auth_candidates( - target_summary.as_ref(), - parent_summary.as_ref(), - )) + } } #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{collections::BTreeMap, path::PathBuf}; use super::*; - use crate::cli::flow::{ - local::{IdentityUsage, LocalIdentityStatus, LocalIdentitySummary}, - target::IdentityTarget, - }; + use crate::cli::flow::local::{IdentityUsage, LocalIdentityStatus}; + + fn identity(name: &str) -> AuthCandidateSpec { + AuthCandidateSpec::Identity(DhttpName::try_from(name).unwrap().into_owned()) + } + + fn email() -> AuthCandidateSpec { + AuthCandidateSpec::Email + } + + #[test] + fn candidate_orders_cover_existing_and_missing_root_and_child() { + let root = IdentityTarget::parse("alice.smith").unwrap(); + let child = IdentityTarget::parse("phone.alice.smith").unwrap(); + assert_eq!( + candidate_specs(&root, RemoteTargetState::Exists), + vec![identity("alice.smith"), email()] + ); + assert_eq!( + candidate_specs(&child, RemoteTargetState::Exists), + vec![ + identity("phone.alice.smith"), + identity("alice.smith"), + email() + ] + ); + assert_eq!( + candidate_specs(&root, RemoteTargetState::Missing), + vec![email()] + ); + assert_eq!( + candidate_specs(&child, RemoteTargetState::Missing), + vec![identity("alice.smith"), email()] + ); + } fn summary(name: &str, status: LocalIdentityStatus) -> LocalIdentitySummary { LocalIdentitySummary { @@ -136,116 +241,112 @@ mod tests { } } - fn ready(name: &str) -> LocalIdentitySummary { - summary( - name, - LocalIdentityStatus::Ready { - expires_at: 1_900_000_000, - }, - ) + #[derive(Default)] + struct FakeExactLoader { + summaries: BTreeMap, + requested: Vec, } - #[test] - fn ready_subidentity_prefers_target_then_parent_then_email() { - let target = ready("handle.alice.smith"); - let parent = ready("alice.smith"); - let plan = plan_auth_candidates(Some(&target), Some(&parent)); + impl FakeExactLoader { + fn with(summaries: impl IntoIterator) -> Self { + Self { + summaries: summaries + .into_iter() + .map(|summary| (summary.target.short_name().to_string(), summary)) + .collect(), + requested: Vec::new(), + } + } - assert_eq!( - plan.candidates, - vec![ - AuthCandidate::Identity { - short_name: "handle.alice.smith".into(), - full_name: "handle.alice.smith.dhttp.net".into(), - }, - AuthCandidate::Identity { - short_name: "alice.smith".into(), - full_name: "alice.smith.dhttp.net".into(), + fn requested(&self) -> &[String] { + &self.requested + } + } + + impl ExactIdentityLoader for FakeExactLoader { + async fn load_exact( + &mut self, + name: DhttpName<'_>, + ) -> Result, Error> { + self.requested.push(name.as_partial().to_string()); + Ok(self.summaries.get(name.as_partial()).cloned()) + } + } + + #[tokio::test] + async fn successful_current_identity_never_loads_parent_or_email() { + let ready = |name| { + summary( + name, + LocalIdentityStatus::Ready { + expires_at: 1_900_000_000, }, - AuthCandidate::Email, - ] - ); - assert!(plan.warnings.is_empty()); + ) + }; + let target = IdentityTarget::parse("phone.alice.smith").unwrap(); + let loader = FakeExactLoader::with([ready("phone.alice.smith"), ready("alice.smith")]); + let mut runner = + AuthCandidateRunner::new(loader, candidate_specs(&target, RemoteTargetState::Exists)); + + assert!(matches!( + runner.next().await.unwrap(), + CandidateEvent::Identity { short_name, .. } if short_name == "phone.alice.smith" + )); + assert_eq!(runner.loader().requested(), ["phone.alice.smith"]); } - #[test] - fn expired_target_warns_then_uses_ready_parent() { - let target = summary( - "handle.alice.smith", - LocalIdentityStatus::Expired { - expired_at: 1_700_000_000, - }, - ); - let parent = ready("alice.smith"); - let plan = plan_auth_candidates(Some(&target), Some(&parent)); + #[tokio::test] + async fn abnormal_candidates_warn_only_when_visited() { + let target = IdentityTarget::parse("phone.alice.smith").unwrap(); + let loader = FakeExactLoader::with([ + summary( + "phone.alice.smith", + LocalIdentityStatus::Expired { + expired_at: 1_700_000_000, + }, + ), + summary( + "alice.smith", + LocalIdentityStatus::Incomplete { + detail: "private key is missing".to_string(), + }, + ), + ]); + let mut runner = + AuthCandidateRunner::new(loader, candidate_specs(&target, RemoteTargetState::Exists)); assert_eq!( - plan.candidates, - vec![ - AuthCandidate::Identity { - short_name: "alice.smith".into(), - full_name: "alice.smith.dhttp.net".into(), - }, - AuthCandidate::Email, - ] + runner.next().await.unwrap(), + CandidateEvent::Warning( + "WARN: Cannot authenticate with phone.alice.smith because its local certificate has expired; trying alice.smith".to_string() + ) ); + assert_eq!(runner.loader().requested(), ["phone.alice.smith"]); assert_eq!( - plan.warnings, - vec![ - "Cannot verify with handle.alice.smith because its local certificate has expired.\nTrying its parent identity, alice.smith." - ] - ); - } - - #[test] - fn invalid_parent_warns_before_email() { - let parent = summary( - "alice.smith", - LocalIdentityStatus::Invalid { - detail: "certificate does not match local key".into(), - }, + runner.next().await.unwrap(), + CandidateEvent::Warning( + "WARN: Cannot authenticate with alice.smith because its local identity is incomplete; falling back to email verification".to_string() + ) ); - let plan = plan_auth_candidates(None, Some(&parent)); - - assert_eq!(plan.candidates, vec![AuthCandidate::Email]); assert_eq!( - plan.warnings, - vec![ - "Cannot verify with alice.smith because its local identity is invalid: certificate does not match local key.\nFalling back to email verification." - ] + runner.loader().requested(), + ["phone.alice.smith", "alice.smith"] ); + assert_eq!(runner.next().await.unwrap(), CandidateEvent::Email); } - #[test] - fn unavailable_target_and_parent_explain_each_skip_before_one_email_fallback() { - let target = summary( - "phone.alice.smith", - LocalIdentityStatus::Incomplete { - detail: "private key missing".into(), - }, - ); - let parent = summary( - "alice.smith", - LocalIdentityStatus::Invalid { - detail: "certificate is unreadable".into(), - }, + #[tokio::test] + async fn missing_candidates_advance_silently() { + let target = IdentityTarget::parse("phone.alice.smith").unwrap(); + let mut runner = AuthCandidateRunner::new( + FakeExactLoader::default(), + candidate_specs(&target, RemoteTargetState::Exists), ); - let plan = plan_auth_candidates(Some(&target), Some(&parent)); - - assert_eq!(plan.candidates, vec![AuthCandidate::Email]); + assert_eq!(runner.next().await.unwrap(), CandidateEvent::Email); assert_eq!( - plan.warnings, - vec![ - "Cannot verify with phone.alice.smith because its local identity is incomplete: private key missing.", - "Cannot verify with alice.smith because its local identity is invalid: certificate is unreadable.\nFalling back to email verification.", - ] + runner.loader().requested(), + ["phone.alice.smith", "alice.smith"] ); } - - #[test] - fn unrelated_default_is_never_a_candidate() { - let plan = plan_auth_candidates(None, None); - assert_eq!(plan.candidates, vec![AuthCandidate::Email]); - } } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index e24e671..df84355 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -3,7 +3,7 @@ use std::io::IsTerminal; use dhttp::home::{DhttpHome, HomeScope}; use snafu::{OptionExt, whatever}; -use super::local; +use super::{auth_plan::CandidateEvent, local}; use crate::{ cert_server::CertServer, cli::{self, Error, Renew, prompt::InquireResultExt}, @@ -137,12 +137,18 @@ async fn ensure_saved_renew_target( whatever!("{}", renew_not_saved_root_message(name.as_partial())); } -fn resolve_non_interactive_approval_plan(ready_identity: Option<&str>) -> RenewApprovalPlan { - match ready_identity { - Some(auth_domain) => RenewApprovalPlan::Identity { - auth_domain: auth_domain.to_string(), - }, - None => RenewApprovalPlan::Email, +fn approval_plan_from_candidate(candidate: CandidateEvent) -> Result { + match candidate { + CandidateEvent::Identity { full_name, .. } => Ok(RenewApprovalPlan::Identity { + auth_domain: full_name, + }), + CandidateEvent::Email => Ok(RenewApprovalPlan::Email), + CandidateEvent::Warning(_) => { + unreachable!("first_auth_candidate consumes warnings") + } + CandidateEvent::Exhausted => { + whatever!("no authentication candidate is available") + } } } @@ -193,13 +199,13 @@ async fn run_interactive( if state.approval_plan.is_none() { let target = crate::cli::flow::target::IdentityTarget::parse(domain.as_partial())?; - let auth_plan = super::auth_plan::load_apply_auth_plan(dhttp_home, &target).await?; - for warning in &auth_plan.warnings { - crate::cli::flow::transcript::print_err_block(warning); - } - state.approval_plan = Some(resolve_non_interactive_approval_plan( - auth_plan.first_identity_full_name(), - )); + let candidate = super::auth_plan::first_auth_candidate( + dhttp_home, + &target, + crate::cli::flow::target::RemoteTargetState::Exists, + ) + .await?; + state.approval_plan = Some(approval_plan_from_candidate(candidate)?); continue; } @@ -391,11 +397,14 @@ pub(crate) async fn run( let domain = resolve_target(command, dhttp_home).await?; ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; let target = crate::cli::flow::target::IdentityTarget::parse(domain.as_partial())?; - let auth_plan = super::auth_plan::load_apply_auth_plan(dhttp_home, &target).await?; - for warning in &auth_plan.warnings { - crate::cli::flow::transcript::print_err_block(warning); - } - let approval_plan = resolve_non_interactive_approval_plan(auth_plan.first_identity_full_name()); + let approval_plan = approval_plan_from_candidate( + super::auth_plan::first_auth_candidate( + dhttp_home, + &target, + crate::cli::flow::target::RemoteTargetState::Exists, + ) + .await?, + )?; let identity_profile = dhttp_home.resolve_identity_profile(domain.borrow()).await?; let local_identity = identity_profile.load_identity().await?; let chain_key = cli::certificate_chain_key_from_identity(&local_identity)? @@ -464,9 +473,8 @@ mod tests { use dhttp::home::{DhttpHome, HomeScope}; use super::{ - InteractiveRenewState, RenewApprovalPlan, RenewVerifyCodeAction, - renew_not_saved_root_message, renew_verify_code_actions, - resolve_non_interactive_approval_plan, + CandidateEvent, InteractiveRenewState, RenewApprovalPlan, RenewVerifyCodeAction, + approval_plan_from_candidate, renew_not_saved_root_message, renew_verify_code_actions, }; use crate::cli::Renew; @@ -538,7 +546,11 @@ mod tests { #[test] fn renew_prefers_ready_identity_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan(Some("alice.smith.dhttp.net")), + approval_plan_from_candidate(CandidateEvent::Identity { + short_name: "alice.smith".to_string(), + full_name: "alice.smith.dhttp.net".to_string(), + }) + .unwrap(), RenewApprovalPlan::Identity { auth_domain: "alice.smith.dhttp.net".to_string() } @@ -548,7 +560,7 @@ mod tests { #[test] fn renew_without_ready_identity_uses_email_non_interactively() { assert_eq!( - resolve_non_interactive_approval_plan(None), + approval_plan_from_candidate(CandidateEvent::Email).unwrap(), RenewApprovalPlan::Email, ); } diff --git a/genmeta-identity/src/cli/flow/transcript.rs b/genmeta-identity/src/cli/flow/transcript.rs index 63b228e..6e41b37 100644 --- a/genmeta-identity/src/cli/flow/transcript.rs +++ b/genmeta-identity/src/cli/flow/transcript.rs @@ -20,6 +20,11 @@ pub(crate) fn print_err_block(block: &str) { } } +pub(crate) fn print_warning(message: &str) { + let message = message.strip_prefix("WARN: ").unwrap_or(message); + indicatif_eprintln!("WARN: {message}"); +} + #[cfg(test)] mod tests { use super::block_lines; @@ -35,4 +40,12 @@ mod tests { ] ); } + + #[test] + fn warning_prefix_is_stable() { + assert_eq!( + "WARN: already prefixed".strip_prefix("WARN: "), + Some("already prefixed") + ); + } } From c926b43828a2dee78d20597b6c93f8014be4b8d5 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:02:04 +0800 Subject: [PATCH 11/27] feat(identity): share recoverable email verification --- genmeta-identity/src/cli.rs | 52 -- genmeta-identity/src/cli/flow/apply.rs | 420 +------------ genmeta-identity/src/cli/flow/email.rs | 699 +++++++++++++++++++++- genmeta-identity/src/cli/flow/recovery.rs | 335 +++-------- genmeta-identity/src/cli/flow/renew.rs | 318 +--------- genmeta-identity/src/cli/prompt.rs | 24 +- 6 files changed, 821 insertions(+), 1027 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 4cef22b..c7ce775 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -35,7 +35,6 @@ use tracing_subscriber::{ use crate::{ CERT_SERVER_BASE_URL, cert_server::{self, CertServer}, - cli::prompt::InquireResultExt, }; #[derive(Debug, Snafu)] @@ -211,61 +210,10 @@ async fn resolve_default_target_name(dhttp_home: &DhttpHome) -> Result, -) -> Result { - match flow::email::EmailVerificationAction::from_verify_code(provided) { - flow::email::EmailVerificationAction::ReuseProvidedCode(code) => Ok(code), - flow::email::EmailVerificationAction::SendAndPrompt => { - flow::progress::run( - flow::progress::SEND_CODE, - cert_server.send_email_verification(email), - ) - .await?; - prompt::prompt_verify_code() - .await - .require_interactive("--verify-code") - .map_err(Error::from) - } - } -} - fn parse_identity_name(identity: &str) -> Result, Error> { Ok(flow::target::IdentityTarget::parse(identity)?.into_dhttp_name()) } -async fn login_with_email( - cert_server: &CertServer, - domain: Option<&Name<'_>>, - email: Option, - verify_code: Option, -) -> Result { - let email = match email { - Some(email) => email, - None => prompt::prompt_email() - .await - .require_interactive("--email")?, - }; - let verify_code = acquire_verify_code(cert_server, &email, verify_code).await?; - if let Some(domain) = domain { - Ok(flow::progress::run( - flow::progress::VERIFY_EMAIL, - cert_server.domain_login(domain.as_full(), &email, &verify_code), - ) - .await? - .access_token) - } else { - Ok(flow::progress::run( - flow::progress::VERIFY_EMAIL, - cert_server.login(&email, &verify_code), - ) - .await? - .access_token) - } -} - /// Apply identity #[derive(Parser, Debug, Clone)] pub struct Apply { diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 49683ef..00a3d48 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -25,7 +25,6 @@ enum ApplyApprovalPlan { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ApplyRunOutcome { Applied, - ReturnedToCaller, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -62,41 +61,12 @@ fn missing_root_target_error(target: &IdentityTarget) -> Error { )) } -#[derive(Debug, Clone, PartialEq, Eq)] -enum ApplyVerifyCodeAction { - ResendVerificationCode, - ChangeEmail, - Cancel, -} - -impl ApplyVerifyCodeAction { - fn label(&self) -> String { - match self { - Self::ResendVerificationCode => "Resend verification code".to_string(), - Self::ChangeEmail => "Change email".to_string(), - Self::Cancel => "Cancel".to_string(), - } - } -} - -fn apply_verify_code_actions() -> Vec { - vec![ - ApplyVerifyCodeAction::ResendVerificationCode, - ApplyVerifyCodeAction::ChangeEmail, - ApplyVerifyCodeAction::Cancel, - ] -} - #[derive(Debug, Clone)] struct InteractiveApplyState { target: Option>, kind: Option, kind_prompt_required: bool, approval_plan: Option, - email: Option, - email_prompt_required: bool, - verify_code: Option, - verification_code_sent_to: Option, } impl InteractiveApplyState { @@ -113,77 +83,14 @@ impl InteractiveApplyState { .transpose()?, kind_prompt_required: command.kind.is_none(), approval_plan: None, - email: command.email.clone(), - email_prompt_required: command.email.is_none(), - verify_code: command.verify_code.clone(), - verification_code_sent_to: None, }) } - fn revisit_email(&mut self) { - self.email_prompt_required = true; - self.verify_code = None; - self.verification_code_sent_to = None; - } - fn fall_back_to_email(&mut self) { self.approval_plan = Some(ApplyApprovalPlan::Email); - self.email_prompt_required = true; - self.verify_code = None; - self.verification_code_sent_to = None; - } -} - -fn apply_verification_recovery( - state: &mut InteractiveApplyState, - recovery: &crate::cli::flow::recovery::VerificationRecovery, -) -> bool { - match recovery { - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { message } => { - crate::cli::flow::transcript::print_line(message); - true - } - crate::cli::flow::recovery::VerificationRecovery::OfferResend { message } => { - crate::cli::flow::transcript::print_line(message); - true - } - crate::cli::flow::recovery::VerificationRecovery::BackToEmail { message } => { - crate::cli::flow::transcript::print_line(message); - state.revisit_email(); - true - } - crate::cli::flow::recovery::VerificationRecovery::Abort => false, } } -async fn offer_expired_code_resend( - state: &mut InteractiveApplyState, - cert_server: &CertServer, - email: &str, - message: &str, -) -> Result<(), Error> { - crate::cli::flow::transcript::print_block(&crate::cli::flow::recovery::format_resend_offer( - message, - )); - let resend = crate::cli::prompt::sync(|| { - inquire::Confirm::new("Send a new verification code?") - .with_default(true) - .prompt() - }) - .await - .require_interactive("interactive input")?; - if resend { - super::progress::run( - super::progress::SEND_CODE, - cert_server.send_email_verification(email), - ) - .await?; - state.verification_code_sent_to = Some(email.to_string()); - } - state.verify_code = None; - Ok(()) -} - fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { matches!( crate::auth::classify_identity_attempt(error), @@ -191,28 +98,6 @@ fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { ) } -fn classify_apply_email_issue_error( - _target: &IdentityTarget, - error: &crate::cert_server::Error, -) -> Option { - match error { - crate::cert_server::Error::Api { - status, - code, - message, - } if *status == reqwest::StatusCode::FORBIDDEN && code == "domain_forbidden" => Some( - crate::cli::flow::recovery::VerificationRecovery::BackToEmail { - message: message.clone(), - }, - ), - _ => None, - } -} - -fn preserve_apply_email_issue_error(error: crate::cert_server::Error) -> Error { - Error::from(error) -} - fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { super::registration::is_subdomain_quota_exceeded(error) } @@ -371,31 +256,6 @@ async fn resolve_kind(command: &Apply) -> Result { } } -async fn resolve_email(command: &Apply) -> Result { - match command.email.clone() { - Some(email) => Ok(email), - None => Ok(crate::cli::prompt::prompt_email() - .await - .require_interactive("--email")?), - } -} - -async fn prompt_apply_verify_code_action() -> Result { - let actions = apply_verify_code_actions(); - let labels = actions - .iter() - .map(ApplyVerifyCodeAction::label) - .collect::>(); - let selected = crate::cli::prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected apply action is unavailable") -} - async fn run_post_save_epilogue( post_save: ApplyPostSavePolicy, dhttp_home: &DhttpHome, @@ -552,85 +412,6 @@ async fn run_interactive_with_policy( .approval_plan .clone() .whatever_context::<_, Error>("interactive apply approval plan is unavailable")?; - if matches!(approval_plan, ApplyApprovalPlan::Email) - && (state.email.is_none() || state.email_prompt_required) - { - let email = crate::cli::prompt::prompt_email_with_default(state.email.as_deref()) - .await - .require_interactive("--email")?; - state.email = Some(email); - state.email_prompt_required = false; - continue; - } - - if matches!(approval_plan, ApplyApprovalPlan::Email) && state.verify_code.is_none() { - let email = state - .email - .clone() - .whatever_context::<_, Error>("interactive apply email is unavailable")?; - if state.verification_code_sent_to.as_deref() != Some(email.as_str()) { - match super::progress::run( - super::progress::SEND_CODE, - cert_server.send_email_verification(&email), - ) - .await - { - Ok(_) => { - state.verification_code_sent_to = Some(email.clone()); - } - Err(error) => { - let recovery = crate::cli::flow::recovery::classify_resend_error(&error); - if matches!( - recovery, - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } - ) { - state.verification_code_sent_to = Some(email.clone()); - } - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - } - } - match crate::cli::prompt::prompt_verify_code_with_more_options(None) - .await - .require_interactive("--verify-code")? - { - crate::cli::prompt::TextPromptResult::Submitted(code) => { - state.verify_code = Some(code); - } - crate::cli::prompt::TextPromptResult::MoreOptions => { - match prompt_apply_verify_code_action().await? { - ApplyVerifyCodeAction::ResendVerificationCode => { - match super::progress::run( - super::progress::SEND_CODE, - cert_server.send_email_verification(&email), - ) - .await - { - Ok(_) => { - state.verification_code_sent_to = Some(email); - } - Err(error) => { - let recovery = - crate::cli::flow::recovery::classify_resend_error(&error); - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - } - } - ApplyVerifyCodeAction::ChangeEmail => state.revisit_email(), - ApplyVerifyCodeAction::Cancel => { - return Ok(ApplyRunOutcome::ReturnedToCaller); - } - } - } - } - continue; - } let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; let kind = state @@ -640,43 +421,14 @@ async fn run_interactive_with_policy( super::device::resolve_device_name(command.device_name.as_deref(), home_scope); let detail = match approval_plan { ApplyApprovalPlan::Email => { - let email = state - .email - .clone() - .whatever_context::<_, Error>("interactive apply email is unavailable")?; - let verify_code = state.verify_code.as_deref().whatever_context::<_, Error>( - "interactive apply verification code is unavailable", - )?; - let token = match super::progress::run( - super::progress::VERIFY_EMAIL, - cert_server.login(&email, verify_code), + let token = super::email::run_cert_server_email_session( + cert_server, + super::email::EmailLogin::Account, + command.email.as_deref(), + command.verify_code.as_deref(), + true, ) - .await - { - Ok(login) => login.access_token, - Err(error) => { - let recovery = - crate::cli::flow::recovery::classify_verify_submit_error(&error); - if let crate::cli::flow::recovery::VerificationRecovery::OfferResend { - message, - } = &recovery - { - offer_expired_code_resend(&mut state, cert_server, &email, message) - .await?; - continue; - } - if matches!( - recovery, - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } - ) { - state.verify_code = None; - } - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - }; + .await?; match super::progress::run( super::progress::REQUEST_CERT, cert_server.issue_cert( @@ -746,15 +498,7 @@ async fn run_interactive_with_policy( .await?; return Ok(ApplyRunOutcome::Applied); } - Err(error) => { - if let Some(recovery) = classify_apply_email_issue_error(&target, &error) { - state.verify_code = None; - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - } - return Err(Error::from(error)); - } + Err(error) => return Err(Error::from(error)), } } ApplyApprovalPlan::DirectIdentity { auth_domain } => { @@ -877,7 +621,6 @@ pub(crate) async fn run_with_policy( .await? { ApplyRunOutcome::Applied => Ok(()), - ApplyRunOutcome::ReturnedToCaller => whatever!("apply was cancelled"), }; } let default_identity_when_command_started = cli::load_current_settings(dhttp_home) @@ -898,26 +641,14 @@ pub(crate) async fn run_with_policy( let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; let detail = match approval_plan { ApplyApprovalPlan::Email => { - let email = resolve_email(command).await?; - let verify_code = match command.verify_code.clone() { - Some(code) => code, - None => { - super::progress::run( - super::progress::SEND_CODE, - cert_server.send_email_verification(&email), - ) - .await?; - crate::cli::prompt::prompt_verify_code() - .await - .require_interactive("--verify-code")? - } - }; - let token = super::progress::run( - super::progress::VERIFY_EMAIL, - cert_server.login(&email, &verify_code), + let token = super::email::run_cert_server_email_session( + cert_server, + super::email::EmailLogin::Account, + command.email.as_deref(), + command.verify_code.as_deref(), + false, ) - .await? - .access_token; + .await?; match super::progress::run( super::progress::REQUEST_CERT, cert_server.issue_cert( @@ -958,10 +689,9 @@ pub(crate) async fn run_with_policy( &csr_pem, ), ) - .await - .map_err(preserve_apply_email_issue_error)? + .await? } - Err(error) => return Err(preserve_apply_email_issue_error(error)), + Err(error) => return Err(Error::from(error)), } } ApplyApprovalPlan::DirectIdentity { auth_domain } => { @@ -1055,13 +785,10 @@ pub(crate) async fn run( #[cfg(test)] mod tests { use super::{ - ApplyApprovalPlan, ApplyVerifyCodeAction, CandidateEvent, InteractiveApplyState, - MissingTargetAction, apply_identity_name_opening, apply_verify_code_actions, - approval_plan_from_candidate, authorize_local_replacement, - classify_apply_email_issue_error, explicit_target_from_command, + ApplyApprovalPlan, CandidateEvent, MissingTargetAction, apply_identity_name_opening, + approval_plan_from_candidate, authorize_local_replacement, explicit_target_from_command, interactive_name_unavailable_message, missing_root_target_error, missing_target_action, - new_identity_confirmation_message, preserve_apply_email_issue_error, - preserve_apply_registration_error, resolve_apply_target, + new_identity_confirmation_message, preserve_apply_registration_error, resolve_apply_target, }; use crate::cli::{ Apply, LocalIdentitySave, @@ -1149,90 +876,6 @@ mod tests { assert!(resolved.local.is_none()); } - #[test] - fn stay_recovery_keeps_apply_verify_state() { - let mut state = InteractiveApplyState::from_command( - &Apply { - name: Some("alice.smith".to_string()), - kind: Some("primary".to_string()), - force: false, - device_name: None, - email: Some("alice@example.test".to_string()), - verify_code: None, - }, - None, - ) - .unwrap(); - state.verify_code = Some("123456".to_string()); - - super::apply_verification_recovery( - &mut state, - &crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { - message: "retry later".to_string(), - }, - ); - - assert_eq!(state.verify_code.as_deref(), Some("123456")); - } - - #[test] - fn back_to_email_recovery_reopens_apply_email_prompt() { - let mut state = InteractiveApplyState::from_command( - &Apply { - name: Some("alice.smith".to_string()), - kind: Some("primary".to_string()), - force: false, - device_name: None, - email: Some("alice@example.test".to_string()), - verify_code: None, - }, - None, - ) - .unwrap(); - state.email_prompt_required = false; - - super::apply_verification_recovery( - &mut state, - &crate::cli::flow::recovery::VerificationRecovery::BackToEmail { - message: "start over".to_string(), - }, - ); - - assert!(state.email_prompt_required); - assert!(state.verify_code.is_none()); - } - - #[test] - fn apply_email_issue_domain_forbidden_reopens_owner_email_prompt() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::FORBIDDEN, - code: "domain_forbidden".to_string(), - message: "domain access is forbidden".to_string(), - }; - let target = IdentityTarget::parse("alice.smith").unwrap(); - - assert_eq!( - classify_apply_email_issue_error(&target, &error), - Some( - crate::cli::flow::recovery::VerificationRecovery::BackToEmail { - message: "domain access is forbidden".to_string(), - } - ), - ); - } - - #[test] - fn non_interactive_apply_email_issue_keeps_the_certserver_problem_message() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::FORBIDDEN, - code: "domain_forbidden".to_string(), - message: "domain access is forbidden".to_string(), - }; - let rendered = preserve_apply_email_issue_error(error).to_string(); - - assert_eq!(rendered, "domain access is forbidden"); - } - #[test] fn explicit_target_from_command_returns_none_without_name() { let target = explicit_target_from_command(&Apply { @@ -1379,27 +1022,4 @@ mod tests { } )); } - - #[test] - fn apply_verify_code_actions_are_limited_to_the_code_recovery_boundary() { - assert_eq!( - apply_verify_code_actions() - .into_iter() - .map(|action| action.label()) - .collect::>(), - vec![ - "Resend verification code".to_string(), - "Change email".to_string(), - "Cancel".to_string(), - ] - ); - assert_eq!( - apply_verify_code_actions(), - vec![ - ApplyVerifyCodeAction::ResendVerificationCode, - ApplyVerifyCodeAction::ChangeEmail, - ApplyVerifyCodeAction::Cancel, - ] - ); - } } diff --git a/genmeta-identity/src/cli/flow/email.rs b/genmeta-identity/src/cli/flow/email.rs index 7dbb5dc..01a77d9 100644 --- a/genmeta-identity/src/cli/flow/email.rs +++ b/genmeta-identity/src/cli/flow/email.rs @@ -1,35 +1,700 @@ +use std::fmt; + +use super::recovery::{VerificationRecovery, classify_verify_submit_error}; +use crate::{ + cert_server::CertServer, + cli::{Error, prompt::InquireResultExt}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EmailLogin { + Account, + Domain(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MoreAction { + Resend, + ChangeEmail, + Cancel, +} + +impl MoreAction { + fn label(self) -> &'static str { + match self { + Self::Resend => "Resend verification code", + Self::ChangeEmail => "Change email", + Self::Cancel => "Cancel", + } + } + + fn all() -> [Self; 3] { + [Self::Resend, Self::ChangeEmail, Self::Cancel] + } +} + #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum EmailVerificationAction { - ReuseProvidedCode(String), - SendAndPrompt, +pub(crate) enum EmailSessionError { + Cancelled, + NonInteractivePairRequired, +} + +impl fmt::Display for EmailSessionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled => f.write_str("Email verification was cancelled."), + Self::NonInteractivePairRequired => f.write_str( + "Non-interactive email verification requires both --email and --verify-code.", + ), + } + } +} + +impl std::error::Error for EmailSessionError {} + +fn session_error(error: EmailSessionError) -> Error { + use snafu::FromString; + + Error::without_source(error.to_string()) +} + +trait EmailApi { + async fn send(&self, email: &str) -> Result<(), crate::cert_server::Error>; + + async fn verify( + &self, + login: &EmailLogin, + email: &str, + code: &str, + ) -> Result; +} + +trait EmailUi { + async fn prompt_email(&self, default: Option<&str>) -> Result; + async fn prompt_code(&self) -> Result; + async fn choose_more_action(&self) -> Result; + async fn confirm_resend(&self) -> Result; + fn print_server_message(&self, message: &str); +} + +struct CertServerEmailApi<'a> { + cert_server: &'a CertServer, +} + +impl<'a> CertServerEmailApi<'a> { + fn new(cert_server: &'a CertServer) -> Self { + Self { cert_server } + } } -impl EmailVerificationAction { - pub(crate) fn from_verify_code(verify_code: Option) -> Self { - match verify_code { - Some(code) => Self::ReuseProvidedCode(code), - None => Self::SendAndPrompt, +impl EmailApi for CertServerEmailApi<'_> { + async fn send(&self, email: &str) -> Result<(), crate::cert_server::Error> { + self.cert_server + .send_email_verification(email) + .await + .map(|_| ()) + } + + async fn verify( + &self, + login: &EmailLogin, + email: &str, + code: &str, + ) -> Result { + match login { + EmailLogin::Account => self + .cert_server + .login(email, code) + .await + .map(|response| response.access_token), + EmailLogin::Domain(domain) => self + .cert_server + .domain_login(domain, email, code) + .await + .map(|response| response.access_token), + } + } +} + +struct InquireEmailUi; + +impl EmailUi for InquireEmailUi { + async fn prompt_email(&self, default: Option<&str>) -> Result { + crate::cli::prompt::prompt_email_with_default(default) + .await + .require_interactive("--email") + .map_err(Error::from) + } + + async fn prompt_code(&self) -> Result { + crate::cli::prompt::prompt_verify_code_with_more_options(None) + .await + .require_interactive("--verify-code") + .map_err(Error::from) + } + + async fn choose_more_action(&self) -> Result { + let actions = MoreAction::all(); + let labels = actions + .iter() + .map(|action| action.label().to_string()) + .collect::>(); + let selected = crate::cli::prompt::prompt_select_string("More options:", labels) + .await + .require_interactive("interactive input")?; + Ok(actions + .into_iter() + .find(|action| action.label() == selected) + .expect("inquire returned an option that was not provided")) + } + + async fn confirm_resend(&self) -> Result { + crate::cli::prompt::confirm_send_new_verification_code() + .await + .require_interactive("interactive input") + .map_err(Error::from) + } + + fn print_server_message(&self, message: &str) { + super::transcript::print_line(message); + } +} + +pub(crate) async fn run_cert_server_email_session( + cert_server: &CertServer, + login: EmailLogin, + email: Option<&str>, + verify_code: Option<&str>, + interactive: bool, +) -> Result { + run_email_session( + &CertServerEmailApi::new(cert_server), + &InquireEmailUi, + login, + email, + verify_code, + interactive, + ) + .await +} + +async fn run_email_session( + api: &A, + ui: &U, + login: EmailLogin, + initial_email: Option<&str>, + initial_verify_code: Option<&str>, + interactive: bool, +) -> Result +where + A: EmailApi, + U: EmailUi, +{ + if !interactive { + let (Some(email), Some(code)) = (initial_email, initial_verify_code) else { + return Err(session_error(EmailSessionError::NonInteractivePairRequired)); + }; + return super::progress::run( + super::progress::VERIFY_EMAIL, + api.verify(&login, email, code), + ) + .await + .map_err(Error::from); + } + + let mut email = initial_email.map(ToOwned::to_owned); + let mut verify_code = initial_verify_code.map(ToOwned::to_owned); + let mut sent_to = initial_verify_code + .is_some() + .then(|| initial_email.map(ToOwned::to_owned)) + .flatten(); + + loop { + let current_email = match email.clone() { + Some(email) => email, + None => { + let prompted = ui.prompt_email(None).await?; + if initial_verify_code.is_some() && verify_code.is_some() { + sent_to = Some(prompted.clone()); + } + email = Some(prompted.clone()); + prompted + } + }; + + if verify_code.is_none() && sent_to.as_deref() != Some(current_email.as_str()) { + super::progress::run(super::progress::SEND_CODE, api.send(¤t_email)) + .await + .map_err(Error::from)?; + sent_to = Some(current_email.clone()); + } + + if verify_code.is_none() { + match ui.prompt_code().await? { + crate::cli::prompt::TextPromptResult::Submitted(code) => { + verify_code = Some(code); + } + crate::cli::prompt::TextPromptResult::MoreOptions => { + match ui.choose_more_action().await? { + MoreAction::Resend => { + super::progress::run( + super::progress::SEND_CODE, + api.send(¤t_email), + ) + .await + .map_err(Error::from)?; + sent_to = Some(current_email); + } + MoreAction::ChangeEmail => { + email = None; + sent_to = None; + } + MoreAction::Cancel => { + return Err(session_error(EmailSessionError::Cancelled)); + } + } + continue; + } + } + } + + let code = verify_code + .as_deref() + .expect("verification code was collected before verification"); + match super::progress::run( + super::progress::VERIFY_EMAIL, + api.verify(&login, ¤t_email, code), + ) + .await + { + Ok(token) => return Ok(token), + Err(error) => match classify_verify_submit_error(&error) { + VerificationRecovery::RetryCode { message } => { + ui.print_server_message(&message); + verify_code = None; + } + VerificationRecovery::OfferResend { message } => { + ui.print_server_message(&message); + verify_code = None; + if ui.confirm_resend().await? { + sent_to = None; + } + } + VerificationRecovery::ChangeEmail { message } => { + ui.print_server_message(&message); + email = None; + verify_code = None; + sent_to = None; + } + VerificationRecovery::Stop => return Err(Error::from(error)), + }, } } } #[cfg(test)] mod tests { - use super::EmailVerificationAction; + use std::{cell::RefCell, collections::VecDeque}; + + use super::*; + + fn api_error( + status: reqwest::StatusCode, + code: &str, + message: &str, + ) -> crate::cert_server::Error { + crate::cert_server::Error::Api { + status, + code: code.to_string(), + message: message.to_string(), + } + } + + fn request_error(message: &str) -> crate::cert_server::Error { + use snafu::FromString; + + crate::cert_server::Error::Whatever { + source: snafu::Whatever::without_source(message.to_string()), + } + } + + struct FakeEmailApi { + calls: RefCell>, + send_results: RefCell>>, + verify_results: RefCell>>, + } + + impl FakeEmailApi { + fn accepting(token: &str) -> Self { + Self::with_verify_results([Ok(token.to_string())]) + } + + fn with_verify_results( + results: impl IntoIterator>, + ) -> Self { + Self { + calls: RefCell::new(Vec::new()), + send_results: RefCell::new(VecDeque::new()), + verify_results: RefCell::new(results.into_iter().collect()), + } + } + + fn failing_send(error: crate::cert_server::Error) -> Self { + Self { + calls: RefCell::new(Vec::new()), + send_results: RefCell::new([Err(error)].into()), + verify_results: RefCell::new(VecDeque::new()), + } + } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + fn send_count(&self) -> usize { + self.calls + .borrow() + .iter() + .filter(|call| call.starts_with("send:")) + .count() + } + } + + impl EmailApi for FakeEmailApi { + async fn send(&self, email: &str) -> Result<(), crate::cert_server::Error> { + self.calls.borrow_mut().push(format!("send:{email}")); + self.send_results.borrow_mut().pop_front().unwrap_or(Ok(())) + } + + async fn verify( + &self, + login: &EmailLogin, + email: &str, + code: &str, + ) -> Result { + let login = match login { + EmailLogin::Account => "account", + EmailLogin::Domain(_) => "domain", + }; + self.calls + .borrow_mut() + .push(format!("verify-{login}:{email}:{code}")); + self.verify_results + .borrow_mut() + .pop_front() + .expect("test must provide a verification result") + } + } + + enum UiEvent { + Email(String), + Code(String), + MoreOptions, + Choose(MoreAction), + Confirm(bool), + } + + fn email(value: &str) -> UiEvent { + UiEvent::Email(value.to_string()) + } + + fn code(value: &str) -> UiEvent { + UiEvent::Code(value.to_string()) + } + + fn more_options() -> UiEvent { + UiEvent::MoreOptions + } + + fn choose(value: &str) -> UiEvent { + UiEvent::Choose( + MoreAction::all() + .into_iter() + .find(|action| action.label() == value) + .expect("test action label must be valid"), + ) + } + + fn confirm(value: bool) -> UiEvent { + UiEvent::Confirm(value) + } + + struct ScriptedEmailUi { + events: RefCell>, + messages: RefCell>, + confirm_questions: RefCell>, + code_prompt_count: RefCell, + } + + impl ScriptedEmailUi { + fn new(events: impl IntoIterator) -> Self { + Self { + events: RefCell::new(events.into_iter().collect()), + messages: RefCell::new(Vec::new()), + confirm_questions: RefCell::new(Vec::new()), + code_prompt_count: RefCell::new(0), + } + } + + fn next(&self) -> UiEvent { + self.events + .borrow_mut() + .pop_front() + .expect("scripted UI event is missing") + } + + fn printed_messages(&self) -> Vec { + self.messages.borrow().clone() + } + + fn confirm_questions(&self) -> Vec { + self.confirm_questions.borrow().clone() + } + + fn code_prompt_count(&self) -> usize { + *self.code_prompt_count.borrow() + } + } + + impl EmailUi for ScriptedEmailUi { + async fn prompt_email(&self, _default: Option<&str>) -> Result { + match self.next() { + UiEvent::Email(email) => Ok(email), + _ => panic!("expected a scripted email"), + } + } + + async fn prompt_code(&self) -> Result { + *self.code_prompt_count.borrow_mut() += 1; + match self.next() { + UiEvent::Code(code) => Ok(crate::cli::prompt::TextPromptResult::Submitted(code)), + UiEvent::MoreOptions => Ok(crate::cli::prompt::TextPromptResult::MoreOptions), + _ => panic!("expected a scripted verification code action"), + } + } + + async fn choose_more_action(&self) -> Result { + match self.next() { + UiEvent::Choose(action) => Ok(action), + _ => panic!("expected a scripted more-options choice"), + } + } + + async fn confirm_resend(&self) -> Result { + self.confirm_questions + .borrow_mut() + .push("Send a new verification code?".to_string()); + match self.next() { + UiEvent::Confirm(confirm) => Ok(confirm), + _ => panic!("expected a scripted resend confirmation"), + } + } + + fn print_server_message(&self, message: &str) { + self.messages.borrow_mut().push(message.to_string()); + } + } + + #[tokio::test] + async fn interactive_session_sends_once_then_verifies() { + let api = FakeEmailApi::accepting("token"); + let ui = ScriptedEmailUi::new([email("alice@example.test"), code("123456")]); + + let token = run_email_session(&api, &ui, EmailLogin::Account, None, None, true) + .await + .unwrap(); + + assert_eq!(token, "token"); + assert_eq!( + api.calls(), + [ + "send:alice@example.test", + "verify-account:alice@example.test:123456" + ] + ); + } + + #[tokio::test] + async fn question_mark_keeps_resend_change_email_and_cancel() { + let api = FakeEmailApi::accepting("token"); + let ui = ScriptedEmailUi::new([ + email("old@example.test"), + more_options(), + choose("Change email"), + email("new@example.test"), + code("123456"), + ]); + + run_email_session(&api, &ui, EmailLogin::Account, None, None, true) + .await + .unwrap(); - #[test] - fn provided_verify_code_reuses_existing_code() { assert_eq!( - EmailVerificationAction::from_verify_code(Some("000000".to_string())), - EmailVerificationAction::ReuseProvidedCode("000000".to_string()), + api.calls(), + [ + "send:old@example.test", + "send:new@example.test", + "verify-account:new@example.test:123456", + ] ); } - #[test] - fn missing_verify_code_requires_send_and_prompt() { + #[tokio::test] + async fn question_mark_can_resend_without_changing_email() { + let api = FakeEmailApi::accepting("token"); + let ui = ScriptedEmailUi::new([ + email("alice@example.test"), + more_options(), + choose("Resend verification code"), + code("123456"), + ]); + + run_email_session(&api, &ui, EmailLogin::Account, None, None, true) + .await + .unwrap(); + assert_eq!( - EmailVerificationAction::from_verify_code(None), - EmailVerificationAction::SendAndPrompt, + api.calls(), + [ + "send:alice@example.test", + "send:alice@example.test", + "verify-account:alice@example.test:123456", + ] ); } + + #[tokio::test] + async fn question_mark_can_cancel_without_verifying() { + let api = FakeEmailApi::accepting("token"); + let ui = ScriptedEmailUi::new([ + email("alice@example.test"), + more_options(), + choose("Cancel"), + ]); + + let error = run_email_session(&api, &ui, EmailLogin::Account, None, None, true) + .await + .unwrap_err(); + + assert_eq!(error.to_string(), "Email verification was cancelled."); + assert_eq!(api.calls(), ["send:alice@example.test"]); + } + + #[tokio::test] + async fn invalid_code_reprompts_without_resending() { + let api = FakeEmailApi::with_verify_results([ + Err(api_error( + reqwest::StatusCode::UNAUTHORIZED, + "verify_code_invalid", + "verification code is incorrect", + )), + Ok("token".to_string()), + ]); + let ui = + ScriptedEmailUi::new([email("alice@example.test"), code("111111"), code("222222")]); + + let token = run_email_session(&api, &ui, EmailLogin::Account, None, None, true) + .await + .unwrap(); + + assert_eq!(token, "token"); + assert_eq!(api.send_count(), 1); + assert_eq!(ui.printed_messages(), ["verification code is incorrect"]); + } + + #[tokio::test] + async fn expired_code_resends_only_after_yes() { + let api = FakeEmailApi::with_verify_results([ + Err(api_error( + reqwest::StatusCode::UNAUTHORIZED, + "verify_code_expired", + "verification code expired", + )), + Ok("token".to_string()), + ]); + let ui = ScriptedEmailUi::new([ + email("alice@example.test"), + code("111111"), + confirm(true), + code("222222"), + ]); + + run_email_session(&api, &ui, EmailLogin::Account, None, None, true) + .await + .unwrap(); + + assert_eq!(api.send_count(), 2); + assert_eq!(ui.confirm_questions(), ["Send a new verification code?"]); + } + + #[tokio::test] + async fn expired_code_no_returns_to_code_without_send() { + let api = FakeEmailApi::with_verify_results([ + Err(api_error( + reqwest::StatusCode::UNAUTHORIZED, + "verify_code_expired", + "verification code expired", + )), + Ok("token".to_string()), + ]); + let ui = ScriptedEmailUi::new([ + email("alice@example.test"), + code("111111"), + confirm(false), + code("222222"), + ]); + + run_email_session(&api, &ui, EmailLogin::Account, None, None, true) + .await + .unwrap(); + + assert_eq!(api.send_count(), 1); + } + + #[tokio::test] + async fn send_failure_does_not_enter_code_input_or_mark_sent() { + let api = FakeEmailApi::failing_send(request_error("network unavailable")); + let ui = ScriptedEmailUi::new([email("alice@example.test")]); + + assert!( + run_email_session(&api, &ui, EmailLogin::Account, None, None, true) + .await + .is_err() + ); + assert_eq!(api.send_count(), 1); + assert_eq!(ui.code_prompt_count(), 0); + } + + async fn run_noninteractive_fixture( + email: Option<&str>, + code: Option<&str>, + ) -> Result { + let api = FakeEmailApi::accepting("token"); + run_email_session( + &api, + &ScriptedEmailUi::new([]), + EmailLogin::Account, + email, + code, + false, + ) + .await?; + Ok(api) + } + + #[tokio::test] + async fn noninteractive_email_requires_both_email_and_hidden_code_and_never_sends() { + for (email, code) in [ + (None, None), + (Some("a@b.test"), None), + (None, Some("000000")), + ] { + assert!(run_noninteractive_fixture(email, code).await.is_err()); + } + + let api = run_noninteractive_fixture(Some("a@b.test"), Some("000000")) + .await + .unwrap(); + assert_eq!(api.calls(), ["verify-account:a@b.test:000000"]); + } } diff --git a/genmeta-identity/src/cli/flow/recovery.rs b/genmeta-identity/src/cli/flow/recovery.rs index e8b4e21..8af5952 100644 --- a/genmeta-identity/src/cli/flow/recovery.rs +++ b/genmeta-identity/src/cli/flow/recovery.rs @@ -1,50 +1,9 @@ #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum VerificationRecovery { - StayCurrentStep { message: String }, + RetryCode { message: String }, OfferResend { message: String }, - BackToEmail { message: String }, - Abort, -} - -pub(crate) fn format_resend_offer(message: &str) -> String { - format!("{message}\n") -} - -pub(crate) fn classify_resend_error(error: &crate::cert_server::Error) -> VerificationRecovery { - match error { - crate::cert_server::Error::Api { - status, - code, - message, - } - if *status == reqwest::StatusCode::TOO_MANY_REQUESTS - && matches!( - code.as_str(), - "verify_code_too_frequent" - | "verify_code_attempt_exceeded" - | "verify_code_rate_limited" - ) => - { - VerificationRecovery::StayCurrentStep { - message: message.clone(), - } - } - crate::cert_server::Error::Request { .. } - | crate::cert_server::Error::DhttpEndpoint { .. } - | crate::cert_server::Error::DhttpRequest { .. } - | crate::cert_server::Error::DhttpRead { .. } => VerificationRecovery::StayCurrentStep { - message: "Failed to resend the verification code. To continue, check the network and try again.".to_string(), - }, - crate::cert_server::Error::Api { status, .. } if status.is_server_error() => { - VerificationRecovery::Abort - } - crate::cert_server::Error::Api { message, .. } => VerificationRecovery::BackToEmail { - message: message.clone(), - }, - _ => VerificationRecovery::BackToEmail { - message: "The current verification session can no longer be used. To continue, enter your email again.".to_string(), - }, - } + ChangeEmail { message: String }, + Stop, } pub(crate) fn classify_verify_submit_error( @@ -55,229 +14,125 @@ pub(crate) fn classify_verify_submit_error( status, code, message, - } if *status == reqwest::StatusCode::UNAUTHORIZED && code == "verify_code_expired" => - { - VerificationRecovery::OfferResend { - message: message.clone(), - } - } - crate::cert_server::Error::Api { - status, - code, - message, - } - if *status == reqwest::StatusCode::UNAUTHORIZED && code == "verify_code_invalid" => - { - VerificationRecovery::StayCurrentStep { - message: message.clone(), - } - } - crate::cert_server::Error::Api { - status, - code, - message, - } - if *status == reqwest::StatusCode::TOO_MANY_REQUESTS - && code == "verify_code_too_frequent" => - { - VerificationRecovery::StayCurrentStep { - message: message.clone(), + } => match (*status, code.as_str()) { + (reqwest::StatusCode::UNAUTHORIZED, "verify_code_invalid") => { + VerificationRecovery::RetryCode { + message: message.clone(), + } } - } - crate::cert_server::Error::Api { status, code, message } - if *status == reqwest::StatusCode::UNAUTHORIZED - && code == "domain_email_not_matched" => - { - VerificationRecovery::BackToEmail { - message: message.clone(), + (reqwest::StatusCode::UNAUTHORIZED, "verify_code_expired") => { + VerificationRecovery::OfferResend { + message: message.clone(), + } } - } - crate::cert_server::Error::Api { - status, - code, - message, - } if *status == reqwest::StatusCode::TOO_MANY_REQUESTS - && code == "verify_code_attempt_exceeded" => - { - VerificationRecovery::StayCurrentStep { - message: message.clone(), + (reqwest::StatusCode::UNAUTHORIZED, "domain_email_not_matched") => { + VerificationRecovery::ChangeEmail { + message: message.clone(), + } } - } - crate::cert_server::Error::Api { status, code, .. } - if *status == reqwest::StatusCode::FORBIDDEN && code == "user_blocked" => - { - VerificationRecovery::Abort - } - crate::cert_server::Error::Api { status, .. } if status.is_server_error() => { - VerificationRecovery::Abort - } - crate::cert_server::Error::Api { message, .. } => VerificationRecovery::BackToEmail { - message: message.clone(), - }, - _ => VerificationRecovery::BackToEmail { - message: "The verification code session needs to be restarted. To continue, enter your email again.".to_string(), + ( + reqwest::StatusCode::TOO_MANY_REQUESTS, + "verify_code_too_frequent" + | "verify_code_attempt_exceeded" + | "verify_code_rate_limited", + ) + | (reqwest::StatusCode::FORBIDDEN, "user_blocked") => VerificationRecovery::Stop, + _ => VerificationRecovery::Stop, }, + crate::cert_server::Error::Request { .. } + | crate::cert_server::Error::DhttpEndpoint { .. } + | crate::cert_server::Error::DhttpRequest { .. } + | crate::cert_server::Error::DhttpRead { .. } + | crate::cert_server::Error::IdentityFallbackUnavailable + | crate::cert_server::Error::Json { .. } + | crate::cert_server::Error::Whatever { .. } => VerificationRecovery::Stop, } } #[cfg(test)] mod tests { - use super::{ - VerificationRecovery, classify_resend_error, classify_verify_submit_error, - format_resend_offer, - }; + use super::{VerificationRecovery, classify_verify_submit_error}; - #[test] - fn resend_offer_leaves_one_blank_line_before_the_confirm_prompt() { - assert_eq!( - format_resend_offer("verification code expired"), - "verification code expired\n" - ); - } - - #[test] - fn resend_rate_limits_stay_on_current_step() { - for code in [ - "verify_code_too_frequent", - "verify_code_attempt_exceeded", - "verify_code_rate_limited", - ] { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::TOO_MANY_REQUESTS, - code: code.to_string(), - message: "email verification is temporarily rate limited".to_string(), - }; - - assert_eq!( - classify_resend_error(&error), - VerificationRecovery::StayCurrentStep { - message: "email verification is temporarily rate limited".to_string(), - } - ); + fn api(status: reqwest::StatusCode, code: &str, message: &str) -> crate::cert_server::Error { + crate::cert_server::Error::Api { + status, + code: code.to_string(), + message: message.to_string(), } } #[test] - fn invalid_code_keeps_the_certserver_problem_message() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::UNAUTHORIZED, - code: "verify_code_invalid".to_string(), - message: "Your verification code is incorrect. Please try again.".to_string(), - }; - + fn only_invalid_expired_and_email_mismatch_are_recoverable() { assert_eq!( - classify_verify_submit_error(&error), - VerificationRecovery::StayCurrentStep { - message: "Your verification code is incorrect. Please try again.".to_string(), + classify_verify_submit_error(&api( + reqwest::StatusCode::UNAUTHORIZED, + "verify_code_invalid", + "verification code is incorrect", + )), + VerificationRecovery::RetryCode { + message: "verification code is incorrect".to_string(), } ); - } - - #[test] - fn verify_server_error_aborts() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, - code: "internal_error".to_string(), - message: "boom".to_string(), - }; - - assert_eq!( - classify_verify_submit_error(&error), - VerificationRecovery::Abort - ); - } - - #[test] - fn blocked_user_aborts_verification() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::FORBIDDEN, - code: "user_blocked".to_string(), - message: "user is blocked".to_string(), - }; - - assert_eq!( - classify_verify_submit_error(&error), - VerificationRecovery::Abort - ); - } - - #[test] - fn verification_attempt_limit_stays_at_code_input() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::TOO_MANY_REQUESTS, - code: "verify_code_attempt_exceeded".to_string(), - message: "too many failed verification attempts, try again later".to_string(), - }; - assert_eq!( - classify_verify_submit_error(&error), - VerificationRecovery::StayCurrentStep { - message: "too many failed verification attempts, try again later".to_string(), - } - ); - } - - #[test] - fn expired_code_offers_resend_with_server_message() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::UNAUTHORIZED, - code: "verify_code_expired".to_string(), - message: "verification code expired".to_string(), - }; - - assert_eq!( - classify_verify_submit_error(&error), + classify_verify_submit_error(&api( + reqwest::StatusCode::UNAUTHORIZED, + "verify_code_expired", + "verification code expired", + )), VerificationRecovery::OfferResend { message: "verification code expired".to_string(), } ); - } - - #[test] - fn verify_domain_email_mismatch_goes_back_to_email() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::UNAUTHORIZED, - code: "domain_email_not_matched".to_string(), - message: "email does not match the current owner of the domain".to_string(), - }; - - assert_eq!( - classify_verify_submit_error(&error), - VerificationRecovery::BackToEmail { - message: "email does not match the current owner of the domain".to_string(), - } - ); - } - - #[test] - fn verify_other_client_errors_go_back_to_email() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::NOT_FOUND, - code: "domain_not_found".to_string(), - message: "domain not found".to_string(), - }; - assert_eq!( - classify_verify_submit_error(&error), - VerificationRecovery::BackToEmail { - message: "domain not found".to_string(), + classify_verify_submit_error(&api( + reqwest::StatusCode::UNAUTHORIZED, + "domain_email_not_matched", + "email does not match this identity", + )), + VerificationRecovery::ChangeEmail { + message: "email does not match this identity".to_string(), } ); } #[test] - fn resend_business_error_keeps_the_certserver_problem_message() { - let error = crate::cert_server::Error::Api { - status: reqwest::StatusCode::UNAUTHORIZED, - code: "verify_session_invalid".to_string(), - message: "verification session is no longer valid".to_string(), - }; - - assert_eq!( - classify_resend_error(&error), - VerificationRecovery::BackToEmail { - message: "verification session is no longer valid".to_string(), - } - ); + fn limits_blocks_server_and_unknown_errors_stop() { + for error in [ + api( + reqwest::StatusCode::TOO_MANY_REQUESTS, + "verify_code_too_frequent", + "try later", + ), + api( + reqwest::StatusCode::TOO_MANY_REQUESTS, + "verify_code_attempt_exceeded", + "try later", + ), + api( + reqwest::StatusCode::TOO_MANY_REQUESTS, + "verify_code_rate_limited", + "try later", + ), + api( + reqwest::StatusCode::FORBIDDEN, + "user_blocked", + "user is blocked", + ), + api( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "boom", + ), + api( + reqwest::StatusCode::CONFLICT, + "future_code", + "future problem", + ), + ] { + assert_eq!( + classify_verify_submit_error(&error), + VerificationRecovery::Stop + ); + } } } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index df84355..3ff43be 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -6,7 +6,7 @@ use snafu::{OptionExt, whatever}; use super::{auth_plan::CandidateEvent, local}; use crate::{ cert_server::CertServer, - cli::{self, Error, Renew, prompt::InquireResultExt}, + cli::{self, Error, Renew}, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -15,108 +15,19 @@ enum RenewApprovalPlan { Identity { auth_domain: String }, } -#[derive(Debug, Clone, PartialEq, Eq)] -enum RenewVerifyCodeAction { - ResendVerificationCode, - ChangeEmail, - Cancel, -} - -impl RenewVerifyCodeAction { - fn label(&self) -> String { - match self { - Self::ResendVerificationCode => "Resend verification code".to_string(), - Self::ChangeEmail => "Change email".to_string(), - Self::Cancel => "Cancel".to_string(), - } - } -} - -fn renew_verify_code_actions() -> Vec { - vec![ - RenewVerifyCodeAction::ResendVerificationCode, - RenewVerifyCodeAction::ChangeEmail, - RenewVerifyCodeAction::Cancel, - ] -} - #[derive(Debug, Clone)] struct InteractiveRenewState { target: Option>, approval_plan: Option, - email: Option, - email_prompt_required: bool, - verify_code: Option, - verification_code_sent_to: Option, } impl InteractiveRenewState { - fn from_command(command: &Renew, target: Option>) -> Self { + fn from_command(_command: &Renew, target: Option>) -> Self { Self { target, approval_plan: None, - email: command.email.clone(), - email_prompt_required: command.email.is_none(), - verify_code: command.verify_code.clone(), - verification_code_sent_to: None, - } - } - - fn revisit_email(&mut self) { - self.email_prompt_required = true; - self.verify_code = None; - self.verification_code_sent_to = None; - } -} - -fn apply_verification_recovery( - state: &mut InteractiveRenewState, - recovery: &crate::cli::flow::recovery::VerificationRecovery, -) -> bool { - match recovery { - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { message } => { - crate::cli::flow::transcript::print_line(message); - true - } - crate::cli::flow::recovery::VerificationRecovery::OfferResend { message } => { - crate::cli::flow::transcript::print_line(message); - true } - crate::cli::flow::recovery::VerificationRecovery::BackToEmail { message } => { - crate::cli::flow::transcript::print_line(message); - state.revisit_email(); - true - } - crate::cli::flow::recovery::VerificationRecovery::Abort => false, - } -} - -async fn offer_expired_code_resend( - state: &mut InteractiveRenewState, - cert_server: &CertServer, - email: &str, - message: &str, -) -> Result<(), Error> { - crate::cli::flow::transcript::print_block(&crate::cli::flow::recovery::format_resend_offer( - message, - )); - let resend = crate::cli::prompt::sync(|| { - inquire::Confirm::new("Send a new verification code?") - .with_default(true) - .prompt() - }) - .await - .require_interactive("interactive input")?; - if resend { - super::progress::run( - super::progress::SEND_CODE, - cert_server.send_email_verification(email), - ) - .await?; - state.verification_code_sent_to = Some(email.to_string()); } - state.verify_code = None; - Ok(()) } fn renew_not_saved_root_message(short_name: &str) -> String { @@ -162,22 +73,6 @@ async fn resolve_target( } } -async fn prompt_renew_verify_code_action() -> Result { - let actions = renew_verify_code_actions(); - let labels = actions - .iter() - .map(RenewVerifyCodeAction::label) - .collect::>(); - let selected = crate::cli::prompt::prompt_select_string("More options:", labels.clone()) - .await - .require_interactive("interactive input")?; - actions - .into_iter() - .zip(labels) - .find_map(|(action, label)| (label == selected).then_some(action)) - .whatever_context::<_, Error>("selected renew action is unavailable") -} - async fn run_interactive( command: &Renew, dhttp_home: &DhttpHome, @@ -213,87 +108,6 @@ async fn run_interactive( .approval_plan .clone() .whatever_context::<_, Error>("interactive renew approval plan is unavailable")?; - if matches!(approval_plan, RenewApprovalPlan::Email) - && (state.email.is_none() || state.email_prompt_required) - { - let email = crate::cli::prompt::prompt_email_with_default(state.email.as_deref()) - .await - .require_interactive("--email")?; - state.email = Some(email); - state.email_prompt_required = false; - continue; - } - - if matches!(approval_plan, RenewApprovalPlan::Email) && state.verify_code.is_none() { - let email = state - .email - .clone() - .whatever_context::<_, Error>("interactive renew email is unavailable")?; - if state.verification_code_sent_to.as_deref() != Some(email.as_str()) { - match super::progress::run( - super::progress::SEND_CODE, - cert_server.send_email_verification(&email), - ) - .await - { - Ok(_) => { - state.verification_code_sent_to = Some(email.clone()); - } - Err(error) => { - let recovery = crate::cli::flow::recovery::classify_resend_error(&error); - if matches!( - recovery, - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } - ) { - state.verification_code_sent_to = Some(email.clone()); - } - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - } - } - match crate::cli::prompt::prompt_verify_code_with_more_options(None) - .await - .require_interactive("--verify-code")? - { - crate::cli::prompt::TextPromptResult::Submitted(code) => { - state.verify_code = Some(code); - } - crate::cli::prompt::TextPromptResult::MoreOptions => { - match prompt_renew_verify_code_action().await? { - RenewVerifyCodeAction::ResendVerificationCode => { - match super::progress::run( - super::progress::SEND_CODE, - cert_server.send_email_verification(&email), - ) - .await - { - Ok(_) => { - state.verification_code_sent_to = Some(email); - } - Err(error) => { - let recovery = - crate::cli::flow::recovery::classify_resend_error(&error); - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - } - } - RenewVerifyCodeAction::ChangeEmail => state.revisit_email(), - RenewVerifyCodeAction::Cancel => { - whatever!( - "Renew was cancelled.\nNo local identity files were changed." - ); - } - } - } - } - continue; - } let identity_profile = dhttp_home.resolve_identity_profile(domain.borrow()).await?; let local_identity = identity_profile.load_identity().await?; @@ -307,43 +121,14 @@ async fn run_interactive( let detail = match approval_plan { RenewApprovalPlan::Email => { - let email = state - .email - .clone() - .whatever_context::<_, Error>("interactive renew email is unavailable")?; - let verify_code = state.verify_code.as_deref().whatever_context::<_, Error>( - "interactive renew verification code is unavailable", - )?; - let token = match super::progress::run( - super::progress::VERIFY_EMAIL, - cert_server.domain_login(domain.as_full(), &email, verify_code), + let token = super::email::run_cert_server_email_session( + cert_server, + super::email::EmailLogin::Domain(domain.as_full().to_string()), + command.email.as_deref(), + command.verify_code.as_deref(), + true, ) - .await - { - Ok(login) => login.access_token, - Err(error) => { - let recovery = - crate::cli::flow::recovery::classify_verify_submit_error(&error); - if let crate::cli::flow::recovery::VerificationRecovery::OfferResend { - message, - } = &recovery - { - offer_expired_code_resend(&mut state, cert_server, &email, message) - .await?; - continue; - } - if matches!( - recovery, - crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { .. } - ) { - state.verify_code = None; - } - if apply_verification_recovery(&mut state, &recovery) { - continue; - } - return Err(Error::from(error)); - } - }; + .await?; super::progress::run( super::progress::RENEW_IDENTITY, cert_server.renew_cert( @@ -417,11 +202,12 @@ pub(crate) async fn run( let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; let detail = match approval_plan { RenewApprovalPlan::Email => { - let token = cli::login_with_email( + let token = super::email::run_cert_server_email_session( cert_server, - Some(&domain), - command.email.clone(), - command.verify_code.clone(), + super::email::EmailLogin::Domain(domain.as_full().to_string()), + command.email.as_deref(), + command.verify_code.as_deref(), + false, ) .await?; super::progress::run( @@ -473,8 +259,8 @@ mod tests { use dhttp::home::{DhttpHome, HomeScope}; use super::{ - CandidateEvent, InteractiveRenewState, RenewApprovalPlan, RenewVerifyCodeAction, - approval_plan_from_candidate, renew_not_saved_root_message, renew_verify_code_actions, + CandidateEvent, RenewApprovalPlan, approval_plan_from_candidate, + renew_not_saved_root_message, }; use crate::cli::Renew; @@ -494,55 +280,6 @@ mod tests { crate::cert_server::CertServer::new("https://license.genmeta.net").unwrap() } - #[test] - fn stay_recovery_keeps_renew_verify_state() { - let mut state = InteractiveRenewState::from_command( - &Renew { - name: Some("alice.smith".to_string()), - force: false, - device_name: None, - email: Some("alice@example.test".to_string()), - verify_code: None, - }, - None, - ); - state.verify_code = Some("123456".to_string()); - - super::apply_verification_recovery( - &mut state, - &crate::cli::flow::recovery::VerificationRecovery::StayCurrentStep { - message: "retry later".to_string(), - }, - ); - - assert_eq!(state.verify_code.as_deref(), Some("123456")); - } - - #[test] - fn back_to_email_recovery_reopens_renew_email_prompt() { - let mut state = InteractiveRenewState::from_command( - &Renew { - name: Some("alice.smith".to_string()), - force: false, - device_name: None, - email: Some("alice@example.test".to_string()), - verify_code: None, - }, - None, - ); - state.email_prompt_required = false; - - super::apply_verification_recovery( - &mut state, - &crate::cli::flow::recovery::VerificationRecovery::BackToEmail { - message: "start over".to_string(), - }, - ); - - assert!(state.email_prompt_required); - assert!(state.verify_code.is_none()); - } - #[test] fn renew_prefers_ready_identity_non_interactively() { assert_eq!( @@ -592,27 +329,4 @@ mod tests { assert_eq!(rendered, "Failed to renew: alice.smith not found!"); } - - #[test] - fn renew_verify_code_actions_include_resend_and_return_points() { - assert_eq!( - renew_verify_code_actions() - .into_iter() - .map(|action| action.label()) - .collect::>(), - vec![ - "Resend verification code".to_string(), - "Change email".to_string(), - "Cancel".to_string(), - ] - ); - assert_eq!( - renew_verify_code_actions(), - vec![ - RenewVerifyCodeAction::ResendVerificationCode, - RenewVerifyCodeAction::ChangeEmail, - RenewVerifyCodeAction::Cancel, - ] - ); - } } diff --git a/genmeta-identity/src/cli/prompt.rs b/genmeta-identity/src/cli/prompt.rs index 006d2b5..657d8c3 100644 --- a/genmeta-identity/src/cli/prompt.rs +++ b/genmeta-identity/src/cli/prompt.rs @@ -132,10 +132,6 @@ pub(crate) async fn prompt_local_replacement() -> Result Result { - prompt_email_with_default(None).await -} - pub(crate) async fn prompt_email_with_default( default: Option<&str>, ) -> Result { @@ -193,18 +189,6 @@ pub(crate) async fn prompt_select_string( prompt_select_string_with_cursor(message, options, None).await } -pub(crate) async fn prompt_verify_code() -> Result { - sync!( - inquire::Text::new(verify_code_prompt_message()) - .with_validator(inquire::required!("Verification code cannot be empty.")) - .with_validator(inquire::length!( - 6, - "Verification code must be exactly 6 characters." - )) - .prompt() - ) -} - pub(crate) async fn prompt_kind() -> Result { prompt_kind_with_cursor(None).await } @@ -266,6 +250,14 @@ pub(crate) async fn prompt_verify_code_with_more_options( .map(text_prompt_result) } +pub(crate) async fn confirm_send_new_verification_code() -> Result { + sync!( + inquire::Confirm::new("Send a new verification code?") + .with_default(true) + .prompt() + ) +} + pub(crate) async fn prompt_select_string_with_cursor( message: &str, options: Vec, From b6ff3e66ef7d81adf93068abdbe33d6b16b6d900 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:12:30 +0800 Subject: [PATCH 12/27] feat(identity): validate lazy certificate installation --- genmeta-identity/src/cli.rs | 60 +-- genmeta-identity/src/cli/flow.rs | 2 + genmeta-identity/src/cli/flow/apply.rs | 46 ++- genmeta-identity/src/cli/flow/install.rs | 375 ++++++++++++++++++ genmeta-identity/src/cli/flow/key_material.rs | 147 +++++++ genmeta-identity/src/cli/flow/renew.rs | 48 ++- genmeta-identity/src/local_identity.rs | 6 +- .../tests/fixtures/install/chain.pem | 28 ++ .../tests/fixtures/install/leaf.crt | 15 + .../tests/fixtures/install/leaf.key | 6 + .../fixtures/install/malformed-ski-chain.pem | 27 ++ .../fixtures/install/malformed-ski-leaf.crt | 14 + .../tests/fixtures/install/other.key | 6 + .../tests/fixtures/install/root.crt | 13 + 14 files changed, 717 insertions(+), 76 deletions(-) create mode 100644 genmeta-identity/src/cli/flow/install.rs create mode 100644 genmeta-identity/src/cli/flow/key_material.rs create mode 100644 genmeta-identity/tests/fixtures/install/chain.pem create mode 100644 genmeta-identity/tests/fixtures/install/leaf.crt create mode 100644 genmeta-identity/tests/fixtures/install/leaf.key create mode 100644 genmeta-identity/tests/fixtures/install/malformed-ski-chain.pem create mode 100644 genmeta-identity/tests/fixtures/install/malformed-ski-leaf.crt create mode 100644 genmeta-identity/tests/fixtures/install/other.key create mode 100644 genmeta-identity/tests/fixtures/install/root.crt diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index c7ce775..2ada7b0 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -1,8 +1,7 @@ pub mod flow; pub mod prompt; pub mod validator; - -use std::{io::IsTerminal, ops::Deref}; +use std::io::IsTerminal; use clap::Parser; use dhttp::{ @@ -13,15 +12,14 @@ use dhttp::{ settings::{DhttpSettingsFile, LoadDhttpSettingsError, SaveDhttpSettingsError}, ssl::{ ListIdentityProfilesError, LoadCertsError, LoadIdentityError, LoadKeyError, - ResolveIdentityProfileError, SaveIdentityError, + ResolveIdentityProfileError, }, }, }, name::DhttpName as Name, }; -pub use flow::welcome::WelcomeServiceError; +pub use flow::{install::Error as InstallError, welcome::WelcomeServiceError}; use indicatif::ProgressStyle; -use rankey::EncodePem; use snafu::{ResultExt, Snafu, Whatever, whatever}; use tokio::io; use tracing_indicatif::{ @@ -44,8 +42,6 @@ pub enum Error { #[snafu(transparent)] CertServer { source: cert_server::Error }, #[snafu(transparent)] - SaveIdentity { source: SaveIdentityError }, - #[snafu(transparent)] LoadDefaultConfig { source: LoadDhttpSettingsError }, #[snafu(transparent)] SaveDefaultConfig { source: SaveDhttpSettingsError }, @@ -78,6 +74,8 @@ pub enum Error { LocalIdentity { source: crate::local_identity::Error, }, + #[snafu(transparent)] + Install { source: InstallError }, #[snafu(display("failed to generate private key"))] GenerateKey { @@ -122,43 +120,16 @@ fn certificate_chain_key_from_identity( } } -fn generate_private_key_and_csr( - name: &Name<'_>, -) -> Result<(impl Deref + use<>, String), Error> { - let key_pem = flow::progress::run_sync(flow::progress::GENERATE_KEY, || { - rankey::generate_secp384r1_key() - .map_err(|e| Box::new(e) as Box) - .context(GenerateKeySnafu) - })?; - let csr = rankey::generate_csr(&key_pem, "CN", name.as_full(), &[name.as_full()]) - .map_err(|e| Box::new(e) as Box) - .context(GenerateCsrSnafu)?; - let csr_pem = csr - .to_pem(rankey::LineEnding::LF) - .map_err(|e| Box::new(e) as Box) - .context(EncodeCsrSnafu)?; +fn generate_private_key_and_csr(name: &Name<'_>) -> Result<(String, String), Error> { + let mut material = flow::key_material::LazyKeyMaterial::for_name(name.clone()); + let csr_pem = material.csr_pem()?.to_string(); + let key_pem = material + .key_pem() + .expect("requesting a CSR generates its key first") + .to_string(); Ok((key_pem, csr_pem)) } -async fn save_identity( - dhttp_home: &DhttpHome, - name: &Name<'_>, - key_pem: &[u8], - cert_pem: &[u8], -) -> Result<(), Error> { - let profile = dhttp_home.identity_profile(name.borrow()); - flow::progress::run_with_spinner( - save_identity_progress_message(), - profile.save_identity(cert_pem, key_pem), - ) - .await?; - Ok(()) -} - -fn save_identity_progress_message() -> &'static str { - "Saving identity..." -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LocalIdentitySave { New, @@ -492,7 +463,7 @@ mod tests { use super::{ Apply, Cli, Default, Info, Options, cert_server_base_url, - certificate_chain_key_from_identity, save_identity_progress_message, + certificate_chain_key_from_identity, }; use crate::CERT_SERVER_BASE_URL; @@ -670,11 +641,6 @@ mod tests { assert!(rendered.contains("--sequence"), "{rendered}"); } - #[test] - fn save_progress_uses_user_task_copy() { - assert_eq!(save_identity_progress_message(), "Saving identity..."); - } - #[test] fn apply_and_renew_reject_default_flag() { let apply_error = diff --git a/genmeta-identity/src/cli/flow.rs b/genmeta-identity/src/cli/flow.rs index aa1615a..d041fba 100644 --- a/genmeta-identity/src/cli/flow.rs +++ b/genmeta-identity/src/cli/flow.rs @@ -4,6 +4,8 @@ pub(crate) mod default_identity; pub(crate) mod device; pub(crate) mod email; pub(crate) mod epilogue; +pub(crate) mod install; +pub(crate) mod key_material; pub(crate) mod kind; pub(crate) mod local; pub(crate) mod output; diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 00a3d48..56de7f6 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -121,6 +121,27 @@ fn approval_plan_from_candidate(candidate: CandidateEvent) -> Result, + kind: IdentityKind, + key_pem: &str, + detail: &crate::cert_server::CertificateDetail, +) -> Result<(), Error> { + super::install::validate_and_save( + dhttp_home, + detail, + &super::install::InstallExpectation { + target: domain, + kind: kind.into(), + sequence: None, + }, + key_pem, + ) + .await?; + Ok(()) +} + fn apply_identity_name_opening() -> &'static str { "Applying identity, generating ECC key pair locally, then requesting and deploying certificate." } @@ -474,11 +495,12 @@ async fn run_interactive_with_policy( ), ) .await?; - cli::save_identity( + validate_and_save_apply( dhttp_home, - &domain, - key_pem.as_bytes(), - detail.cert_pem.as_bytes(), + domain.borrow(), + kind, + &key_pem, + &detail, ) .await?; let welcome = super::welcome::maybe_create_welcome_service( @@ -575,13 +597,7 @@ async fn run_interactive_with_policy( } }; - cli::save_identity( - dhttp_home, - &domain, - key_pem.as_bytes(), - detail.cert_pem.as_bytes(), - ) - .await?; + validate_and_save_apply(dhttp_home, domain.borrow(), kind, &key_pem, &detail).await?; let welcome = super::welcome::maybe_create_welcome_service( dhttp_home, domain.borrow(), @@ -742,13 +758,7 @@ pub(crate) async fn run_with_policy( } }; - cli::save_identity( - dhttp_home, - &domain, - key_pem.as_bytes(), - detail.cert_pem.as_bytes(), - ) - .await?; + validate_and_save_apply(dhttp_home, domain.borrow(), kind, &key_pem, &detail).await?; let welcome = super::welcome::maybe_create_welcome_service( dhttp_home, domain.borrow(), diff --git a/genmeta-identity/src/cli/flow/install.rs b/genmeta-identity/src/cli/flow/install.rs new file mode 100644 index 0000000..0310f73 --- /dev/null +++ b/genmeta-identity/src/cli/flow/install.rs @@ -0,0 +1,375 @@ +use std::sync::Arc; + +use dhttp::{ + certificate::{CertificateChainKind, DhttpSubjectKeyIdentifier}, + name::DhttpName, +}; +use rustls::{ + pki_types::{CertificateDer, PrivateKeyDer, UnixTime, pem::PemObject}, + server::danger::ClientCertVerifier, +}; +use snafu::{OptionExt, ResultExt, Snafu, ensure}; +use x509_parser::{ + extensions::GeneralName, + prelude::{FromDer, X509Certificate}, +}; + +use crate::cert_server::CertificateDetail; + +#[derive(Debug, Clone)] +pub(crate) struct InstallExpectation<'a> { + pub(crate) target: DhttpName<'a>, + pub(crate) kind: CertificateChainKind, + pub(crate) sequence: Option, +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum Error { + #[snafu(display( + "certificate response target name {actual} does not match expected target name {expected}" + ))] + ResponseTargetName { expected: String, actual: String }, + #[snafu(display( + "certificate response usage {actual} does not match expected usage {expected}" + ))] + ResponseUsage { expected: String, actual: String }, + #[snafu(display( + "certificate response sequence {actual} does not match expected sequence {expected}" + ))] + ResponseSequence { expected: u32, actual: u32 }, + #[snafu(display("certificate response is missing DHTTP certificate metadata"))] + MissingResponseMetadata, + #[snafu(display("certificate response contains invalid DHTTP certificate metadata"))] + InvalidResponseMetadata { + source: dhttp::certificate::InvalidDhttpSubjectKeyIdentifier, + }, + #[snafu(display("certificate response DHTTP certificate metadata does not match the leaf"))] + ResponseMetadataMismatch, + #[snafu(display("failed to parse certificate PEM chain"))] + ParseCertificatePem { + source: rustls::pki_types::pem::Error, + }, + #[snafu(display("certificate PEM chain is empty"))] + EmptyCertificateChain, + #[snafu(display("failed to parse leaf certificate"))] + ParseLeafCertificate { + source: x509_parser::nom::Err, + }, + #[snafu(display("failed to parse leaf certificate subject alternative names"))] + ParseSubjectAlternativeName { + source: x509_parser::error::X509Error, + }, + #[snafu(display("leaf certificate does not contain expected target name {target}"))] + MissingTargetName { target: String }, + #[snafu(display("leaf certificate is missing or has invalid DHTTP certificate metadata"))] + LeafMetadata { + source: dhttp::identity::ExtractDhttpSubjectKeyIdentifierError, + }, + #[snafu(display("leaf DHTTP certificate metadata usage does not match the response"))] + LeafMetadataUsage, + #[snafu(display("leaf DHTTP certificate metadata sequence does not match the response"))] + LeafMetadataSequence, + #[snafu(display("failed to parse generated private key"))] + ParsePrivateKey { + source: rustls::pki_types::pem::Error, + }, + #[snafu(display("failed to compare generated private key with leaf certificate"))] + ComparePrivateKey { + source: crate::local_identity::Error, + }, + #[snafu(display("leaf certificate does not match the generated private key"))] + PrivateKeyMismatch, + #[snafu(display("certificate chain is not trusted by the DHTTP root"))] + UntrustedCertificate { source: rustls::Error }, + #[snafu(display("failed to install validated identity"))] + SaveIdentity { + source: dhttp::home::identity::ssl::SaveIdentityError, + }, +} + +fn parse_kind(kind: &str) -> Option { + match kind { + "primary" => Some(CertificateChainKind::Primary), + "secondary" => Some(CertificateChainKind::Secondary), + _ => None, + } +} + +pub(crate) fn validate_install( + detail: &CertificateDetail, + expected: &InstallExpectation<'_>, + key_pem: &str, +) -> Result<(), Error> { + let verifier = + dhttp::trust::dhttp_client_cert_verifier(dhttp::trust::ClientIdentityPolicy::Required); + validate_install_with_verifier(detail, expected, key_pem, verifier) +} + +fn validate_install_with_verifier( + detail: &CertificateDetail, + expected: &InstallExpectation<'_>, + key_pem: &str, + verifier: Arc, +) -> Result<(), Error> { + ensure!( + detail.domain == expected.target.as_full(), + error::ResponseTargetNameSnafu { + expected: expected.target.as_full(), + actual: &detail.domain, + } + ); + + let detail_kind = parse_kind(&detail.kind).context(error::ResponseUsageSnafu { + expected: expected.kind.as_str(), + actual: &detail.kind, + })?; + ensure!( + detail_kind == expected.kind, + error::ResponseUsageSnafu { + expected: expected.kind.as_str(), + actual: &detail.kind, + } + ); + if let Some(expected_sequence) = expected.sequence { + ensure!( + detail.sequence == expected_sequence, + error::ResponseSequenceSnafu { + expected: expected_sequence, + actual: detail.sequence, + } + ); + } + + let response_ski = detail + .ski + .as_deref() + .context(error::MissingResponseMetadataSnafu)?; + let response_ski = + DhttpSubjectKeyIdentifier::try_from_subject_key_identifier_bytes(response_ski.as_bytes()) + .context(error::InvalidResponseMetadataSnafu)?; + + let certs = CertificateDer::pem_slice_iter(detail.cert_pem.as_bytes()) + .collect::, _>>() + .context(error::ParseCertificatePemSnafu)?; + let leaf = certs.first().context(error::EmptyCertificateChainSnafu)?; + let (_, leaf_certificate) = + X509Certificate::from_der(leaf.as_ref()).context(error::ParseLeafCertificateSnafu)?; + let subject_alternative_name = leaf_certificate + .subject_alternative_name() + .context(error::ParseSubjectAlternativeNameSnafu)?; + let has_target_name = subject_alternative_name.as_ref().is_some_and(|extension| { + extension.value.general_names.iter().any(|name| match name { + GeneralName::DNSName(candidate) => candidate == &expected.target.as_full(), + _ => false, + }) + }); + ensure!( + has_target_name, + error::MissingTargetNameSnafu { + target: expected.target.as_full(), + } + ); + + let leaf_ski = dhttp::identity::extract_dhttp_subject_key_identifier(&certs) + .context(error::LeafMetadataSnafu)?; + ensure!( + leaf_ski == response_ski, + error::ResponseMetadataMismatchSnafu + ); + ensure!( + leaf_ski.chain().kind() == detail_kind, + error::LeafMetadataUsageSnafu + ); + ensure!( + leaf_ski.chain().sequence().get() == detail.sequence, + error::LeafMetadataSequenceSnafu + ); + + let private_key = + PrivateKeyDer::from_pem_slice(key_pem.as_bytes()).context(error::ParsePrivateKeySnafu)?; + let key_matches = crate::local_identity::private_key_matches_certificate_der( + private_key.secret_der(), + leaf.as_ref(), + ) + .context(error::ComparePrivateKeySnafu)?; + ensure!(key_matches, error::PrivateKeyMismatchSnafu); + + verifier + .verify_client_cert(leaf, &certs[1..], UnixTime::now()) + .context(error::UntrustedCertificateSnafu)?; + Ok(()) +} + +pub(crate) async fn validate_and_save( + dhttp_home: &dhttp::home::DhttpHome, + detail: &CertificateDetail, + expected: &InstallExpectation<'_>, + key_pem: &str, +) -> Result<(), Error> { + validate_install(detail, expected, key_pem)?; + dhttp_home + .identity_profile(expected.target.borrow()) + .save_identity(detail.cert_pem.as_bytes(), key_pem.as_bytes()) + .await + .context(error::SaveIdentitySnafu) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use rustls::{RootCertStore, pki_types::CertificateDer, server::WebPkiClientVerifier}; + + use super::*; + + const CHAIN: &str = include_str!("../../../tests/fixtures/install/chain.pem"); + const MALFORMED_SKI_CHAIN: &str = + include_str!("../../../tests/fixtures/install/malformed-ski-chain.pem"); + const KEY: &str = include_str!("../../../tests/fixtures/install/leaf.key"); + const OTHER_KEY: &str = include_str!("../../../tests/fixtures/install/other.key"); + const ROOT: &[u8] = include_bytes!("../../../tests/fixtures/install/root.crt"); + const SKI: &str = "7:0:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn detail() -> CertificateDetail { + CertificateDetail { + domain: "alice.smith.dhttp.net".to_string(), + device_name: Some("test device".to_string()), + sequence: 7, + kind: "primary".to_string(), + serial_number: None, + ski: Some(SKI.to_string()), + ski_version: Some("1".to_string()), + status: "active".to_string(), + csr: String::new(), + cert_pem: CHAIN.to_string(), + issued_at: 0, + valid_not_after: i64::MAX, + created_at: 0, + } + } + + fn expectation<'a>(name: DhttpName<'a>) -> InstallExpectation<'a> { + InstallExpectation { + target: name, + kind: CertificateChainKind::Primary, + sequence: Some(7), + } + } + + fn test_verifier() -> Arc { + _ = rustls::crypto::ring::default_provider().install_default(); + let mut roots = RootCertStore::empty(); + roots.add_parsable_certificates(CertificateDer::pem_slice_iter(ROOT).map(Result::unwrap)); + WebPkiClientVerifier::builder(Arc::new(roots)) + .build() + .unwrap() + } + + fn assert_validation_error( + detail: &CertificateDetail, + expected: &InstallExpectation<'_>, + key: &str, + expected_message: &str, + ) { + let error = validate_install_with_verifier(detail, expected, key, test_verifier()) + .expect_err("fixture must fail validation"); + assert!( + error.to_string().contains(expected_message), + "expected {expected_message:?} in {error}" + ); + } + + #[test] + fn install_validation_rejects_mismatched_target_kind_sequence_key_and_metadata() { + let name = DhttpName::try_from("alice.smith").unwrap(); + let expected = expectation(name.borrow()); + validate_install_with_verifier(&detail(), &expected, KEY, test_verifier()).unwrap(); + validate_install_with_verifier( + &detail(), + &InstallExpectation { + sequence: None, + ..expected.clone() + }, + KEY, + test_verifier(), + ) + .unwrap(); + + let mut other_target = detail(); + other_target.domain = "other.smith.dhttp.net".to_string(); + assert_validation_error(&other_target, &expected, KEY, "target name"); + + let other_name = DhttpName::try_from("other.smith").unwrap(); + let other_target_expected = InstallExpectation { + target: other_name.borrow(), + ..expected.clone() + }; + assert_validation_error(&other_target, &other_target_expected, KEY, "target name"); + + let mut other_kind = detail(); + other_kind.kind = "secondary".to_string(); + assert_validation_error(&other_kind, &expected, KEY, "usage"); + let secondary_expected = InstallExpectation { + kind: CertificateChainKind::Secondary, + ..expected.clone() + }; + assert_validation_error(&other_kind, &secondary_expected, KEY, "usage"); + + let other_sequence = InstallExpectation { + sequence: Some(8), + ..expected.clone() + }; + assert_validation_error(&detail(), &other_sequence, KEY, "sequence"); + let mut other_sequence_detail = detail(); + other_sequence_detail.sequence = 8; + assert_validation_error(&other_sequence_detail, &other_sequence, KEY, "sequence"); + assert_validation_error(&detail(), &expected, OTHER_KEY, "private key"); + + let mut missing_response_ski = detail(); + missing_response_ski.ski = None; + assert_validation_error( + &missing_response_ski, + &expected, + KEY, + "DHTTP certificate metadata", + ); + + let mut malformed_leaf_ski = detail(); + malformed_leaf_ski.cert_pem = MALFORMED_SKI_CHAIN.to_string(); + assert_validation_error( + &malformed_leaf_ski, + &expected, + KEY, + "DHTTP certificate metadata", + ); + } + + #[test] + fn production_trust_rejects_a_chain_outside_the_dhttp_root() { + let name = DhttpName::try_from("alice.smith").unwrap(); + let error = validate_install(&detail(), &expectation(name.borrow()), KEY).unwrap_err(); + + assert!(error.to_string().contains("trusted"), "{error}"); + } + + #[tokio::test] + async fn failed_validation_never_creates_or_saves_a_profile() { + let home_path = std::env::temp_dir().join(format!( + "genmeta-identity-invalid-install-{}", + std::process::id() + )); + let _ = tokio::fs::remove_dir_all(&home_path).await; + let home = dhttp::home::DhttpHome::new(home_path.clone()); + let name = DhttpName::try_from("alice.smith").unwrap(); + let mut invalid = detail(); + invalid.domain = "other.smith.dhttp.net".to_string(); + + assert!( + validate_and_save(&home, &invalid, &expectation(name.borrow()), KEY) + .await + .is_err() + ); + assert!(!home_path.exists()); + } +} diff --git a/genmeta-identity/src/cli/flow/key_material.rs b/genmeta-identity/src/cli/flow/key_material.rs new file mode 100644 index 0000000..f477f5f --- /dev/null +++ b/genmeta-identity/src/cli/flow/key_material.rs @@ -0,0 +1,147 @@ +use dhttp::name::DhttpName; +use rankey::EncodePem; +use snafu::ResultExt; + +use crate::cli::{EncodeCsrSnafu, Error, GenerateCsrSnafu, GenerateKeySnafu}; + +pub(crate) trait KeyMaterialGenerator { + fn generate_key(&self) -> Result; + fn generate_csr(&self, key_pem: &str, full_name: &str) -> Result; +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct RankeyGenerator; + +impl KeyMaterialGenerator for RankeyGenerator { + fn generate_key(&self) -> Result { + rankey::generate_secp384r1_key() + .map(|key| key.to_string()) + .map_err(|error| Box::new(error) as Box) + .context(GenerateKeySnafu) + } + + fn generate_csr(&self, key_pem: &str, full_name: &str) -> Result { + rankey::generate_csr(key_pem, "CN", full_name, &[full_name]) + .map_err(|error| Box::new(error) as Box) + .context(GenerateCsrSnafu)? + .to_pem(rankey::LineEnding::LF) + .map_err(|error| Box::new(error) as Box) + .context(EncodeCsrSnafu) + } +} + +pub(crate) struct LazyKeyMaterial { + name: DhttpName<'static>, + generator: G, + key_pem: Option, + csr_pem: Option, +} + +impl LazyKeyMaterial +where + G: KeyMaterialGenerator, +{ + pub(crate) fn new(name: DhttpName<'_>, generator: G) -> Self { + Self { + name: name.into_owned(), + generator, + key_pem: None, + csr_pem: None, + } + } + + pub(crate) fn csr_pem(&mut self) -> Result<&str, Error> { + if self.key_pem.is_none() { + self.key_pem = Some(super::progress::run_sync( + super::progress::GENERATE_KEY, + || self.generator.generate_key(), + )?); + } + if self.csr_pem.is_none() { + let csr = self.generator.generate_csr( + self.key_pem + .as_deref() + .expect("key material was generated above"), + self.name.as_full(), + )?; + self.csr_pem = Some(csr); + } + Ok(self + .csr_pem + .as_deref() + .expect("CSR material was generated above")) + } + + pub(crate) fn key_pem(&self) -> Option<&str> { + self.key_pem.as_deref() + } +} + +impl LazyKeyMaterial { + pub(crate) fn for_name(name: DhttpName<'_>) -> Self { + Self::new(name, RankeyGenerator) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use super::*; + + #[derive(Clone, Default)] + struct CountingGenerator { + key_calls: Arc, + csr_calls: Arc, + } + + impl CountingGenerator { + fn key_calls(&self) -> usize { + self.key_calls.load(Ordering::SeqCst) + } + + fn csr_calls(&self) -> usize { + self.csr_calls.load(Ordering::SeqCst) + } + } + + impl KeyMaterialGenerator for CountingGenerator { + fn generate_key(&self) -> Result { + self.key_calls.fetch_add(1, Ordering::SeqCst); + Ok("test-key".to_string()) + } + + fn generate_csr(&self, key_pem: &str, full_name: &str) -> Result { + self.csr_calls.fetch_add(1, Ordering::SeqCst); + Ok(format!("csr:{key_pem}:{full_name}")) + } + } + + #[test] + fn lazy_material_generates_one_key_and_one_csr_across_retries() { + let generator = CountingGenerator::default(); + let name = DhttpName::try_from("alice.smith").unwrap(); + let mut material = LazyKeyMaterial::new(name.borrow(), generator.clone()); + + let first = material.csr_pem().unwrap().to_string(); + let second = material.csr_pem().unwrap().to_string(); + + assert_eq!(first, second); + assert_eq!(material.key_pem(), Some("test-key")); + assert_eq!(generator.key_calls(), 1); + assert_eq!(generator.csr_calls(), 1); + } + + #[test] + fn constructing_lazy_material_has_no_crypto_side_effect() { + let generator = CountingGenerator::default(); + let name = DhttpName::try_from("alice.smith").unwrap(); + let _material = LazyKeyMaterial::new(name.borrow(), generator.clone()); + + assert_eq!(generator.key_calls(), 0); + assert_eq!(generator.csr_calls(), 0); + } +} diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 3ff43be..dbe70bb 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -63,6 +63,28 @@ fn approval_plan_from_candidate(candidate: CandidateEvent) -> Result, + kind: dhttp::certificate::CertificateChainKind, + sequence: u32, + key_pem: &str, + detail: &crate::cert_server::CertificateDetail, +) -> Result<(), Error> { + super::install::validate_and_save( + dhttp_home, + detail, + &super::install::InstallExpectation { + target: domain, + kind, + sequence: Some(sequence), + }, + key_pem, + ) + .await?; + Ok(()) +} + async fn resolve_target( command: &Renew, dhttp_home: &DhttpHome, @@ -113,7 +135,8 @@ async fn run_interactive( let local_identity = identity_profile.load_identity().await?; let chain_key = cli::certificate_chain_key_from_identity(&local_identity)? .whatever_context::<_, Error>("local identity does not expose a certificate chain")?; - let kind = chain_key.kind().as_str(); + let chain_kind = chain_key.kind(); + let kind = chain_kind.as_str(); let sequence = chain_key.sequence().get(); let device_name = super::device::resolve_device_name(command.device_name.as_deref(), home_scope); @@ -158,11 +181,13 @@ async fn run_interactive( } }; - cli::save_identity( + validate_and_save_renew( dhttp_home, - &domain, - key_pem.as_bytes(), - detail.cert_pem.as_bytes(), + domain.borrow(), + chain_kind, + sequence, + &key_pem, + &detail, ) .await?; return Ok(()); @@ -194,7 +219,8 @@ pub(crate) async fn run( let local_identity = identity_profile.load_identity().await?; let chain_key = cli::certificate_chain_key_from_identity(&local_identity)? .whatever_context::<_, Error>("local identity does not expose a certificate chain")?; - let kind = chain_key.kind().as_str(); + let chain_kind = chain_key.kind(); + let kind = chain_kind.as_str(); let sequence = chain_key.sequence().get(); let device_name = super::device::resolve_device_name(command.device_name.as_deref(), home_scope); @@ -239,11 +265,13 @@ pub(crate) async fn run( } }; - cli::save_identity( + validate_and_save_renew( dhttp_home, - &domain, - key_pem.as_bytes(), - detail.cert_pem.as_bytes(), + domain.borrow(), + chain_kind, + sequence, + &key_pem, + &detail, ) .await?; Ok(()) diff --git a/genmeta-identity/src/local_identity.rs b/genmeta-identity/src/local_identity.rs index 6b6357b..55a813f 100644 --- a/genmeta-identity/src/local_identity.rs +++ b/genmeta-identity/src/local_identity.rs @@ -16,8 +16,12 @@ pub enum Error { pub fn private_key_matches_certificate(key_der: &[u8], cert_pem: &[u8]) -> Result { let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem).map_err(|_| Error::ParseCertificatePem)?; + private_key_matches_certificate_der(key_der, &pem.contents) +} + +pub fn private_key_matches_certificate_der(key_der: &[u8], cert_der: &[u8]) -> Result { let (_, cert) = - x509_parser::parse_x509_certificate(&pem.contents).map_err(|_| Error::ParseCertificate)?; + x509_parser::parse_x509_certificate(cert_der).map_err(|_| Error::ParseCertificate)?; let cert_spki = cert.public_key().raw; let signing_key = diff --git a/genmeta-identity/tests/fixtures/install/chain.pem b/genmeta-identity/tests/fixtures/install/chain.pem new file mode 100644 index 0000000..4da564a --- /dev/null +++ b/genmeta-identity/tests/fixtures/install/chain.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIICXjCCAeWgAwIBAgIUc5iTarjYuHkuTLMNTvZ1L8wSdzQwCgYIKoZIzj0EAwMw +LTErMCkGA1UEAwwiZ2VubWV0YSBpZGVudGl0eSBpbnN0YWxsIHRlc3Qgcm9vdDAe +Fw0yNjA3MTMxNzA1NDVaFw0zNjA3MTAxNzA1NDVaMCAxHjAcBgNVBAMMFWFsaWNl +LnNtaXRoLmRodHRwLm5ldDB2MBAGByqGSM49AgEGBSuBBAAiA2IABHiXW9gURSwt +x7jPr5g/GH1RAGavJ5e8WWHEA3OhWm8TvWEiWo6m+hJrGh5xXrrNk8zMp/Cn7m2v +6XI0N4mXEtU3RQuG6oMLt9WkvDf2iahwvVfrY9JXfMhg5ecpn+Or2qOB0jCBzzAM +BgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAUBggrBgEFBQcD +AgYIKwYBBQUHAwEwIAYDVR0RBBkwF4IVYWxpY2Uuc21pdGguZGh0dHAubmV0ME0G +A1UdDgRGBEQ3OjA6MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIz +NDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjAfBgNVHSMEGDAWgBTs0ygDtv1W +ZPYfST/MegeEA3v0WTAKBggqhkjOPQQDAwNnADBkAjA9/BmvRDkZzSLRVDBquEkb +sEKjqYPaxOGGT1XWWivy0Wk5DXTiS0FyOzJVhp9Wg5cCMBH62BA18uM9m/5StcV1 +D/4+sbF0A9EoDGQh3JmpkHjkuMv7sRjvTvBggggJaDihBw== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIB/DCCAYKgAwIBAgIUECbL8M8d18tsi39M7rEXwkDh1ykwCgYIKoZIzj0EAwMw +LTErMCkGA1UEAwwiZ2VubWV0YSBpZGVudGl0eSBpbnN0YWxsIHRlc3Qgcm9vdDAe +Fw0yNjA3MTMxNzA1NDVaFw0zNjA3MTAxNzA1NDVaMC0xKzApBgNVBAMMImdlbm1l +dGEgaWRlbnRpdHkgaW5zdGFsbCB0ZXN0IHJvb3QwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATapdygUzH2icF523Wlg/jjgKaA/0BhnbRN7OKCifcIk966jzI+sRzUkmoP +OlWoiyf3YjIlk2wp4EZZlmBZZUu9hGMFOlQJOjz3BosDQrfsz3H9etzUy8vVR6uE +zQ+wJ0ejYzBhMB0GA1UdDgQWBBTs0ygDtv1WZPYfST/MegeEA3v0WTAfBgNVHSME +GDAWgBTs0ygDtv1WZPYfST/MegeEA3v0WTAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjBeNcU4ZagtFZrG3PVCzpa3bHpN +f89Wa5wgGgekX7DU6A6xRwaXoCfxHRx3AaQy0gsCMQD8otQIsdl9m+0WRZm0XCca +8FBuvfsRK/uaXQww5JkV1S6Sfgix5mA+GpWjiFIl+5g= +-----END CERTIFICATE----- diff --git a/genmeta-identity/tests/fixtures/install/leaf.crt b/genmeta-identity/tests/fixtures/install/leaf.crt new file mode 100644 index 0000000..eafec72 --- /dev/null +++ b/genmeta-identity/tests/fixtures/install/leaf.crt @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICXjCCAeWgAwIBAgIUc5iTarjYuHkuTLMNTvZ1L8wSdzQwCgYIKoZIzj0EAwMw +LTErMCkGA1UEAwwiZ2VubWV0YSBpZGVudGl0eSBpbnN0YWxsIHRlc3Qgcm9vdDAe +Fw0yNjA3MTMxNzA1NDVaFw0zNjA3MTAxNzA1NDVaMCAxHjAcBgNVBAMMFWFsaWNl +LnNtaXRoLmRodHRwLm5ldDB2MBAGByqGSM49AgEGBSuBBAAiA2IABHiXW9gURSwt +x7jPr5g/GH1RAGavJ5e8WWHEA3OhWm8TvWEiWo6m+hJrGh5xXrrNk8zMp/Cn7m2v +6XI0N4mXEtU3RQuG6oMLt9WkvDf2iahwvVfrY9JXfMhg5ecpn+Or2qOB0jCBzzAM +BgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAUBggrBgEFBQcD +AgYIKwYBBQUHAwEwIAYDVR0RBBkwF4IVYWxpY2Uuc21pdGguZGh0dHAubmV0ME0G +A1UdDgRGBEQ3OjA6MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIz +NDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjAfBgNVHSMEGDAWgBTs0ygDtv1W +ZPYfST/MegeEA3v0WTAKBggqhkjOPQQDAwNnADBkAjA9/BmvRDkZzSLRVDBquEkb +sEKjqYPaxOGGT1XWWivy0Wk5DXTiS0FyOzJVhp9Wg5cCMBH62BA18uM9m/5StcV1 +D/4+sbF0A9EoDGQh3JmpkHjkuMv7sRjvTvBggggJaDihBw== +-----END CERTIFICATE----- diff --git a/genmeta-identity/tests/fixtures/install/leaf.key b/genmeta-identity/tests/fixtures/install/leaf.key new file mode 100644 index 0000000..97b585e --- /dev/null +++ b/genmeta-identity/tests/fixtures/install/leaf.key @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDM4PsT01EMPKQvqqNA +v91w+KCHYXkQycNlSdpPnYZ8Ro87h3/0RREp6jy6Afqr2WKhZANiAAR4l1vYFEUs +Lce4z6+YPxh9UQBmryeXvFlhxANzoVpvE71hIlqOpvoSaxoecV66zZPMzKfwp+5t +r+lyNDeJlxLVN0ULhuqDC7fVpLw39omocL1X62PSV3zIYOXnKZ/jq9o= +-----END PRIVATE KEY----- diff --git a/genmeta-identity/tests/fixtures/install/malformed-ski-chain.pem b/genmeta-identity/tests/fixtures/install/malformed-ski-chain.pem new file mode 100644 index 0000000..8869fcd --- /dev/null +++ b/genmeta-identity/tests/fixtures/install/malformed-ski-chain.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIICHTCCAaKgAwIBAgIUc5iTarjYuHkuTLMNTvZ1L8wSdzUwCgYIKoZIzj0EAwMw +LTErMCkGA1UEAwwiZ2VubWV0YSBpZGVudGl0eSBpbnN0YWxsIHRlc3Qgcm9vdDAe +Fw0yNjA3MTMxNzA1NDVaFw0zNjA3MTAxNzA1NDVaMCAxHjAcBgNVBAMMFWFsaWNl +LnNtaXRoLmRodHRwLm5ldDB2MBAGByqGSM49AgEGBSuBBAAiA2IABHiXW9gURSwt +x7jPr5g/GH1RAGavJ5e8WWHEA3OhWm8TvWEiWo6m+hJrGh5xXrrNk8zMp/Cn7m2v +6XI0N4mXEtU3RQuG6oMLt9WkvDf2iahwvVfrY9JXfMhg5ecpn+Or2qOBjzCBjDAM +BgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAUBggrBgEFBQcD +AgYIKwYBBQUHAwEwIAYDVR0RBBkwF4IVYWxpY2Uuc21pdGguZGh0dHAubmV0MAoG +A1UdDgQDBAEAMB8GA1UdIwQYMBaAFOzTKAO2/VZk9h9JP8x6B4QDe/RZMAoGCCqG +SM49BAMDA2kAMGYCMQC9+dwoUa9BPqyvZe9jHe/kQ32nNgX3Vo+HlolRA5x6hy7C +M2hnYlNRdL/lBTH9bZ8CMQDeByP50xQy+L/OJJFMyz7npH2NxH0FTzO3IW3lNvcl +37RLKVD7Y4rhpl7VhMQHXRY= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIB/DCCAYKgAwIBAgIUECbL8M8d18tsi39M7rEXwkDh1ykwCgYIKoZIzj0EAwMw +LTErMCkGA1UEAwwiZ2VubWV0YSBpZGVudGl0eSBpbnN0YWxsIHRlc3Qgcm9vdDAe +Fw0yNjA3MTMxNzA1NDVaFw0zNjA3MTAxNzA1NDVaMC0xKzApBgNVBAMMImdlbm1l +dGEgaWRlbnRpdHkgaW5zdGFsbCB0ZXN0IHJvb3QwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATapdygUzH2icF523Wlg/jjgKaA/0BhnbRN7OKCifcIk966jzI+sRzUkmoP +OlWoiyf3YjIlk2wp4EZZlmBZZUu9hGMFOlQJOjz3BosDQrfsz3H9etzUy8vVR6uE +zQ+wJ0ejYzBhMB0GA1UdDgQWBBTs0ygDtv1WZPYfST/MegeEA3v0WTAfBgNVHSME +GDAWgBTs0ygDtv1WZPYfST/MegeEA3v0WTAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjBeNcU4ZagtFZrG3PVCzpa3bHpN +f89Wa5wgGgekX7DU6A6xRwaXoCfxHRx3AaQy0gsCMQD8otQIsdl9m+0WRZm0XCca +8FBuvfsRK/uaXQww5JkV1S6Sfgix5mA+GpWjiFIl+5g= +-----END CERTIFICATE----- diff --git a/genmeta-identity/tests/fixtures/install/malformed-ski-leaf.crt b/genmeta-identity/tests/fixtures/install/malformed-ski-leaf.crt new file mode 100644 index 0000000..e5962d0 --- /dev/null +++ b/genmeta-identity/tests/fixtures/install/malformed-ski-leaf.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICHTCCAaKgAwIBAgIUc5iTarjYuHkuTLMNTvZ1L8wSdzUwCgYIKoZIzj0EAwMw +LTErMCkGA1UEAwwiZ2VubWV0YSBpZGVudGl0eSBpbnN0YWxsIHRlc3Qgcm9vdDAe +Fw0yNjA3MTMxNzA1NDVaFw0zNjA3MTAxNzA1NDVaMCAxHjAcBgNVBAMMFWFsaWNl +LnNtaXRoLmRodHRwLm5ldDB2MBAGByqGSM49AgEGBSuBBAAiA2IABHiXW9gURSwt +x7jPr5g/GH1RAGavJ5e8WWHEA3OhWm8TvWEiWo6m+hJrGh5xXrrNk8zMp/Cn7m2v +6XI0N4mXEtU3RQuG6oMLt9WkvDf2iahwvVfrY9JXfMhg5ecpn+Or2qOBjzCBjDAM +BgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAUBggrBgEFBQcD +AgYIKwYBBQUHAwEwIAYDVR0RBBkwF4IVYWxpY2Uuc21pdGguZGh0dHAubmV0MAoG +A1UdDgQDBAEAMB8GA1UdIwQYMBaAFOzTKAO2/VZk9h9JP8x6B4QDe/RZMAoGCCqG +SM49BAMDA2kAMGYCMQC9+dwoUa9BPqyvZe9jHe/kQ32nNgX3Vo+HlolRA5x6hy7C +M2hnYlNRdL/lBTH9bZ8CMQDeByP50xQy+L/OJJFMyz7npH2NxH0FTzO3IW3lNvcl +37RLKVD7Y4rhpl7VhMQHXRY= +-----END CERTIFICATE----- diff --git a/genmeta-identity/tests/fixtures/install/other.key b/genmeta-identity/tests/fixtures/install/other.key new file mode 100644 index 0000000..5f3de9c --- /dev/null +++ b/genmeta-identity/tests/fixtures/install/other.key @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDB+v/zabwXr3QYbCzno +QNU5SM6wYnTKufalsP6AupSCcyRdXfCEsMlhTVsWdI6VPgShZANiAATXc3lQfl0u +z8ZsF25kniMoPS4AzaH5IRolY2GJLBIvOVeXG54haUr/gxkgKzjq9BZP+bI/cFpw +Km/pTlE4OxXEDj3V9G9a0xqfbC0epleCD3Fzm0aQQ90wGDtL+KTOXuk= +-----END PRIVATE KEY----- diff --git a/genmeta-identity/tests/fixtures/install/root.crt b/genmeta-identity/tests/fixtures/install/root.crt new file mode 100644 index 0000000..8720f88 --- /dev/null +++ b/genmeta-identity/tests/fixtures/install/root.crt @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB/DCCAYKgAwIBAgIUECbL8M8d18tsi39M7rEXwkDh1ykwCgYIKoZIzj0EAwMw +LTErMCkGA1UEAwwiZ2VubWV0YSBpZGVudGl0eSBpbnN0YWxsIHRlc3Qgcm9vdDAe +Fw0yNjA3MTMxNzA1NDVaFw0zNjA3MTAxNzA1NDVaMC0xKzApBgNVBAMMImdlbm1l +dGEgaWRlbnRpdHkgaW5zdGFsbCB0ZXN0IHJvb3QwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATapdygUzH2icF523Wlg/jjgKaA/0BhnbRN7OKCifcIk966jzI+sRzUkmoP +OlWoiyf3YjIlk2wp4EZZlmBZZUu9hGMFOlQJOjz3BosDQrfsz3H9etzUy8vVR6uE +zQ+wJ0ejYzBhMB0GA1UdDgQWBBTs0ygDtv1WZPYfST/MegeEA3v0WTAfBgNVHSME +GDAWgBTs0ygDtv1WZPYfST/MegeEA3v0WTAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjBeNcU4ZagtFZrG3PVCzpa3bHpN +f89Wa5wgGgekX7DU6A6xRwaXoCfxHRx3AaQy0gsCMQD8otQIsdl9m+0WRZm0XCca +8FBuvfsRK/uaXQww5JkV1S6Sfgix5mA+GpWjiFIl+5g= +-----END CERTIFICATE----- From 817b4ec8b7109e2266a4dfc0c51edb3b1b7d742a Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:20:56 +0800 Subject: [PATCH 13/27] feat(identity): align registration and payment flow --- genmeta-identity/src/cert_server.rs | 39 +- genmeta-identity/src/checkout.rs | 136 ++- genmeta-identity/src/cli/flow/progress.rs | 5 +- genmeta-identity/src/cli/flow/registration.rs | 780 ++++++++++++------ 4 files changed, 636 insertions(+), 324 deletions(-) diff --git a/genmeta-identity/src/cert_server.rs b/genmeta-identity/src/cert_server.rs index 98c2150..937d5cc 100644 --- a/genmeta-identity/src/cert_server.rs +++ b/genmeta-identity/src/cert_server.rs @@ -152,6 +152,36 @@ pub struct DomainLoginResponse { pub struct DomainAvailabilityResponse { pub domain: String, pub availability: String, + pub currency: String, + pub prices: Vec, +} + +impl DomainAvailabilityResponse { + pub fn monthly_amount(&self) -> Option { + self.prices + .iter() + .find(|price| price.interval == "monthly") + .map(|price| price.amount) + } + + pub fn yearly_amount(&self) -> Option { + self.prices + .iter() + .find(|price| price.interval == "yearly") + .map(|price| price.amount) + } + + pub fn is_free(&self) -> bool { + !self.prices.is_empty() && self.prices.iter().all(|price| price.amount == 0) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PricingItem { + pub interval: String, + pub amount: i64, + #[serde(default)] + pub discount: Option, } #[derive(Debug, Clone, Deserialize)] @@ -857,12 +887,19 @@ mod tests { "domain":"alice.smith.dhttp.net", "availability":"conflict", "currency":"USD", - "prices":[] + "prices":[ + {"interval":"monthly","amount":500}, + {"interval":"yearly","amount":3000,"discount":0.5} + ] } "#; let response: DomainAvailabilityResponse = serde_json::from_str(payload).unwrap(); assert_eq!(response.domain, "alice.smith.dhttp.net"); assert_eq!(response.availability, "conflict"); + assert_eq!(response.currency, "USD"); + assert_eq!(response.monthly_amount(), Some(500)); + assert_eq!(response.yearly_amount(), Some(3000)); + assert!(!response.is_free()); } #[test] diff --git a/genmeta-identity/src/checkout.rs b/genmeta-identity/src/checkout.rs index 5809078..a622409 100644 --- a/genmeta-identity/src/checkout.rs +++ b/genmeta-identity/src/checkout.rs @@ -1,6 +1,7 @@ use std::io::IsTerminal; use qrcode::{QrCode, render::unicode, types::QrError}; +use snafu::FromString; use crate::{cert_server::CreateDomainResponse, cli::flow::transcript}; @@ -10,6 +11,7 @@ pub enum CheckoutState { Completed, Expired, Cancelled, + Failed, } pub fn classify_checkout(response: &CreateDomainResponse) -> CheckoutState { @@ -22,7 +24,8 @@ pub fn classify_checkout(response: &CreateDomainResponse) -> CheckoutState { "paid" => CheckoutState::Completed, "expired" => CheckoutState::Expired, "cancelled" | "canceled" => CheckoutState::Cancelled, - _ => CheckoutState::Pending, + "pending" | "open" | "unpaid" | "processing" => CheckoutState::Pending, + _ => CheckoutState::Failed, }; } @@ -30,43 +33,25 @@ pub fn classify_checkout(response: &CreateDomainResponse) -> CheckoutState { return match reservation.status.as_str() { "expired" => CheckoutState::Expired, "cancelled" | "canceled" => CheckoutState::Cancelled, - _ => CheckoutState::Pending, + "pending" | "reserved" => CheckoutState::Pending, + _ => CheckoutState::Failed, }; } - CheckoutState::Pending + match response.next_action.as_str() { + "payment" | "pending" => CheckoutState::Pending, + _ => CheckoutState::Failed, + } } pub fn print_payment_instructions(response: &CreateDomainResponse) { - transcript::print_err_block(&payment_instruction_block( - response, - std::io::stderr().is_terminal(), - )); -} - -fn payment_instruction_block(response: &CreateDomainResponse, include_qr: bool) -> String { - let mut lines = vec![ - format!("payment required for {}", response.domain), - format!("currency: {}", response.quotes.currency), - format!("monthly: {}", response.quotes.monthly), - format!("yearly: {}", response.quotes.yearly), - format!( - "default billing cycle: {}", - response.quotes.default_billing_cycle - ), - ]; - if let Some(reservation) = &response.reservation { - lines.push(format!("reservation: {}", reservation.reservation_no)); - lines.push(format!( - "reservation expires at: {}", - reservation.expires_at - )); - } if let Some(payment_entry) = &response.payment_entry { - lines.push(format!("checkout expires at: {}", payment_entry.expires_at)); - return checkout_instruction_block(&lines.join("\n"), &payment_entry.url, include_qr); + let include_qr = std::io::stderr().is_terminal(); + let block = payment_instruction_block(&payment_entry.url, include_qr) + .or_else(|_| payment_instruction_block(&payment_entry.url, false)) + .expect("rendering payment instructions without a QR code cannot fail"); + transcript::print_err_block(&block); } - lines.join("\n") } fn render_terminal_qr(url: &str) -> Result { @@ -78,29 +63,44 @@ fn render_terminal_qr(url: &str) -> Result { .build()) } -pub(crate) fn checkout_instruction_block(summary: &str, url: &str, include_qr: bool) -> String { - let mut block = summary.to_string(); +pub(crate) fn payment_instruction_block(url: &str, include_qr: bool) -> Result { + let mut block = String::new(); - if include_qr && let Ok(qr) = render_terminal_qr(url) { - block.push_str("\n\nScan this QR code to pay:\n"); - block.push_str(&qr); + if include_qr { + block.push_str(render_terminal_qr(url)?.trim_end()); + block.push_str("\n\n"); } - block.push_str("\n\nOpen link: "); + block.push_str("[!] Please complete your payment within 15 minutes.\n"); + block.push_str(" Open the link below, or scan the QR code above\n\n"); + block.push_str(" Link: "); block.push_str(url); - block + Ok(block) } pub async fn wait_for_checkout_completion( cert_server: &crate::cert_server::CertServer, checkout_token: &str, ) -> Result { - crate::cli::flow::progress::run_with_spinner("Waiting for payment confirmation...", async { + crate::cli::flow::progress::run(crate::cli::flow::progress::WAIT_FOR_PAYMENT, async { loop { let response = cert_server.get_checkout(checkout_token).await?; match classify_checkout(&response) { - CheckoutState::Completed | CheckoutState::Expired | CheckoutState::Cancelled => { - return Ok(response); + CheckoutState::Completed => return Ok(response), + CheckoutState::Expired => { + return Err(crate::cert_server::Error::without_source( + "checkout expired before payment was completed".to_string(), + )); + } + CheckoutState::Cancelled => { + return Err(crate::cert_server::Error::without_source( + "checkout was cancelled before payment was completed".to_string(), + )); + } + CheckoutState::Failed => { + return Err(crate::cert_server::Error::without_source( + "checkout failed or returned an unsupported terminal state".to_string(), + )); } CheckoutState::Pending => { tokio::time::sleep(std::time::Duration::from_secs(3)).await @@ -134,20 +134,6 @@ mod tests { assert_eq!(classify_checkout(&response), CheckoutState::Expired); } - #[test] - fn checkout_block_includes_terminal_qr_when_stderr_is_terminal() { - let block = checkout_instruction_block( - "Payment is required to create alice.smith.", - "https://pay.example.test/checkout/ckt_123", - true, - ); - - assert!(block.contains("Payment is required to create alice.smith.")); - assert!(block.contains("Scan this QR code to pay:")); - assert!(block.contains(['â–€', 'â–„', 'â–ˆ']), "{block:?}"); - assert!(block.contains("Open link: https://pay.example.test/checkout/ckt_123")); - } - #[test] fn terminal_qr_uses_the_compact_stable_layout() { let qr = render_terminal_qr("https://pay.example.test/checkout/ckt_123").unwrap(); @@ -164,35 +150,41 @@ mod tests { } #[test] - fn checkout_block_omits_terminal_qr_when_stderr_is_not_terminal() { - let block = checkout_instruction_block( - "Payment is required to create alice.smith.", - "https://pay.example.test/checkout/ckt_123", - false, - ); + fn payment_block_omits_only_the_qr_on_a_non_terminal_stream() { + let block = + payment_instruction_block("https://pay.example.test/checkout/ckt_123", false).unwrap(); - assert!(block.contains("Payment is required to create alice.smith.")); - assert!(!block.contains("Scan this QR code to pay:")); assert!(!block.contains(['â–€', 'â–„', 'â–ˆ'])); assert_eq!( block, - "Payment is required to create alice.smith.\n\nOpen link: https://pay.example.test/checkout/ckt_123" + "[!] Please complete your payment within 15 minutes.\n Open the link below, or scan the QR code above\n\n Link: https://pay.example.test/checkout/ckt_123" ); } #[test] - fn payment_instruction_block_adds_qr_to_payment_entry() { + fn payment_block_places_compact_qr_before_the_link() { + let rendered = + payment_instruction_block("https://pay.example.test/checkout", true).unwrap(); + let qr = rendered.find('â–ˆ').unwrap(); + let notice = rendered + .find("[!] Please complete your payment within 15 minutes.") + .unwrap(); + let link = rendered + .find("Link: https://pay.example.test/checkout") + .unwrap(); + + assert!(qr < notice && notice < link, "{rendered}"); + assert!(rendered.contains("Open the link below, or scan the QR code above")); + assert!(!rendered.contains("Open link:")); + } + + #[test] + fn unknown_invoice_state_is_a_terminal_failure() { let response: CreateDomainResponse = serde_json::from_str( - r#"{"domain":"alice.smith.dhttp.net","quotes":{"currency":"USD","monthly":9900,"yearly":99000,"default_billing_cycle":"yearly"},"next_action":"payment","payment_entry":{"url":"https://pay.example.com","checkout_token":"tok_123","expires_at":123456}}"#, + r#"{"domain":"alice.smith.dhttp.net","quotes":{"currency":"USD","monthly":9900,"yearly":99000,"default_billing_cycle":"yearly"},"next_action":"payment","invoice":{"number":"INV1","status":"surprising","amount":9900,"currency":"USD"}}"#, ) .unwrap(); - let block = payment_instruction_block(&response, true); - - assert!(block.contains("payment required for alice.smith.dhttp.net")); - assert!(block.contains("currency: USD")); - assert!(block.contains("Scan this QR code to pay:")); - assert!(block.contains("Open link: https://pay.example.com")); - assert!(!block.contains("tok_123")); + assert_eq!(classify_checkout(&response), CheckoutState::Failed); } } diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index c9e20d6..bd2f24e 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -31,6 +31,8 @@ pub(crate) const REQUEST_CERT: ProgressCopy = ProgressCopy::new( "Generating CSR and requesting certificate...", "Generated CSR and requested certificate.", ); +pub(crate) const WAIT_FOR_PAYMENT: ProgressCopy = + ProgressCopy::new("Waiting for payment completion...", "Payment completed."); pub(crate) const RENEW_IDENTITY: ProgressCopy = ProgressCopy::new("Renewing identity...", "Renewed identity."); pub(crate) const SAVE_DEFAULT: ProgressCopy = @@ -96,7 +98,7 @@ mod tests { use super::{ CHECK_NAME, GENERATE_KEY, ProgressCopy, RENEW_IDENTITY, REQUEST_CERT, SAVE_DEFAULT, - SEND_CODE, VERIFY_EMAIL, run_with_spinner, + SEND_CODE, VERIFY_EMAIL, WAIT_FOR_PAYMENT, run_with_spinner, }; #[test] @@ -118,6 +120,7 @@ mod tests { REQUEST_CERT.success, "Generated CSR and requested certificate." ); + assert_eq!(WAIT_FOR_PAYMENT.success, "Payment completed."); assert_eq!(RENEW_IDENTITY.success, "Renewed identity."); assert_eq!(SAVE_DEFAULT.success, "Saved default identity."); } diff --git a/genmeta-identity/src/cli/flow/registration.rs b/genmeta-identity/src/cli/flow/registration.rs index 23b442b..bb3ebca 100644 --- a/genmeta-identity/src/cli/flow/registration.rs +++ b/genmeta-identity/src/cli/flow/registration.rs @@ -1,12 +1,11 @@ use std::io::IsTerminal; -use snafu::{FromString, OptionExt, whatever}; +use snafu::{FromString, Snafu, whatever}; use super::target::IdentityTarget; use crate::{ cert_server::{ - CertServer, CreateDomainResponse, CreateSubdomainAttempt, CreateSubdomainResponse, - InvoiceDetail, SubdomainQuotaQuote, + CertServer, CreateDomainResponse, CreateSubdomainResponse, DomainAvailabilityResponse, }, cli::{ Error, @@ -22,6 +21,10 @@ fn is_domain_conflict(error: &crate::cert_server::Error) -> bool { error.is_api_code("domain_conflict") } +fn is_subdomain_conflict(error: &crate::cert_server::Error) -> bool { + error.is_api_code("subdomain_conflict") || is_domain_conflict(error) +} + pub(crate) fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { error.is_api_code("subdomain_quota_exceeded") } @@ -36,6 +39,290 @@ fn missing_parent_identity_message(target: &IdentityTarget, parent: &str) -> Str target.short_name() ) } + +#[derive(Debug, Clone, Copy)] +pub(crate) enum RegistrationProof<'a> { + AccessToken(&'a str), + ParentIdentity(&'a str), +} + +pub(crate) trait RegistrationApi { + async fn pricing( + &self, + target: &IdentityTarget, + ) -> Result; + + async fn create_root( + &self, + token: &str, + target: &IdentityTarget, + ) -> Result; + + async fn create_child( + &self, + proof: RegistrationProof<'_>, + target: &IdentityTarget, + ) -> Result; + + async fn checkout(&self, token: &str) -> Result; +} + +pub(crate) trait RegistrationUi { + async fn confirm_paid_root(&self) -> Result; + fn print_payment(&self, url: &str) -> Result<(), RegistrationError>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RegistrationOutcome { + Existing, + CreatedFree, + CreatedPaid, +} + +#[derive(Debug, Snafu)] +pub(crate) enum RegistrationError { + #[snafu(transparent)] + CertServer { source: crate::cert_server::Error }, + + #[snafu(transparent)] + Prompt { source: prompt::Error }, + + #[snafu(display("failed to render the payment QR code"))] + PaymentQr { source: qrcode::types::QrError }, + + #[snafu(display("identity registration was declined"))] + Declined, + + #[snafu(display( + "creating this identity requires interactive payment confirmation; rerun this command in an interactive terminal" + ))] + PaymentRequiresInteractive, + + #[snafu(display("cert server did not return monthly and yearly pricing for {target}"))] + MissingPricing { target: String }, + + #[snafu(display("{target} is not available to register"))] + TargetUnavailable { target: String }, + + #[snafu(display("cert server unexpectedly returned a checkout for a free identity"))] + UnexpectedFreeCheckout, + + #[snafu(display("checkout did not complete successfully"))] + CheckoutNotCompleted, + + #[snafu(display("cert server unexpectedly returned a child quota checkout"))] + ChildQuotaCheckout, + + #[snafu(display("{message}"))] + MissingParent { message: String }, +} + +impl From for Error { + fn from(error: RegistrationError) -> Self { + match error { + RegistrationError::CertServer { source } => Self::from(source), + RegistrationError::Prompt { source } => Self::from(source), + other => Self::without_source(other.to_string()), + } + } +} + +pub(crate) struct CertServerRegistrationApi<'a> { + cert_server: &'a CertServer, +} + +impl<'a> CertServerRegistrationApi<'a> { + pub(crate) fn new(cert_server: &'a CertServer) -> Self { + Self { cert_server } + } +} + +impl RegistrationApi for CertServerRegistrationApi<'_> { + async fn pricing( + &self, + target: &IdentityTarget, + ) -> Result { + Ok(self + .cert_server + .inspect_domain_availability(target.full_name()) + .await?) + } + + async fn create_root( + &self, + token: &str, + target: &IdentityTarget, + ) -> Result { + Ok(self + .cert_server + .create_domain_with_token(token, target.full_name()) + .await?) + } + + async fn create_child( + &self, + proof: RegistrationProof<'_>, + target: &IdentityTarget, + ) -> Result { + let parent = target + .parent() + .ok_or_else(|| RegistrationError::MissingParent { + message: "sub-identity target is missing its direct parent".to_string(), + })?; + let label = + target + .sub_identity_label() + .ok_or_else(|| RegistrationError::MissingParent { + message: "sub-identity target is missing its direct child label".to_string(), + })?; + + let response = match proof { + RegistrationProof::AccessToken(token) => { + self.cert_server + .create_subdomain(token, parent.as_full(), label, None) + .await? + } + RegistrationProof::ParentIdentity(identity) => { + self.cert_server + .create_subdomain_with_identity(identity, parent.as_full(), label, None) + .await? + } + }; + Ok(response) + } + + async fn checkout(&self, token: &str) -> Result { + Ok(crate::checkout::wait_for_checkout_completion(self.cert_server, token).await?) + } +} + +pub(crate) struct InquireRegistrationUi; + +impl RegistrationUi for InquireRegistrationUi { + async fn confirm_paid_root(&self) -> Result { + let answer = prompt::sync(|| { + inquire::Confirm::new( + "This new name is nice, it costs $5/mon or $30/yr, would you like to subscribe to own it exclusively?", + ) + .with_default(true) + .prompt() + }) + .await + .require_interactive("an interactive terminal to confirm payment")?; + Ok(answer) + } + + fn print_payment(&self, url: &str) -> Result<(), RegistrationError> { + let include_qr = std::io::stderr().is_terminal(); + let block = crate::checkout::payment_instruction_block(url, include_qr) + .map_err(|source| RegistrationError::PaymentQr { source })?; + crate::cli::flow::transcript::print_err_block(&block); + Ok(()) + } +} + +pub(crate) struct NoRegistrationUi; + +impl RegistrationUi for NoRegistrationUi { + async fn confirm_paid_root(&self) -> Result { + Err(RegistrationError::PaymentRequiresInteractive) + } + + fn print_payment(&self, _url: &str) -> Result<(), RegistrationError> { + Err(RegistrationError::PaymentRequiresInteractive) + } +} + +pub(crate) async fn register_missing_root( + api: &impl RegistrationApi, + ui: &impl RegistrationUi, + target: &IdentityTarget, + token: &str, + interactive: bool, +) -> Result { + let pricing = api.pricing(target).await?; + match pricing.availability.as_str() { + "conflict" => return Ok(RegistrationOutcome::Existing), + "available" => {} + _ => { + return Err(RegistrationError::TargetUnavailable { + target: target.short_name().to_string(), + }); + } + } + + if pricing.monthly_amount().is_none() || pricing.yearly_amount().is_none() { + return Err(RegistrationError::MissingPricing { + target: target.short_name().to_string(), + }); + } + + let paid = !pricing.is_free(); + if paid { + if !interactive { + return Err(RegistrationError::PaymentRequiresInteractive); + } + if !ui.confirm_paid_root().await? { + return Err(RegistrationError::Declined); + } + } + + let created = match api.create_root(token, target).await { + Ok(created) => created, + Err(RegistrationError::CertServer { source }) if is_domain_conflict(&source) => { + return Ok(RegistrationOutcome::Existing); + } + Err(error) => return Err(error), + }; + + let Some(payment_entry) = created.payment_entry.as_ref() else { + return Ok(RegistrationOutcome::CreatedFree); + }; + + if !paid { + return Err(RegistrationError::UnexpectedFreeCheckout); + } + + ui.print_payment(&payment_entry.url)?; + let completed = api.checkout(&payment_entry.checkout_token).await?; + if crate::checkout::classify_checkout(&completed) != crate::checkout::CheckoutState::Completed { + return Err(RegistrationError::CheckoutNotCompleted); + } + + Ok(RegistrationOutcome::CreatedPaid) +} + +pub(crate) async fn register_missing_child( + api: &impl RegistrationApi, + target: &IdentityTarget, + proof: RegistrationProof<'_>, +) -> Result { + let parent = target + .parent() + .ok_or_else(|| RegistrationError::MissingParent { + message: "sub-identity target is missing its direct parent".to_string(), + })?; + + let created = match api.create_child(proof, target).await { + Ok(created) => created, + Err(RegistrationError::CertServer { source }) if is_subdomain_conflict(&source) => { + return Ok(RegistrationOutcome::Existing); + } + Err(RegistrationError::CertServer { source }) if is_domain_not_found(&source) => { + return Err(RegistrationError::MissingParent { + message: missing_parent_identity_message(target, parent.as_partial()), + }); + } + Err(error) => return Err(error), + }; + + if created.invoice.is_some() { + return Err(RegistrationError::ChildQuotaCheckout); + } + + Ok(RegistrationOutcome::CreatedFree) +} + pub(crate) fn ensure_non_interactive_root_checkout_not_required( target: &IdentityTarget, response: &CreateDomainResponse, @@ -44,7 +331,8 @@ pub(crate) fn ensure_non_interactive_root_checkout_not_required( crate::checkout::CheckoutState::Completed => Ok(()), crate::checkout::CheckoutState::Pending | crate::checkout::CheckoutState::Expired - | crate::checkout::CheckoutState::Cancelled => whatever!( + | crate::checkout::CheckoutState::Cancelled + | crate::checkout::CheckoutState::Failed => whatever!( "creating {} requires interactive checkout; rerun this command in an interactive terminal to complete payment", target.short_name() ), @@ -64,179 +352,30 @@ pub(crate) fn ensure_non_interactive_sub_identity_checkout_not_required( Ok(()) } -pub(crate) async fn prompt_restart_checkout(message: &str) -> Result { - let message = message.to_string(); - Ok( - prompt::sync(move || inquire::Confirm::new(&message).with_default(true).prompt()) - .await - .require_interactive("interactive input")?, - ) -} -pub(crate) fn print_root_checkout_instructions( - target: &IdentityTarget, - response: &CreateDomainResponse, -) { - if let Some(block) = - root_checkout_instruction_block(target, response, std::io::stderr().is_terminal()) - { - crate::cli::flow::transcript::print_err_block(&block); - } -} - -fn root_checkout_summary(target: &IdentityTarget) -> String { - format!("Payment is required to create {}.", target.short_name()) -} - -fn root_checkout_instruction_block( - target: &IdentityTarget, - response: &CreateDomainResponse, - include_qr: bool, -) -> Option { - response.payment_entry.as_ref().map(|payment_entry| { - crate::checkout::checkout_instruction_block( - &root_checkout_summary(target), - &payment_entry.url, - include_qr, - ) - }) -} - -fn print_subdomain_checkout_instructions( - target: &IdentityTarget, - invoice: &InvoiceDetail, - quote: &SubdomainQuotaQuote, -) { - crate::cli::flow::transcript::print_err_block(&subdomain_checkout_instruction_block( - target, - invoice, - quote, - std::io::stderr().is_terminal(), - )); -} - -fn subdomain_checkout_summary(target: &IdentityTarget, quote: &SubdomainQuotaQuote) -> String { - format!( - "Creating {} exceeded the sub-identity quota.\n\nAmount due now to expand and continue: {} {}", - target.short_name(), - quote.currency, - format_minor_amount(quote.due), - ) -} - -fn subdomain_checkout_instruction_block( - target: &IdentityTarget, - invoice: &InvoiceDetail, - quote: &SubdomainQuotaQuote, - include_qr: bool, -) -> String { - crate::checkout::checkout_instruction_block( - &subdomain_checkout_summary(target, quote), - &invoice.url, - include_qr, - ) -} - -fn format_minor_amount(amount: i64) -> String { - let major = amount / 100; - let cents = amount.abs() % 100; - format!("{major}.{cents:02}") -} -async fn wait_for_invoice_terminal( - cert_server: &CertServer, - access_token: &str, - invoice_no: &str, -) -> Result { - super::progress::run_with_spinner("Waiting for payment confirmation...", async { - loop { - let invoice = cert_server.get_invoice(access_token, invoice_no).await?; - match invoice.status.as_str() { - "paid" | "expired" | "cancelled" | "canceled" => return Ok(invoice), - _ => tokio::time::sleep(std::time::Duration::from_secs(3)).await, - } - } - }) - .await -} - pub(crate) async fn ensure_identity_exists_with_token( cert_server: &CertServer, target: &IdentityTarget, access_token: &str, - progress_message: &str, + _progress_message: &str, ) -> Result<(), Error> { - let created = match super::progress::run_with_spinner( - progress_message, - cert_server.create_domain_with_token(access_token, target.full_name()), - ) - .await - { - Ok(created) => created, - Err(error) if is_domain_conflict(&error) => return Ok(()), - Err(error) => return Err(Error::from(error)), - }; - - ensure_non_interactive_root_checkout_not_required(target, &created)?; - Ok(()) + let api = CertServerRegistrationApi::new(cert_server); + register_missing_root(&api, &NoRegistrationUi, target, access_token, false) + .await + .map(|_| ()) + .map_err(Error::from) } pub(crate) async fn ensure_identity_exists_with_token_interactively( cert_server: &CertServer, target: &IdentityTarget, access_token: &str, - progress_message: &str, + _progress_message: &str, ) -> Result<(), Error> { - loop { - let created = match super::progress::run_with_spinner( - progress_message, - cert_server.create_domain_with_token(access_token, target.full_name()), - ) + let api = CertServerRegistrationApi::new(cert_server); + register_missing_root(&api, &InquireRegistrationUi, target, access_token, true) .await - { - Ok(created) => created, - Err(error) if is_domain_conflict(&error) => return Ok(()), - Err(error) => return Err(Error::from(error)), - }; - - if created.payment_entry.is_none() { - return Ok(()); - } - - print_root_checkout_instructions(target, &created); - let completed = crate::checkout::wait_for_checkout_completion( - cert_server, - &created - .payment_entry - .as_ref() - .expect("payment entry just checked") - .checkout_token, - ) - .await?; - - match crate::checkout::classify_checkout(&completed) { - crate::checkout::CheckoutState::Completed => return Ok(()), - crate::checkout::CheckoutState::Expired => { - if !prompt_restart_checkout( - "This checkout expired. Start a new checkout for this identity?", - ) - .await? - { - whatever!("checkout was not completed"); - } - } - crate::checkout::CheckoutState::Cancelled => { - if !prompt_restart_checkout( - "This checkout was cancelled. Start a new checkout for this identity?", - ) - .await? - { - whatever!("checkout was not completed"); - } - } - crate::checkout::CheckoutState::Pending => { - whatever!("checkout did not reach a terminal state"); - } - } - } + .map(|_| ()) + .map_err(Error::from) } pub(crate) async fn create_sub_identity_with_token( @@ -268,97 +407,238 @@ pub(crate) async fn create_sub_identity_with_token_interactively( parent: &dhttp::name::DhttpName<'_>, label: &str, ) -> Result { - loop { - match super::progress::run_with_spinner( - create_identity_progress_message(), - cert_server.create_subdomain_attempt(access_token, parent.as_full(), label, None), - ) - .await - { - Ok(CreateSubdomainAttempt::Created(response)) => return Ok(response), - Ok(CreateSubdomainAttempt::QuotaExceeded(quote)) => { - let continue_checkout = prompt_restart_checkout(&format!( - "Creating {} exceeded the sub-identity quota under {}. Expand quota and continue?", - target.short_name(), - parent.as_partial() - )) - .await?; - if !continue_checkout { - whatever!("checkout was not completed"); - } - - loop { - let invoice_response = match super::progress::run_with_spinner( - create_identity_progress_message(), - cert_server.create_subdomain_attempt( - access_token, - parent.as_full(), - label, - Some(quote.due), - ), - ) - .await? - { - CreateSubdomainAttempt::Created(response) => response, - CreateSubdomainAttempt::QuotaExceeded(_) => { - whatever!("subdomain quota expansion quote changed during checkout") - } - }; - let invoice_no = invoice_response - .invoice - .as_ref() - .map(|invoice| invoice.number.as_str()) - .whatever_context::<_, Error>( - "quota expansion checkout did not return an invoice number", - )?; - let invoice = super::progress::run_with_spinner( - "Loading payment details...", - cert_server.get_invoice(access_token, invoice_no), - ) - .await?; - print_subdomain_checkout_instructions(target, &invoice, "e); - let invoice = - wait_for_invoice_terminal(cert_server, access_token, invoice_no).await?; - match invoice.status.as_str() { - "paid" => break, - "expired" => { - if !prompt_restart_checkout( - "This checkout expired. Start a new checkout for this sub-identity slot?", - ) - .await? - { - whatever!("checkout was not completed"); - } - } - "cancelled" | "canceled" => { - if !prompt_restart_checkout( - "This checkout was cancelled. Start a new checkout for this sub-identity slot?", - ) - .await? - { - whatever!("checkout was not completed"); - } - } - _ => whatever!("invoice did not reach a terminal state"), - } - } + // Child quota failures are terminal. They belong to a separate quota flow and + // must never enter the root-name payment transcript. + create_sub_identity_with_token(cert_server, target, access_token, parent, label).await +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + + struct FakeRegistrationApi { + pricing: DomainAvailabilityResponse, + created: CreateDomainResponse, + calls: Mutex>, + } + + impl FakeRegistrationApi { + fn free() -> Self { + Self::new(0, 0, None) + } + + fn paid() -> Self { + Self::new(500, 3000, Some("https://pay.example.test/checkout")) + } + + fn new(monthly: i64, yearly: i64, payment_url: Option<&str>) -> Self { + let pricing = serde_json::from_value(serde_json::json!({ + "domain": "alice.smith.dhttp.net", + "availability": "available", + "currency": "USD", + "prices": [ + {"interval": "monthly", "amount": monthly}, + {"interval": "yearly", "amount": yearly, "discount": 0.5} + ] + })) + .unwrap(); + let mut created = serde_json::json!({ + "domain": "alice.smith.dhttp.net", + "quotes": { + "currency": "USD", + "monthly": monthly, + "yearly": yearly, + "default_billing_cycle": "yearly" + }, + "next_action": if payment_url.is_some() { "payment" } else { "completed" } + }); + if let Some(url) = payment_url { + created["payment_entry"] = serde_json::json!({ + "url": url, + "checkout_token": "ckt_test", + "expires_at": 1_900_000_000_i64 + }); } - Err(error) if is_domain_not_found(&error) => { - return Err(Error::with_source( - Box::new(error), - missing_parent_identity_message(target, parent.as_partial()), - )); + Self { + pricing, + created: serde_json::from_value(created).unwrap(), + calls: Mutex::new(Vec::new()), } - Err(error) => return Err(Error::from(error)), + } + + fn calls(&self) -> Vec<&'static str> { + self.calls.lock().unwrap().clone() } } -} -#[cfg(test)] -mod tests { - use super::{ - IdentityTarget, create_identity_progress_message, missing_parent_identity_message, - }; + impl RegistrationApi for FakeRegistrationApi { + async fn pricing( + &self, + _target: &IdentityTarget, + ) -> Result { + self.calls.lock().unwrap().push("pricing"); + Ok(self.pricing.clone()) + } + + async fn create_root( + &self, + _token: &str, + _target: &IdentityTarget, + ) -> Result { + self.calls.lock().unwrap().push("register-root"); + Ok(self.created.clone()) + } + + async fn create_child( + &self, + proof: RegistrationProof<'_>, + target: &IdentityTarget, + ) -> Result { + self.calls.lock().unwrap().push(match proof { + RegistrationProof::AccessToken(_) => "register-child:token", + RegistrationProof::ParentIdentity(_) => "register-child:parent", + }); + Ok(serde_json::from_value(serde_json::json!({ + "domain": target.full_name(), + "parent": target.parent().unwrap().as_full(), + "status": "active", + "cert": {"limit": 5, "used": 1}, + "url": "https://license.genmeta.net/v2/subdomain", + "certs_url": "https://license.genmeta.net/v2/cert", + "created_at": 1_800_000_000_i64 + })) + .unwrap()) + } + + async fn checkout(&self, _token: &str) -> Result { + self.calls.lock().unwrap().push("checkout"); + let mut completed = self.created.clone(); + completed.next_action = "completed".to_string(); + Ok(completed) + } + } + + struct NoUi; + + impl RegistrationUi for NoUi { + async fn confirm_paid_root(&self) -> Result { + Err(RegistrationError::PaymentRequiresInteractive) + } + + fn print_payment(&self, _url: &str) -> Result<(), RegistrationError> { + panic!("payment output must not be printed") + } + } + + struct DecliningUi; + + impl RegistrationUi for DecliningUi { + async fn confirm_paid_root(&self) -> Result { + Ok(false) + } + + fn print_payment(&self, _url: &str) -> Result<(), RegistrationError> { + panic!("declined registration must not print payment output") + } + } + + #[derive(Default)] + struct AcceptingUi { + printed: Mutex>, + } + + impl RegistrationUi for AcceptingUi { + async fn confirm_paid_root(&self) -> Result { + Ok(true) + } + + fn print_payment(&self, url: &str) -> Result<(), RegistrationError> { + self.printed.lock().unwrap().push(url.to_string()); + Ok(()) + } + } + + fn target() -> IdentityTarget { + IdentityTarget::parse("alice.smith").unwrap() + } + + #[tokio::test] + async fn free_registration_returns_created_free_without_checkout() { + let api = FakeRegistrationApi::free(); + let outcome = register_missing_root(&api, &NoUi, &target(), "token", false) + .await + .unwrap(); + + assert_eq!(outcome, RegistrationOutcome::CreatedFree); + assert_eq!(api.calls(), ["pricing", "register-root"]); + } + + #[tokio::test] + async fn paid_registration_requires_confirmation_before_create_request() { + let api = FakeRegistrationApi::paid(); + let result = register_missing_root(&api, &DecliningUi, &target(), "token", true).await; + + assert!(matches!(result, Err(RegistrationError::Declined))); + assert_eq!(api.calls(), ["pricing"]); + } + + #[tokio::test] + async fn noninteractive_paid_registration_stops_before_create_or_checkout() { + let api = FakeRegistrationApi::paid(); + let result = register_missing_root(&api, &NoUi, &target(), "token", false).await; + + assert!(matches!( + result, + Err(RegistrationError::PaymentRequiresInteractive) + )); + assert_eq!(api.calls(), ["pricing"]); + } + + #[tokio::test] + async fn confirmed_paid_registration_prints_payment_then_checks_out() { + let api = FakeRegistrationApi::paid(); + let ui = AcceptingUi::default(); + + let outcome = register_missing_root(&api, &ui, &target(), "token", true) + .await + .unwrap(); + + assert_eq!(outcome, RegistrationOutcome::CreatedPaid); + assert_eq!(api.calls(), ["pricing", "register-root", "checkout"]); + assert_eq!( + *ui.printed.lock().unwrap(), + ["https://pay.example.test/checkout"] + ); + } + + #[tokio::test] + async fn missing_child_accepts_email_or_direct_parent_proof_without_checkout() { + let child = IdentityTarget::parse("phone.alice.smith").unwrap(); + let email_api = FakeRegistrationApi::free(); + let parent_api = FakeRegistrationApi::free(); + + assert_eq!( + register_missing_child(&email_api, &child, RegistrationProof::AccessToken("token")) + .await + .unwrap(), + RegistrationOutcome::CreatedFree + ); + assert_eq!(email_api.calls(), ["register-child:token"]); + + assert_eq!( + register_missing_child( + &parent_api, + &child, + RegistrationProof::ParentIdentity("alice.smith.dhttp.net") + ) + .await + .unwrap(), + RegistrationOutcome::CreatedFree + ); + assert_eq!(parent_api.calls(), ["register-child:parent"]); + } #[test] fn root_and_sub_identity_creation_share_the_approved_progress_copy() { From 63b294033e676f28a07e69afc3f97f0e00050360 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:26:53 +0800 Subject: [PATCH 14/27] feat(identity): implement unified apply lifecycle --- genmeta-identity/src/cli/flow/apply.rs | 1133 ++++++----------- genmeta-identity/src/cli/flow/key_material.rs | 10 +- genmeta-identity/src/cli/flow/kind.rs | 5 - genmeta-identity/src/cli/flow/progress.rs | 35 +- genmeta-identity/src/cli/flow/registration.rs | 104 +- genmeta-identity/src/cli/prompt.rs | 1 - 6 files changed, 385 insertions(+), 903 deletions(-) diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 56de7f6..ba74d9e 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -1,150 +1,25 @@ use std::io::IsTerminal; use dhttp::home::{DhttpHome, HomeScope}; -use snafu::{FromString, OptionExt, whatever}; +use snafu::{FromString, whatever}; use super::{ auth_plan::CandidateEvent, kind::IdentityKind, + registration::{RegistrationError, RegistrationOutcome, RegistrationProof}, target::{ IdentityLevel, IdentityTarget, RemoteTargetState, ReplacementRequirement, ResolvedApplyTarget, remote_state_from_availability, replacement_requirement, }, }; use crate::{ - cert_server::CertServer, + cert_server::{CertServer, CertificateDetail}, cli::{self, Apply, Error, prompt::InquireResultExt}, }; -#[derive(Debug, Clone, PartialEq, Eq)] -enum ApplyApprovalPlan { - Email, - DirectIdentity { auth_domain: String }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ApplyRunOutcome { - Applied, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ApplyPostSavePolicy { - ManageDefaultSuggestion, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MissingTargetAction { - Register, - Reject, -} - -fn missing_target_action( - target: &IdentityTarget, - private_test_continuation: bool, -) -> MissingTargetAction { - match target.level() { - IdentityLevel::SubIdentity => MissingTargetAction::Register, - IdentityLevel::Identity if private_test_continuation => MissingTargetAction::Register, - IdentityLevel::Identity => MissingTargetAction::Reject, - } -} - -fn private_test_root_registration(command: &Apply) -> bool { - command.verify_code.is_some() -} - -fn missing_root_target_error(target: &IdentityTarget) -> Error { - debug_assert_eq!(target.level(), IdentityLevel::Identity); - Error::without_source(format!( - "{} does not exist yet. Apply can register a missing sub-identity, but not a new root identity.", - target.short_name() - )) -} - -#[derive(Debug, Clone)] -struct InteractiveApplyState { - target: Option>, - kind: Option, - kind_prompt_required: bool, - approval_plan: Option, -} - -impl InteractiveApplyState { - fn from_command( - command: &Apply, - target: Option>, - ) -> Result { - Ok(Self { - target, - kind: command - .kind - .as_deref() - .map(str::parse::) - .transpose()?, - kind_prompt_required: command.kind.is_none(), - approval_plan: None, - }) - } - - fn fall_back_to_email(&mut self) { - self.approval_plan = Some(ApplyApprovalPlan::Email); - } -} - -fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { - matches!( - crate::auth::classify_identity_attempt(error), - crate::auth::AuthAttemptDisposition::ReplanMissingTarget - ) -} - -fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { - super::registration::is_subdomain_quota_exceeded(error) -} - -fn preserve_apply_registration_error(error: Error) -> Error { - error -} - -fn approval_plan_from_candidate(candidate: CandidateEvent) -> Result { - match candidate { - CandidateEvent::Identity { full_name, .. } => Ok(ApplyApprovalPlan::DirectIdentity { - auth_domain: full_name, - }), - CandidateEvent::Email => Ok(ApplyApprovalPlan::Email), - CandidateEvent::Warning(_) => { - unreachable!("first_auth_candidate consumes warnings") - } - CandidateEvent::Exhausted => { - whatever!("no authentication candidate is available") - } - } -} - -async fn validate_and_save_apply( - dhttp_home: &DhttpHome, - domain: dhttp::name::DhttpName<'_>, - kind: IdentityKind, - key_pem: &str, - detail: &crate::cert_server::CertificateDetail, -) -> Result<(), Error> { - super::install::validate_and_save( - dhttp_home, - detail, - &super::install::InstallExpectation { - target: domain, - kind: kind.into(), - sequence: None, - }, - key_pem, - ) - .await?; - Ok(()) -} - -fn apply_identity_name_opening() -> &'static str { - "Applying identity, generating ECC key pair locally, then requesting and deploying certificate." -} +const APPLY_OPENING: &str = "Applying identity, generating ECC key pair locally, then requesting and deploying certificate."; +const INSTALLED: &str = "Identity successfully installed on this device."; +const NEW_NAME_FREE: &str = "This new name is yours now."; fn interactive_name_unavailable_message() -> &'static str { "Sorry, this name is not available. Please try another one." @@ -277,500 +152,328 @@ async fn resolve_kind(command: &Apply) -> Result { } } -async fn run_post_save_epilogue( - post_save: ApplyPostSavePolicy, - dhttp_home: &DhttpHome, - domain: dhttp::name::DhttpName<'_>, - default_identity_when_command_started: Option>, - interactive: bool, - welcome: Option<&super::welcome::WelcomeServiceCreated>, -) -> Result<(), Error> { - let ApplyPostSavePolicy::ManageDefaultSuggestion = post_save; - crate::cli::flow::epilogue::run_lifecycle_epilogue( - dhttp_home, - domain, - default_identity_when_command_started, - interactive, - super::output::SavedIdentityAction::Applied, - welcome, - ) - .await +#[derive(Debug, Clone, Copy)] +enum CertificateProof<'a> { + AccessToken(&'a str), + Identity(&'a str), } -fn new_identity_confirmation_message() -> &'static str { - "This new name is yours now." +#[derive(Debug)] +enum RequestFailure { + Local(Error), + Remote(crate::cert_server::Error), } -async fn ensure_identity_exists_after_apply_login( +async fn request_certificate( + cert_server: &CertServer, + proof: CertificateProof<'_>, target: &IdentityTarget, + kind: IdentityKind, + device_name: &str, + key_material: &mut super::key_material::LazyKeyMaterial, +) -> Result { + key_material.ensure_key().map_err(RequestFailure::Local)?; + super::progress::run(super::progress::REQUEST_CERT, async { + let csr_pem = key_material + .csr_pem() + .map_err(RequestFailure::Local)? + .to_string(); + let result = match proof { + CertificateProof::AccessToken(token) => { + cert_server + .issue_cert( + token, + target.full_name(), + kind.as_str(), + None, + device_name, + &csr_pem, + ) + .await + } + CertificateProof::Identity(identity) => { + cert_server + .issue_cert_with_identity( + identity, + target.full_name(), + kind.as_str(), + None, + device_name, + &csr_pem, + ) + .await + } + }; + result.map_err(RequestFailure::Remote) + }) + .await +} + +fn print_new_name(outcome: RegistrationOutcome) { + if outcome == RegistrationOutcome::CreatedFree { + super::transcript::print_line(NEW_NAME_FREE); + } +} + +async fn register_with_email( cert_server: &CertServer, - access_token: &str, + target: &IdentityTarget, + token: &str, interactive: bool, -) -> Result<(), Error> { +) -> Result { + let api = super::registration::CertServerRegistrationApi::new(cert_server); match target.level() { + IdentityLevel::Identity if interactive => { + super::registration::register_missing_root( + &api, + &super::registration::InquireRegistrationUi, + target, + token, + true, + ) + .await + } IdentityLevel::Identity => { - if interactive { - super::registration::ensure_identity_exists_with_token_interactively( - cert_server, - target, - access_token, - super::registration::create_identity_progress_message(), - ) - .await - } else { - super::registration::ensure_identity_exists_with_token( - cert_server, - target, - access_token, - super::registration::create_identity_progress_message(), - ) - .await - } + super::registration::register_missing_root( + &api, + &super::registration::NoRegistrationUi, + target, + token, + false, + ) + .await } IdentityLevel::SubIdentity => { - let parent = target.parent().whatever_context::<_, Error>( - "sub-identity target is missing its parent identity", - )?; - let label = target.sub_identity_label().whatever_context::<_, Error>( - "sub-identity target is missing its direct child label", - )?; - let created = if interactive { - super::registration::create_sub_identity_with_token_interactively( - cert_server, - target, - access_token, - &parent, - label, - ) - .await? - } else { - let created = super::registration::create_sub_identity_with_token( - cert_server, - target, - access_token, - &parent, - label, - ) - .await?; - super::registration::ensure_non_interactive_sub_identity_checkout_not_required( - target, &created, - )?; - created - }; - let _ = created; - Ok(()) + super::registration::register_missing_child( + &api, + target, + RegistrationProof::AccessToken(token), + ) + .await } } } -async fn ensure_sub_identity_exists_with_identity( - target: &IdentityTarget, +async fn register_with_parent( cert_server: &CertServer, - identity_domain: &str, -) -> Result<(), Error> { - let parent = target - .parent() - .whatever_context::<_, Error>("sub-identity target is missing its parent identity")?; - let label = target - .sub_identity_label() - .whatever_context::<_, Error>("sub-identity target is missing its direct child label")?; - - match super::progress::run_with_spinner( - super::registration::create_identity_progress_message(), - cert_server.create_subdomain_with_identity(identity_domain, parent.as_full(), label, None), + target: &IdentityTarget, + parent_identity: &str, +) -> Result { + if target.level() != IdentityLevel::SubIdentity { + return Err(RegistrationError::MissingParent { + message: "a missing root identity cannot be registered by an identity proof" + .to_string(), + }); + } + let api = super::registration::CertServerRegistrationApi::new(cert_server); + super::registration::register_missing_child( + &api, + target, + RegistrationProof::ParentIdentity(parent_identity), ) .await - { - Ok(_) => Ok(()), - Err(crate::cert_server::Error::Api { code, .. }) if code == "subdomain_conflict" => Ok(()), - Err(error) => Err(Error::from(error)), +} + +fn registration_auth_rejection(error: &RegistrationError) -> Option<&crate::cert_server::Error> { + match error { + RegistrationError::CertServer { source } + if crate::auth::classify_identity_attempt(source) + == crate::auth::AuthAttemptDisposition::TryNext => + { + Some(source) + } + _ => None, } } -async fn run_interactive_with_policy( +fn print_auth_rejection(name: &str, error: &crate::cert_server::Error) { + super::transcript::print_warning(&format!( + "Cannot authenticate with {name}: {error}; trying the next authentication method" + )); +} + +#[derive(Debug)] +enum ApplyAttempt { + Certificate(CertificateDetail), + ReplanMissing, +} + +async fn attempt_apply_with_candidates( command: &Apply, dhttp_home: &DhttpHome, - home_scope: HomeScope, cert_server: &CertServer, - post_save: ApplyPostSavePolicy, -) -> Result { - let default_identity_when_command_started = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let resolved = resolve_apply_target(command, dhttp_home, cert_server, true).await?; - let local_identity_save = authorize_local_replacement(&resolved, command.force, true).await?; - let remote_target_state = resolved.remote; - let mut state = InteractiveApplyState::from_command( - command, - Some(resolved.target.clone().into_dhttp_name()), - )?; + resolved: &mut ResolvedApplyTarget, + kind: IdentityKind, + device_name: &str, + interactive: bool, + key_material: &mut super::key_material::LazyKeyMaterial, +) -> Result { + let specs = super::auth_plan::candidate_specs(&resolved.target, resolved.remote); + let loader = super::auth_plan::HomeExactIdentityLoader::new(dhttp_home); + let mut candidates = super::auth_plan::AuthCandidateRunner::new(loader, specs); + let mut last_rejection = None; loop { - if state.kind.is_none() || state.kind_prompt_required { - state.kind = Some( - crate::cli::prompt::prompt_kind_with_cursor(state.kind) - .await - .require_interactive("--kind")?, - ); - state.kind_prompt_required = false; - continue; - } - - let domain = state - .target - .clone() - .whatever_context::<_, Error>("interactive apply target is unavailable")?; - let target = IdentityTarget::parse(domain.as_partial())?; - if state.approval_plan.is_none() { - let candidate = - super::auth_plan::first_auth_candidate(dhttp_home, &target, remote_target_state) - .await?; - state.approval_plan = Some(approval_plan_from_candidate(candidate)?); - continue; - } - - let approval_plan = state - .approval_plan - .clone() - .whatever_context::<_, Error>("interactive apply approval plan is unavailable")?; + match candidates.next().await? { + CandidateEvent::Warning(warning) => { + super::transcript::print_warning(&warning); + } + CandidateEvent::Identity { + short_name, + full_name, + } => { + if resolved.remote == RemoteTargetState::Missing { + match register_with_parent(cert_server, &resolved.target, &full_name).await { + Ok(outcome) => { + print_new_name(outcome); + resolved.remote = RemoteTargetState::Exists; + } + Err(error) if registration_auth_rejection(&error).is_some() => { + let source = registration_auth_rejection(&error) + .expect("guard confirmed an authentication rejection"); + print_auth_rejection(&short_name, source); + last_rejection = Some(error); + continue; + } + Err(error) => return Err(error.into()), + } + } - let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; - let kind = state - .kind - .whatever_context::<_, Error>("interactive apply kind is unavailable")?; - let device_name = - super::device::resolve_device_name(command.device_name.as_deref(), home_scope); - let detail = match approval_plan { - ApplyApprovalPlan::Email => { - let token = super::email::run_cert_server_email_session( + match request_certificate( cert_server, - super::email::EmailLogin::Account, - command.email.as_deref(), - command.verify_code.as_deref(), - true, - ) - .await?; - match super::progress::run( - super::progress::REQUEST_CERT, - cert_server.issue_cert( - &token, - domain.as_full(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), + CertificateProof::Identity(&full_name), + &resolved.target, + kind, + device_name, + key_material, ) .await { - Ok(detail) => detail, - Err(error) if is_domain_not_found(&error) => { - if missing_target_action(&target, private_test_root_registration(command)) - == MissingTargetAction::Reject - { - return Err(missing_root_target_error(&target)); - } - if let Err(error) = ensure_identity_exists_after_apply_login( - &target, - cert_server, - &token, - true, - ) - .await - { - return Err(preserve_apply_registration_error(error)); + Ok(detail) => return Ok(ApplyAttempt::Certificate(detail)), + Err(RequestFailure::Local(error)) => return Err(error), + Err(RequestFailure::Remote(error)) => { + match crate::auth::classify_identity_attempt(&error) { + crate::auth::AuthAttemptDisposition::TryNext => { + print_auth_rejection(&short_name, &error); + last_rejection = + Some(RegistrationError::CertServer { source: error }); + } + crate::auth::AuthAttemptDisposition::ReplanMissingTarget => { + return Ok(ApplyAttempt::ReplanMissing); + } + crate::auth::AuthAttemptDisposition::Terminal => { + return Err(error.into()); + } } - crate::cli::flow::transcript::print_line( - new_identity_confirmation_message(), - ); - let detail = super::progress::run( - super::progress::REQUEST_CERT, - cert_server.issue_cert( - &token, - domain.as_full(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await?; - validate_and_save_apply( - dhttp_home, - domain.borrow(), - kind, - &key_pem, - &detail, - ) - .await?; - let welcome = super::welcome::maybe_create_welcome_service( - dhttp_home, - domain.borrow(), - local_identity_save.created_new_identity(), - ) - .await?; - run_post_save_epilogue( - post_save, - dhttp_home, - domain.borrow(), - default_identity_when_command_started.clone(), - std::io::stdin().is_terminal(), - welcome.as_ref(), - ) - .await?; - return Ok(ApplyRunOutcome::Applied); } - Err(error) => return Err(Error::from(error)), } } - ApplyApprovalPlan::DirectIdentity { auth_domain } => { - match super::progress::run( - super::progress::REQUEST_CERT, - cert_server.issue_cert_with_identity( - &auth_domain, - domain.as_full(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), + CandidateEvent::Email => { + let token = super::email::run_cert_server_email_session( + cert_server, + super::email::EmailLogin::Account, + command.email.as_deref(), + command.verify_code.as_deref(), + interactive, ) - .await - { + .await?; + + if resolved.remote == RemoteTargetState::Missing { + let outcome = + register_with_email(cert_server, &resolved.target, &token, interactive) + .await?; + print_new_name(outcome); + resolved.remote = RemoteTargetState::Exists; + } + + let first = request_certificate( + cert_server, + CertificateProof::AccessToken(&token), + &resolved.target, + kind, + device_name, + key_material, + ) + .await; + let detail = match first { Ok(detail) => detail, - Err(error) if is_domain_not_found(&error) => { - if missing_target_action(&target, private_test_root_registration(command)) - == MissingTargetAction::Reject - { - return Err(missing_root_target_error(&target)); - } - if target.level() != IdentityLevel::SubIdentity { - crate::cli::flow::transcript::print_block(&format!( - "Registering {} requires email verification.\nFalling back to email verification.", - target.short_name() - )); - state.fall_back_to_email(); - continue; - } - match ensure_sub_identity_exists_with_identity( - &target, + Err(RequestFailure::Local(error)) => return Err(error), + Err(RequestFailure::Remote(error)) + if crate::auth::classify_identity_attempt(&error) + == crate::auth::AuthAttemptDisposition::ReplanMissingTarget => + { + let outcome = + register_with_email(cert_server, &resolved.target, &token, interactive) + .await?; + print_new_name(outcome); + resolved.remote = RemoteTargetState::Exists; + match request_certificate( cert_server, - &auth_domain, + CertificateProof::AccessToken(&token), + &resolved.target, + kind, + device_name, + key_material, ) .await { - Ok(()) => {} - Err(Error::CertServer { source }) - if is_subdomain_quota_exceeded(&source) => - { - crate::cli::flow::transcript::print_block(&format!( - "Creating {} exceeded the sub-identity quota under {}.\nFalling back to email verification.", - target.short_name(), - target - .parent() - .map(|parent| parent.as_partial().to_string()) - .unwrap_or_else(|| "".to_string()), - )); - state.fall_back_to_email(); - continue; - } - Err(error) => return Err(error), + Ok(detail) => detail, + Err(RequestFailure::Local(error)) => return Err(error), + Err(RequestFailure::Remote(error)) => return Err(error.into()), } - crate::cli::flow::transcript::print_line( - new_identity_confirmation_message(), - ); - super::progress::run( - super::progress::REQUEST_CERT, - cert_server.issue_cert_with_identity( - &auth_domain, - domain.as_full(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await? } - Err(error) => return Err(Error::from(error)), + Err(RequestFailure::Remote(error)) => return Err(error.into()), + }; + return Ok(ApplyAttempt::Certificate(detail)); + } + CandidateEvent::Exhausted => { + if let Some(error) = last_rejection { + return Err(error.into()); } + whatever!("no authentication candidate is available"); } - }; - - validate_and_save_apply(dhttp_home, domain.borrow(), kind, &key_pem, &detail).await?; - let welcome = super::welcome::maybe_create_welcome_service( - dhttp_home, - domain.borrow(), - local_identity_save.created_new_identity(), - ) - .await?; - run_post_save_epilogue( - post_save, - dhttp_home, - domain.borrow(), - default_identity_when_command_started.clone(), - std::io::stdin().is_terminal(), - welcome.as_ref(), - ) - .await?; - return Ok(ApplyRunOutcome::Applied); + } } } -pub(crate) async fn run_with_policy( - command: &Apply, +async fn install_and_finish( dhttp_home: &DhttpHome, - home_scope: HomeScope, - cert_server: &CertServer, - post_save: ApplyPostSavePolicy, + resolved: &ResolvedApplyTarget, + kind: IdentityKind, + local_identity_save: cli::LocalIdentitySave, + default_identity_when_command_started: Option>, + interactive: bool, + key_material: &super::key_material::LazyKeyMaterial, + detail: &CertificateDetail, ) -> Result<(), Error> { - crate::cli::flow::transcript::print_block(apply_identity_name_opening()); - let is_interactive = std::io::stdin().is_terminal(); - if is_interactive { - return match run_interactive_with_policy( - command, - dhttp_home, - home_scope, - cert_server, - post_save, - ) - .await? - { - ApplyRunOutcome::Applied => Ok(()), - }; - } - let default_identity_when_command_started = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let resolved = resolve_apply_target(command, dhttp_home, cert_server, false).await?; - let local_identity_save = authorize_local_replacement(&resolved, command.force, false).await?; - let remote_target_state = resolved.remote; - let target = resolved.target; - let domain = target.dhttp_name().into_owned(); - let kind = resolve_kind(command).await?; - let device_name = - super::device::resolve_device_name(command.device_name.as_deref(), home_scope); - let approval_plan = approval_plan_from_candidate( - super::auth_plan::first_auth_candidate(dhttp_home, &target, remote_target_state).await?, - )?; - - let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; - let detail = match approval_plan { - ApplyApprovalPlan::Email => { - let token = super::email::run_cert_server_email_session( - cert_server, - super::email::EmailLogin::Account, - command.email.as_deref(), - command.verify_code.as_deref(), - false, - ) - .await?; - match super::progress::run( - super::progress::REQUEST_CERT, - cert_server.issue_cert( - &token, - domain.as_full(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await - { - Ok(detail) => detail, - Err(error) if is_domain_not_found(&error) => { - if missing_target_action(&target, private_test_root_registration(command)) - == MissingTargetAction::Reject - { - return Err(missing_root_target_error(&target)); - } - ensure_identity_exists_after_apply_login( - &target, - cert_server, - &token, - is_interactive, - ) - .await - .map_err(preserve_apply_registration_error)?; - crate::cli::flow::transcript::print_line(new_identity_confirmation_message()); - super::progress::run( - super::progress::REQUEST_CERT, - cert_server.issue_cert( - &token, - domain.as_full(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await? - } - Err(error) => return Err(Error::from(error)), - } - } - ApplyApprovalPlan::DirectIdentity { auth_domain } => { - match super::progress::run( - super::progress::REQUEST_CERT, - cert_server.issue_cert_with_identity( - &auth_domain, - domain.as_full(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await - { - Ok(detail) => detail, - Err(error) if is_domain_not_found(&error) => { - if missing_target_action(&target, private_test_root_registration(command)) - == MissingTargetAction::Reject - { - return Err(missing_root_target_error(&target)); - } - if target.level() != IdentityLevel::SubIdentity { - whatever!( - "registering {} requires interactive email verification", - target.short_name() - ); - } - ensure_sub_identity_exists_with_identity(&target, cert_server, &auth_domain) - .await?; - crate::cli::flow::transcript::print_line(new_identity_confirmation_message()); - super::progress::run( - super::progress::REQUEST_CERT, - cert_server.issue_cert_with_identity( - &auth_domain, - domain.as_full(), - kind.as_str(), - None, - &device_name, - &csr_pem, - ), - ) - .await? - } - Err(error) => return Err(Error::from(error)), - } - } - }; + let key_pem = key_material + .key_pem() + .expect("a certificate response requires the request key"); + super::install::validate_and_save( + dhttp_home, + detail, + &super::install::InstallExpectation { + target: resolved.target.dhttp_name(), + kind: kind.into(), + sequence: None, + }, + key_pem, + ) + .await?; + super::transcript::print_line(INSTALLED); - validate_and_save_apply(dhttp_home, domain.borrow(), kind, &key_pem, &detail).await?; let welcome = super::welcome::maybe_create_welcome_service( dhttp_home, - domain.borrow(), + resolved.target.dhttp_name(), local_identity_save.created_new_identity(), ) .await?; - run_post_save_epilogue( - post_save, + super::epilogue::run_lifecycle_epilogue( dhttp_home, - domain.borrow(), + resolved.target.dhttp_name(), default_identity_when_command_started, - is_interactive, + interactive, + super::output::SavedIdentityAction::Applied, welcome.as_ref(), ) .await @@ -782,35 +485,63 @@ pub(crate) async fn run( home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - run_with_policy( - command, - dhttp_home, - home_scope, - cert_server, - ApplyPostSavePolicy::ManageDefaultSuggestion, - ) - .await + super::transcript::print_line(APPLY_OPENING); + let interactive = std::io::stdin().is_terminal(); + let default_identity_when_command_started = cli::load_current_settings(dhttp_home) + .await? + .and_then(|config| config.settings().default_identity_name().cloned()); + let mut resolved = resolve_apply_target(command, dhttp_home, cert_server, interactive).await?; + let local_identity_save = + authorize_local_replacement(&resolved, command.force, interactive).await?; + let kind = resolve_kind(command).await?; + let device_name = + super::device::resolve_device_name(command.device_name.as_deref(), home_scope); + let mut key_material = + super::key_material::LazyKeyMaterial::for_name(resolved.target.dhttp_name()); + + loop { + match attempt_apply_with_candidates( + command, + dhttp_home, + cert_server, + &mut resolved, + kind, + &device_name, + interactive, + &mut key_material, + ) + .await? + { + ApplyAttempt::Certificate(detail) => { + return install_and_finish( + dhttp_home, + &resolved, + kind, + local_identity_save, + default_identity_when_command_started, + interactive, + &key_material, + &detail, + ) + .await; + } + ApplyAttempt::ReplanMissing => { + resolved.remote = RemoteTargetState::Missing; + } + } + } } #[cfg(test)] mod tests { - use super::{ - ApplyApprovalPlan, CandidateEvent, MissingTargetAction, apply_identity_name_opening, - approval_plan_from_candidate, authorize_local_replacement, explicit_target_from_command, - interactive_name_unavailable_message, missing_root_target_error, missing_target_action, - new_identity_confirmation_message, preserve_apply_registration_error, resolve_apply_target, - }; - use crate::cli::{ - Apply, LocalIdentitySave, - flow::{ - local::{IdentityUsage, LocalIdentityStatus, LocalIdentitySummary}, - target::{IdentityTarget, RemoteTargetState, ResolvedApplyTarget}, - }, - }; + use std::path::PathBuf; - fn command(name: &str) -> Apply { + use super::*; + use crate::cli::flow::local::{IdentityUsage, LocalIdentityStatus, LocalIdentitySummary}; + + fn command(name: Option<&str>) -> Apply { Apply { - name: Some(name.to_string()), + name: name.map(ToOwned::to_owned), kind: Some("primary".to_string()), force: false, device_name: None, @@ -830,206 +561,84 @@ mod tests { valid_from: Some(1_700_000_000), expires_at: Some(1_900_000_000), status, - dir: std::path::PathBuf::from("/tmp/alice.smith"), + dir: PathBuf::from("/tmp/alice.smith"), is_default: false, }), } } - #[tokio::test] - async fn replacement_authorization_prevents_noninteractive_side_effects_without_force() { - let error = authorize_local_replacement( - &resolved_with(Some(LocalIdentityStatus::Ready { - expires_at: 1_900_000_000, - })), - false, - false, - ) - .await - .unwrap_err(); - assert!(error.to_string().contains("--force"), "{error}"); - - assert_eq!( - authorize_local_replacement( - &resolved_with(Some(LocalIdentityStatus::Invalid { - detail: "certificate is unreadable".to_string(), - })), - false, - false, - ) - .await - .unwrap(), - LocalIdentitySave::Replace - ); - assert_eq!( - authorize_local_replacement(&resolved_with(None), false, false) - .await - .unwrap(), - LocalIdentitySave::New - ); - } - - #[tokio::test] - async fn explicit_target_skips_advisory_remote_inspection() { - _ = rustls::crypto::ring::default_provider().install_default(); - let home = dhttp::home::DhttpHome::new(std::env::temp_dir().join(format!( - "genmeta-identity-explicit-target-{}", - std::process::id() - ))); - let server = crate::cert_server::CertServer::new("http://127.0.0.1:1").unwrap(); - - let resolved = resolve_apply_target(&command("alice.smith"), &home, &server, false) - .await - .unwrap(); - - assert_eq!(resolved.remote, RemoteTargetState::Unknown); - assert!(resolved.local.is_none()); - } - #[test] - fn explicit_target_from_command_returns_none_without_name() { - let target = explicit_target_from_command(&Apply { - name: None, - kind: None, - force: false, - device_name: None, - email: None, - verify_code: None, - }) - .unwrap(); - - assert!(target.is_none()); - } - - #[test] - fn root_apply_without_local_auth_defaults_to_email_non_interactively() { + fn apply_copy_matches_the_approved_transcript() { assert_eq!( - approval_plan_from_candidate(CandidateEvent::Email).unwrap(), - ApplyApprovalPlan::Email, - ); - } - - #[test] - fn root_apply_prefers_ready_local_auth_non_interactively() { - assert_eq!( - approval_plan_from_candidate(CandidateEvent::Identity { - short_name: "alice.smith".to_string(), - full_name: "alice.smith.dhttp.net".to_string(), - }) - .unwrap(), - ApplyApprovalPlan::DirectIdentity { - auth_domain: "alice.smith.dhttp.net".to_string(), - }, - ); - } - - #[test] - fn sub_identity_apply_automatically_uses_ready_parent() { - assert_eq!( - approval_plan_from_candidate(CandidateEvent::Identity { - short_name: "alice.smith".to_string(), - full_name: "alice.smith.dhttp.net".to_string(), - }) - .unwrap(), - ApplyApprovalPlan::DirectIdentity { - auth_domain: "alice.smith.dhttp.net".to_string(), - }, - ); - } - - #[test] - fn apply_identity_name_opening_matches_spec_copy() { - assert_eq!( - apply_identity_name_opening(), + APPLY_OPENING, "Applying identity, generating ECC key pair locally, then requesting and deploying certificate." ); + assert_eq!(NEW_NAME_FREE, "This new name is yours now."); + assert_eq!(INSTALLED, "Identity successfully installed on this device."); } #[test] - fn interactive_name_check_copy_matches_spec() { + fn explicit_target_is_parsed_without_remote_preclassification() { assert_eq!( - interactive_name_unavailable_message(), - "Sorry, this name is not available. Please try another one." + explicit_target_from_command(&command(Some("alice.smith"))) + .unwrap() + .unwrap() + .short_name(), + "alice.smith" ); - } - - #[test] - fn newly_registered_identity_uses_the_approved_confirmation() { - assert_eq!( - new_identity_confirmation_message(), - "This new name is yours now." + assert!( + explicit_target_from_command(&command(None)) + .unwrap() + .is_none() ); } - #[test] - fn missing_sub_identity_registration_is_implicit() { - let target = IdentityTarget::parse("phone.alice.smith").unwrap(); - assert_eq!( - missing_target_action(&target, false), - MissingTargetAction::Register + #[tokio::test] + async fn replacement_authorization_prevents_noninteractive_side_effects_without_force() { + let ready = resolved_with(Some(LocalIdentityStatus::Ready { + expires_at: 1_900_000_000, + })); + assert!( + authorize_local_replacement(&ready, false, false) + .await + .is_err() ); - } - - #[test] - fn root_registration_requires_the_private_test_continuation() { - let target = IdentityTarget::parse("alice.smith").unwrap(); - assert_eq!( - missing_target_action(&target, false), - MissingTargetAction::Reject + authorize_local_replacement(&ready, true, false) + .await + .unwrap(), + cli::LocalIdentitySave::Replace ); + + let invalid = resolved_with(Some(LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".to_string(), + })); assert_eq!( - missing_target_action(&target, true), - MissingTargetAction::Register + authorize_local_replacement(&invalid, false, false) + .await + .unwrap(), + cli::LocalIdentitySave::Replace ); } #[test] - fn missing_root_error_does_not_advertise_private_root_registration() { - let target = IdentityTarget::parse("alice.smith").unwrap(); - + fn missing_targets_are_replanned_to_the_safe_candidate_order() { + let root = IdentityTarget::parse("alice.smith").unwrap(); + let child = IdentityTarget::parse("phone.alice.smith").unwrap(); assert_eq!( - missing_root_target_error(&target).to_string(), - "alice.smith does not exist yet. Apply can register a missing sub-identity, but not a new root identity." + super::super::auth_plan::candidate_specs(&root, RemoteTargetState::Missing), + vec![super::super::auth_plan::AuthCandidateSpec::Email] ); - } - - #[test] - fn starter_domain_limit_registration_error_keeps_the_certserver_problem_message() { - let error = preserve_apply_registration_error(crate::cli::Error::CertServer { - source: crate::cert_server::Error::Api { - status: reqwest::StatusCode::CONFLICT, - code: "starter_domain_limit_reached".to_string(), - message: "starter plan is limited to 3 free domains per account".to_string(), - }, - }); - let rendered = error.to_string(); assert_eq!( - rendered, - "starter plan is limited to 3 free domains per account" + super::super::auth_plan::candidate_specs(&child, RemoteTargetState::Missing), + vec![ + super::super::auth_plan::AuthCandidateSpec::Identity( + dhttp::name::DhttpName::try_from("alice.smith") + .unwrap() + .into_owned() + ), + super::super::auth_plan::AuthCandidateSpec::Email, + ] ); - assert!(matches!( - error, - crate::cli::Error::CertServer { source } - if source.is_api_code("starter_domain_limit_reached") - )); - } - - #[test] - fn subdomain_quota_helper_matches_api_code_only() { - assert!(super::is_subdomain_quota_exceeded( - &crate::cert_server::Error::Api { - status: reqwest::StatusCode::UNPROCESSABLE_ENTITY, - code: "subdomain_quota_exceeded".to_string(), - message: "subdomain quota exceeded".to_string(), - } - )); - assert!(!super::is_subdomain_quota_exceeded( - &crate::cert_server::Error::Api { - status: reqwest::StatusCode::UNPROCESSABLE_ENTITY, - code: "domain_not_found".to_string(), - message: "domain not found".to_string(), - } - )); } } diff --git a/genmeta-identity/src/cli/flow/key_material.rs b/genmeta-identity/src/cli/flow/key_material.rs index f477f5f..9847a08 100644 --- a/genmeta-identity/src/cli/flow/key_material.rs +++ b/genmeta-identity/src/cli/flow/key_material.rs @@ -50,13 +50,21 @@ where } } - pub(crate) fn csr_pem(&mut self) -> Result<&str, Error> { + pub(crate) fn ensure_key(&mut self) -> Result<&str, Error> { if self.key_pem.is_none() { self.key_pem = Some(super::progress::run_sync( super::progress::GENERATE_KEY, || self.generator.generate_key(), )?); } + Ok(self + .key_pem + .as_deref() + .expect("key material was generated above")) + } + + pub(crate) fn csr_pem(&mut self) -> Result<&str, Error> { + self.ensure_key()?; if self.csr_pem.is_none() { let csr = self.generator.generate_csr( self.key_pem diff --git a/genmeta-identity/src/cli/flow/kind.rs b/genmeta-identity/src/cli/flow/kind.rs index 9140d78..8c66cae 100644 --- a/genmeta-identity/src/cli/flow/kind.rs +++ b/genmeta-identity/src/cli/flow/kind.rs @@ -11,7 +11,6 @@ pub(crate) enum IdentityKind { impl IdentityKind { pub(crate) const SELECT_PROMPT: &str = "Select usage for this name:"; - pub(crate) const USAGE_HELP: &str = "both client and server\n For a main host, server, desktop, home gateway, or always-on endpoint.\nclient only\n For an additional device, such as a phone, laptop, or temporary endpoint."; pub(crate) fn as_str(self) -> &'static str { match self { @@ -91,9 +90,5 @@ mod tests { "both client and server" ); assert_eq!(IdentityKind::Secondary.usage_label(), "client only"); - assert_eq!( - IdentityKind::USAGE_HELP, - "both client and server\n For a main host, server, desktop, home gateway, or always-on endpoint.\nclient only\n For an additional device, such as a phone, laptop, or temporary endpoint." - ); } } diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index bd2f24e..57f679f 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -71,20 +71,6 @@ pub(crate) fn run_sync( result } -/// Transitional helper for call sites that do not yet have approved retained -/// completion copy. New flow code should use [`run`] with a named copy pair. -pub(crate) async fn run_with_spinner(message: &str, future: Fut) -> Result -where - Fut: Future>, -{ - let span = info_span!("cli_progress", indicatif.pb_show = tracing::field::Empty); - span.pb_set_message(message); - span.pb_start(); - let result = future.instrument(span.clone()).await; - drop(span); - result -} - #[cfg(test)] mod tests { use std::sync::{ @@ -98,7 +84,7 @@ mod tests { use super::{ CHECK_NAME, GENERATE_KEY, ProgressCopy, RENEW_IDENTITY, REQUEST_CERT, SAVE_DEFAULT, - SEND_CODE, VERIFY_EMAIL, WAIT_FOR_PAYMENT, run_with_spinner, + SEND_CODE, VERIFY_EMAIL, WAIT_FOR_PAYMENT, run, }; #[test] @@ -125,17 +111,6 @@ mod tests { assert_eq!(SAVE_DEFAULT.success, "Saved default identity."); } - #[tokio::test] - async fn run_with_spinner_returns_inner_result() { - let value = run_with_spinner("Sending verification code...", async { - Ok::<_, std::io::Error>("ok") - }) - .await - .unwrap(); - - assert_eq!(value, "ok"); - } - #[derive(Clone, Default)] struct CountLayer(Arc); @@ -170,11 +145,9 @@ mod tests { let subscriber = tracing_subscriber::registry().with(layer.with_filter(IndicatifFilter::new(false))); let _guard = tracing::subscriber::set_default(subscriber); - run_with_spinner("Sending verification code...", async { - Ok::<_, std::io::Error>(()) - }) - .await - .unwrap(); + run(SEND_CODE, async { Ok::<_, std::io::Error>(()) }) + .await + .unwrap(); assert_eq!(seen.load(Ordering::SeqCst), 1); } } diff --git a/genmeta-identity/src/cli/flow/registration.rs b/genmeta-identity/src/cli/flow/registration.rs index bb3ebca..e7b7ef7 100644 --- a/genmeta-identity/src/cli/flow/registration.rs +++ b/genmeta-identity/src/cli/flow/registration.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use snafu::{FromString, Snafu, whatever}; +use snafu::{FromString, Snafu}; use super::target::IdentityTarget; use crate::{ @@ -25,14 +25,6 @@ fn is_subdomain_conflict(error: &crate::cert_server::Error) -> bool { error.is_api_code("subdomain_conflict") || is_domain_conflict(error) } -pub(crate) fn is_subdomain_quota_exceeded(error: &crate::cert_server::Error) -> bool { - error.is_api_code("subdomain_quota_exceeded") -} - -pub(crate) fn create_identity_progress_message() -> &'static str { - "Creating identity..." -} - fn missing_parent_identity_message(target: &IdentityTarget, parent: &str) -> String { format!( "Cannot register {} because its parent identity, {parent}, does not exist.", @@ -323,95 +315,6 @@ pub(crate) async fn register_missing_child( Ok(RegistrationOutcome::CreatedFree) } -pub(crate) fn ensure_non_interactive_root_checkout_not_required( - target: &IdentityTarget, - response: &CreateDomainResponse, -) -> Result<(), Error> { - match crate::checkout::classify_checkout(response) { - crate::checkout::CheckoutState::Completed => Ok(()), - crate::checkout::CheckoutState::Pending - | crate::checkout::CheckoutState::Expired - | crate::checkout::CheckoutState::Cancelled - | crate::checkout::CheckoutState::Failed => whatever!( - "creating {} requires interactive checkout; rerun this command in an interactive terminal to complete payment", - target.short_name() - ), - } -} - -pub(crate) fn ensure_non_interactive_sub_identity_checkout_not_required( - target: &IdentityTarget, - response: &CreateSubdomainResponse, -) -> Result<(), Error> { - if response.invoice.is_some() { - whatever!( - "creating {} exceeded the sub-identity quota and requires interactive checkout; rerun this command in an interactive terminal to expand the parent identity quota", - target.short_name() - ); - } - Ok(()) -} - -pub(crate) async fn ensure_identity_exists_with_token( - cert_server: &CertServer, - target: &IdentityTarget, - access_token: &str, - _progress_message: &str, -) -> Result<(), Error> { - let api = CertServerRegistrationApi::new(cert_server); - register_missing_root(&api, &NoRegistrationUi, target, access_token, false) - .await - .map(|_| ()) - .map_err(Error::from) -} - -pub(crate) async fn ensure_identity_exists_with_token_interactively( - cert_server: &CertServer, - target: &IdentityTarget, - access_token: &str, - _progress_message: &str, -) -> Result<(), Error> { - let api = CertServerRegistrationApi::new(cert_server); - register_missing_root(&api, &InquireRegistrationUi, target, access_token, true) - .await - .map(|_| ()) - .map_err(Error::from) -} - -pub(crate) async fn create_sub_identity_with_token( - cert_server: &CertServer, - target: &IdentityTarget, - access_token: &str, - parent: &dhttp::name::DhttpName<'_>, - label: &str, -) -> Result { - match super::progress::run_with_spinner( - create_identity_progress_message(), - cert_server.create_subdomain(access_token, parent.as_full(), label, None), - ) - .await - { - Ok(created) => Ok(created), - Err(error) if is_domain_not_found(&error) => Err(Error::with_source( - Box::new(error), - missing_parent_identity_message(target, parent.as_partial()), - )), - Err(error) => Err(Error::from(error)), - } -} - -pub(crate) async fn create_sub_identity_with_token_interactively( - cert_server: &CertServer, - target: &IdentityTarget, - access_token: &str, - parent: &dhttp::name::DhttpName<'_>, - label: &str, -) -> Result { - // Child quota failures are terminal. They belong to a separate quota flow and - // must never enter the root-name payment transcript. - create_sub_identity_with_token(cert_server, target, access_token, parent, label).await -} - #[cfg(test)] mod tests { use std::sync::Mutex; @@ -640,11 +543,6 @@ mod tests { assert_eq!(parent_api.calls(), ["register-child:parent"]); } - #[test] - fn root_and_sub_identity_creation_share_the_approved_progress_copy() { - assert_eq!(create_identity_progress_message(), "Creating identity..."); - } - #[test] fn missing_parent_does_not_offer_to_create_a_root_identity() { let target = IdentityTarget::parse("phone.alice.smith").unwrap(); diff --git a/genmeta-identity/src/cli/prompt.rs b/genmeta-identity/src/cli/prompt.rs index 657d8c3..68087e0 100644 --- a/genmeta-identity/src/cli/prompt.rs +++ b/genmeta-identity/src/cli/prompt.rs @@ -209,7 +209,6 @@ pub(crate) async fn prompt_kind_with_cursor( IdentityKind::Secondary.usage_label() ] ) - .with_help_message(IdentityKind::USAGE_HELP) .with_starting_cursor(starting_cursor.unwrap_or(0)) .prompt() )?; From 3e0282ba8756b87a10ba6c25e34dd5121eb077a3 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:33:12 +0800 Subject: [PATCH 15/27] feat(identity): finish apply onboarding --- genmeta-identity/src/cli.rs | 12 -- genmeta-identity/src/cli/flow/apply.rs | 69 ++++----- genmeta-identity/src/cli/flow/epilogue.rs | 174 +++++++--------------- genmeta-identity/src/cli/flow/output.rs | 57 +------ genmeta-identity/src/cli/flow/welcome.rs | 83 +++++++---- 5 files changed, 136 insertions(+), 259 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 2ada7b0..202bd3d 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -130,18 +130,6 @@ fn generate_private_key_and_csr(name: &Name<'_>) -> Result<(String, String), Err Ok((key_pem, csr_pem)) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum LocalIdentitySave { - New, - Replace, -} - -impl LocalIdentitySave { - pub(crate) fn created_new_identity(self) -> bool { - matches!(self, Self::New) - } -} - #[tracing::instrument()] async fn load_current_settings(dhttp_home: &DhttpHome) -> Result, Error> { match dhttp_home.load_settings().await { diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index ba74d9e..faa621d 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -14,7 +14,7 @@ use super::{ }; use crate::{ cert_server::{CertServer, CertificateDetail}, - cli::{self, Apply, Error, prompt::InquireResultExt}, + cli::{Apply, Error, prompt::InquireResultExt}, }; const APPLY_OPENING: &str = "Applying identity, generating ECC key pair locally, then requesting and deploying certificate."; @@ -119,14 +119,9 @@ async fn authorize_local_replacement( resolved: &ResolvedApplyTarget, force: bool, interactive: bool, -) -> Result { - let save = if resolved.local.is_some() { - cli::LocalIdentitySave::Replace - } else { - cli::LocalIdentitySave::New - }; +) -> Result<(), Error> { if replacement_requirement(resolved.local.as_ref()) == ReplacementRequirement::None || force { - return Ok(save); + return Ok(()); } if !interactive { return Err(crate::cli::prompt::Error::NotInteractive { @@ -140,7 +135,7 @@ async fn authorize_local_replacement( { whatever!("apply was cancelled"); } - Ok(save) + Ok(()) } async fn resolve_kind(command: &Apply) -> Result { @@ -440,8 +435,6 @@ async fn install_and_finish( dhttp_home: &DhttpHome, resolved: &ResolvedApplyTarget, kind: IdentityKind, - local_identity_save: cli::LocalIdentitySave, - default_identity_when_command_started: Option>, interactive: bool, key_material: &super::key_material::LazyKeyMaterial, detail: &CertificateDetail, @@ -462,21 +455,27 @@ async fn install_and_finish( .await?; super::transcript::print_line(INSTALLED); - let welcome = super::welcome::maybe_create_welcome_service( - dhttp_home, - resolved.target.dhttp_name(), - local_identity_save.created_new_identity(), - ) - .await?; - super::epilogue::run_lifecycle_epilogue( + let usage = match kind { + IdentityKind::Primary => super::local::IdentityUsage::BothClientAndServer, + IdentityKind::Secondary => super::local::IdentityUsage::ClientOnly, + }; + match super::welcome::maybe_create_welcome_service( dhttp_home, resolved.target.dhttp_name(), - default_identity_when_command_started, - interactive, - super::output::SavedIdentityAction::Applied, - welcome.as_ref(), + usage, ) .await + { + Ok(Some(_)) => super::transcript::print_line( + super::welcome::format_welcome_service_created(resolved.target.short_name()), + ), + Ok(None) => {} + Err(error) => super::transcript::print_warning(&format!( + "The identity was installed, but the sample welcome page could not be created: {error}" + )), + } + super::epilogue::run_lifecycle_epilogue(dhttp_home, resolved.target.dhttp_name(), interactive) + .await } pub(crate) async fn run( @@ -487,12 +486,8 @@ pub(crate) async fn run( ) -> Result<(), Error> { super::transcript::print_line(APPLY_OPENING); let interactive = std::io::stdin().is_terminal(); - let default_identity_when_command_started = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); let mut resolved = resolve_apply_target(command, dhttp_home, cert_server, interactive).await?; - let local_identity_save = - authorize_local_replacement(&resolved, command.force, interactive).await?; + authorize_local_replacement(&resolved, command.force, interactive).await?; let kind = resolve_kind(command).await?; let device_name = super::device::resolve_device_name(command.device_name.as_deref(), home_scope); @@ -517,8 +512,6 @@ pub(crate) async fn run( dhttp_home, &resolved, kind, - local_identity_save, - default_identity_when_command_started, interactive, &key_material, &detail, @@ -603,22 +596,16 @@ mod tests { .await .is_err() ); - assert_eq!( - authorize_local_replacement(&ready, true, false) - .await - .unwrap(), - cli::LocalIdentitySave::Replace - ); + authorize_local_replacement(&ready, true, false) + .await + .unwrap(); let invalid = resolved_with(Some(LocalIdentityStatus::Invalid { detail: "certificate is unreadable".to_string(), })); - assert_eq!( - authorize_local_replacement(&invalid, false, false) - .await - .unwrap(), - cli::LocalIdentitySave::Replace - ); + authorize_local_replacement(&invalid, false, false) + .await + .unwrap(); } #[test] diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index ccaf4ba..e6627a7 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -1,38 +1,29 @@ -use std::io::IsTerminal; - use dhttp::{home::DhttpHome, name::DhttpName}; +use snafu::FromString; -use super::{local, output, transcript}; use crate::cli::{self, Error, prompt::InquireResultExt}; -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CurrentDefaultSummary { - pub(crate) name: String, - pub(crate) status: local::LocalIdentityStatus, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DefaultSuggestion { - pub(crate) prompt: String, + pub(crate) question: String, + pub(crate) help: String, pub(crate) default: bool, } pub(crate) fn suggest_default_change( saved_name: &str, - current_default: Option<&CurrentDefaultSummary>, - ansi: bool, + current_default: Option<&str>, ) -> Option { match current_default { - Some(current) if current.name == saved_name => None, + Some(current) if current == saved_name => None, Some(current) => Some(DefaultSuggestion { - prompt: format!( - "Set this name({saved_name}) as default? {}", - output::format_current_default_suffix(¤t.name, ¤t.status, ansi) - ), + question: format!("Set this name({saved_name}) as default?"), + help: format!("current: {current}"), default: false, }), None => Some(DefaultSuggestion { - prompt: format!("Set this name({saved_name}) as default?"), + question: format!("Set this name({saved_name}) as default?"), + help: "current: none".to_string(), default: true, }), } @@ -44,82 +35,45 @@ async fn current_default_name(dhttp_home: &DhttpHome) -> Result Result, Error> { - let Some(name) = current_default_name(dhttp_home).await? else { - return Ok(None); - }; - - let status = match local::try_load_summary_exact(dhttp_home, name.borrow(), None).await? { - Some(summary) => summary.status, - None => local::LocalIdentityStatus::Invalid { - detail: "identity is not saved here".to_string(), - }, - }; - - Ok(Some(CurrentDefaultSummary { - name: name.as_partial().to_string(), - status, - })) -} - -async fn save_default_name( - dhttp_home: &DhttpHome, - name: DhttpName<'_>, -) -> Result, Error> { +async fn save_default_name(dhttp_home: &DhttpHome, name: DhttpName<'_>) -> Result<(), Error> { let mut settings = cli::load_current_settings(dhttp_home) .await? .unwrap_or_else(|| dhttp_home.new_settings()); - let name = name.into_owned(); settings .settings_mut() - .set_default_identity_name(name.clone()); - cli::save_settings(&settings).await?; - Ok(name) + .set_default_identity_name(name.into_owned()); + cli::save_settings(&settings).await } pub(crate) async fn run_lifecycle_epilogue( dhttp_home: &DhttpHome, name: DhttpName<'_>, - _default_at_start: Option>, interactive: bool, - action: output::SavedIdentityAction, - welcome: Option<&super::welcome::WelcomeServiceCreated>, ) -> Result<(), Error> { - let ansi = std::io::stdout().is_terminal(); - let default_after = current_default_name(dhttp_home).await?; - let current_default = current_default_summary(dhttp_home).await?; - let summary = local::load_summary_exact( - dhttp_home, - name.clone(), - default_after.as_ref().map(|default| default.borrow()), - ) - .await?; - - transcript::print_block(&output::format_saved_identity_result( - action, &summary, ansi, - )); - - if interactive - && let Some(suggestion) = - suggest_default_change(name.as_partial(), current_default.as_ref(), ansi) - { - let accepted = crate::cli::prompt::sync(move || { - inquire::Confirm::new(&suggestion.prompt) - .with_default(suggestion.default) - .prompt() - }) - .await - .require_interactive("interactive input")?; - - if accepted { - save_default_name(dhttp_home, name.clone()).await?; - } + if !interactive { + return Ok(()); } - if let Some(welcome) = welcome { - transcript::print_block(&super::welcome::format_welcome_service_created(welcome)); + let current = current_default_name(dhttp_home).await?; + let current_short = current.as_ref().map(|name| name.as_partial()); + let Some(suggestion) = suggest_default_change(name.as_partial(), current_short) else { + return Ok(()); + }; + + let accepted = crate::cli::prompt::sync(move || { + inquire::Confirm::new(&suggestion.question) + .with_help_message(&suggestion.help) + .with_default(suggestion.default) + .prompt() + }) + .await + .require_interactive("interactive input")?; + + if accepted && let Err(error) = save_default_name(dhttp_home, name).await { + return Err(Error::with_source( + Box::new(error), + "Identity was installed, but the default identity was not updated.".to_string(), + )); } Ok(()) } @@ -134,8 +88,7 @@ mod tests { use dhttp::{home::DhttpHome, name::DhttpName}; use tokio::fs; - use super::{CurrentDefaultSummary, DefaultSuggestion, suggest_default_change}; - use crate::cli::flow::local::LocalIdentityStatus; + use super::{DefaultSuggestion, suggest_default_change}; fn unique_test_home_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() @@ -149,34 +102,26 @@ mod tests { } #[test] - fn suggest_fill_empty_default_uses_yes_by_default() { - let suggestion = suggest_default_change("alice.smith", None, false).unwrap(); - - assert!(suggestion.default); - assert_eq!(suggestion.prompt, "Set this name(alice.smith) as default?"); - } - - #[test] - fn suggest_replacing_default_uses_no_by_default_and_shows_current_status() { - let suggestion = suggest_default_change( - "alice.smith", - Some(&CurrentDefaultSummary { - name: "meng.lin".to_string(), - status: LocalIdentityStatus::Invalid { - detail: "certificate is unreadable".to_string(), - }, - }), - false, - ) - .unwrap(); - + fn suggestion_defaults_and_help_match_current_default_state() { + assert_eq!( + suggest_default_change("alice.smith", None), + Some(DefaultSuggestion { + question: "Set this name(alice.smith) as default?".to_string(), + help: "current: none".to_string(), + default: true, + }) + ); assert_eq!( - suggestion, - DefaultSuggestion { - prompt: "Set this name(alice.smith) as default? (current: meng.lin [invalid])" - .to_string(), + suggest_default_change("alice.smith", Some("meng.lin")), + Some(DefaultSuggestion { + question: "Set this name(alice.smith) as default?".to_string(), + help: "current: meng.lin".to_string(), default: false, - } + }) + ); + assert_eq!( + suggest_default_change("alice.smith", Some("alice.smith")), + None ); } @@ -188,16 +133,9 @@ mod tests { let profile = dhttp_home.identity_profile(name.borrow()); fs::create_dir_all(profile.ssl_dir()).await.unwrap(); - super::run_lifecycle_epilogue( - &dhttp_home, - name.borrow(), - None, - false, - crate::cli::flow::output::SavedIdentityAction::Applied, - None, - ) - .await - .unwrap(); + super::run_lifecycle_epilogue(&dhttp_home, name.borrow(), false) + .await + .unwrap(); assert!( super::current_default_name(&dhttp_home) diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index 5d823ef..dc28cc5 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -100,24 +100,6 @@ pub(crate) fn compact_identity_label_parts( label } -pub(crate) fn format_current_default_suffix( - name: &str, - status: &LocalIdentityStatus, - ansi: bool, -) -> String { - render_block( - format!( - "(current: {})", - compact_identity_label_parts(name, status, false) - ), - BlockStyle { - bold: false, - dim: true, - }, - ansi, - ) -} - #[cfg(test)] pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: bool) -> String { match choice { @@ -135,32 +117,6 @@ pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: boo } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SavedIdentityAction { - Applied, -} - -impl SavedIdentityAction { - fn verb(self) -> &'static str { - match self { - Self::Applied => "Applied", - } - } -} - -pub(crate) fn format_saved_identity_result( - action: SavedIdentityAction, - summary: &LocalIdentitySummary, - _ansi: bool, -) -> String { - format!( - "{} identity {} at {}", - action.verb(), - summary.target.short_name(), - summary.dir.display() - ) -} - pub(crate) fn format_info(summary: &LocalIdentitySummary, now: i64, ansi: bool) -> String { let mut lines = vec![format!( "{} {}:", @@ -267,8 +223,7 @@ mod tests { use std::path::PathBuf; use super::{ - SavedIdentityAction, format_current_default_suffix, format_default_query, format_info, - format_saved_identity_result, render_choice_label, render_inventory, + format_default_query, format_info, render_choice_label, render_inventory, render_verbose_inventory, }; use crate::cli::flow::{ @@ -479,16 +434,8 @@ mod tests { } #[test] - fn saved_result_and_prompt_helpers_remain_stable() { + fn saved_choice_label_remains_stable() { let profile = ready_default(); - assert_eq!( - format_saved_identity_result(SavedIdentityAction::Applied, &profile, false), - "Applied identity alice.smith at /tmp/alice.smith" - ); - assert_eq!( - format_current_default_suffix("alice.smith", &profile.status, false), - "(current: alice.smith)" - ); assert_eq!( render_choice_label(&InteractiveInventoryChoice::Saved(profile), false), "alice.smith (default)" diff --git a/genmeta-identity/src/cli/flow/welcome.rs b/genmeta-identity/src/cli/flow/welcome.rs index 7d848b8..3afbea7 100644 --- a/genmeta-identity/src/cli/flow/welcome.rs +++ b/genmeta-identity/src/cli/flow/welcome.rs @@ -8,7 +8,6 @@ use tokio::{fs, io::AsyncWriteExt}; pub(crate) struct WelcomeServiceCreated { pub(crate) server_conf_path: PathBuf, pub(crate) welcome_page_path: PathBuf, - pub(crate) url: String, } #[derive(Debug, Snafu)] @@ -66,9 +65,9 @@ const WELCOME_PAGE_PATH: &str = "templates/welcome/index.html"; pub(crate) async fn maybe_create_welcome_service( dhttp_home: &DhttpHome, name: DhttpName<'_>, - identity_was_newly_saved: bool, + usage: super::local::IdentityUsage, ) -> Result, WelcomeServiceError> { - if !identity_was_newly_saved { + if usage == super::local::IdentityUsage::ClientOnly { return Ok(None); } @@ -112,16 +111,12 @@ pub(crate) async fn maybe_create_welcome_service( Ok(Some(WelcomeServiceCreated { server_conf_path, welcome_page_path, - url: format!("https://{}/", name.as_partial()), })) } -pub(crate) fn format_welcome_service_created(created: &WelcomeServiceCreated) -> String { +pub(crate) fn format_welcome_service_created(name: &str) -> String { format!( - "Welcome service created\n Created server.conf at {}\n Created welcome page at {}\n Open {} after pishoo starts or reloads", - created.server_conf_path.display(), - created.welcome_page_path.display(), - created.url, + "A sample welcome page has been created. After starting the service, access it with `genmeta curl https://{name}/`." ) } @@ -240,6 +235,7 @@ mod tests { use dhttp::{home::DhttpHome, name::DhttpName}; use super::{format_welcome_service_created, maybe_create_welcome_service}; + use crate::cli::flow::local::IdentityUsage; fn unique_test_home_path(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -257,9 +253,10 @@ mod tests { let home = DhttpHome::new(unique_test_home_path("user-scope-new-identity")); let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); - let created = maybe_create_welcome_service(&home, name.borrow(), true) - .await - .unwrap(); + let created = + maybe_create_welcome_service(&home, name.borrow(), IdentityUsage::BothClientAndServer) + .await + .unwrap(); let created = created.expect("new user identity should create welcome files"); assert!(created.server_conf_path.exists()); @@ -267,11 +264,11 @@ mod tests { } #[tokio::test] - async fn skips_welcome_onboarding_when_identity_was_replaced() { - let home = DhttpHome::new(unique_test_home_path("replaced-identity")); + async fn skips_welcome_onboarding_for_client_only_usage() { + let home = DhttpHome::new(unique_test_home_path("client-only")); let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); - let created = maybe_create_welcome_service(&home, name.borrow(), false) + let created = maybe_create_welcome_service(&home, name.borrow(), IdentityUsage::ClientOnly) .await .unwrap(); @@ -286,9 +283,10 @@ mod tests { let home = DhttpHome::new(unique_test_home_path("new-identity")); let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); - let created = maybe_create_welcome_service(&home, name.borrow(), true) - .await - .unwrap(); + let created = + maybe_create_welcome_service(&home, name.borrow(), IdentityUsage::BothClientAndServer) + .await + .unwrap(); let created = created.expect("new identity should create welcome files"); let profile = home.identity_profile(name.borrow()); @@ -339,12 +337,35 @@ mod tests { .await .unwrap(); - let created = maybe_create_welcome_service(&home, name.borrow(), true) + let created = + maybe_create_welcome_service(&home, name.borrow(), IdentityUsage::BothClientAndServer) + .await + .unwrap(); + + assert!(created.is_none()); + assert!(!profile.join("templates/welcome/index.html").exists()); + } + + #[tokio::test] + async fn skips_pair_creation_when_welcome_page_already_exists() { + let home = DhttpHome::new(unique_test_home_path("welcome-page-exists")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + let profile = home.identity_profile(name.borrow()); + let welcome_page = profile.join("templates/welcome/index.html"); + tokio::fs::create_dir_all(welcome_page.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&welcome_page, "existing page") .await .unwrap(); + let created = + maybe_create_welcome_service(&home, name.borrow(), IdentityUsage::BothClientAndServer) + .await + .unwrap(); + assert!(created.is_none()); - assert!(!profile.join("templates/welcome/index.html").exists()); + assert!(!profile.server_conf_path().exists()); } #[cfg(unix)] @@ -365,9 +386,10 @@ mod tests { ) .unwrap(); - let error = maybe_create_welcome_service(&home, name.borrow(), true) - .await - .expect_err("index.html directory should make file creation fail"); + let error = + maybe_create_welcome_service(&home, name.borrow(), IdentityUsage::BothClientAndServer) + .await + .expect_err("index.html directory should make file creation fail"); let rendered = error.to_string(); assert!(rendered.contains("welcome service"), "{rendered}"); @@ -375,15 +397,10 @@ mod tests { } #[test] - fn renders_welcome_service_created_block() { - let created = super::WelcomeServiceCreated { - server_conf_path: PathBuf::from("/tmp/alice/server.conf"), - welcome_page_path: PathBuf::from("/tmp/alice/templates/welcome/index.html"), - url: "https://alice.smith/".to_string(), - }; - - let expected = "Welcome service created\n Created server.conf at /tmp/alice/server.conf\n Created welcome page at /tmp/alice/templates/welcome/index.html\n Open https://alice.smith/ after pishoo starts or reloads"; - - assert_eq!(format_welcome_service_created(&created), expected); + fn welcome_success_copy_is_one_line() { + assert_eq!( + format_welcome_service_created("alice.smith"), + "A sample welcome page has been created. After starting the service, access it with `genmeta curl https://alice.smith/`." + ); } } From ff0191ab230aa99d8176ba6b3602382d31a5c396 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:37:11 +0800 Subject: [PATCH 16/27] feat(identity): enforce safe renewal preflight --- genmeta-identity/src/cli.rs | 14 +- genmeta-identity/src/cli/flow/auth_plan.rs | 19 - genmeta-identity/src/cli/flow/local.rs | 34 ++ genmeta-identity/src/cli/flow/renew.rs | 576 ++++++++++++--------- 4 files changed, 374 insertions(+), 269 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 202bd3d..7421a0a 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -4,8 +4,9 @@ pub mod validator; use std::io::IsTerminal; use clap::Parser; +#[cfg(test)] +use dhttp::certificate::CertificateChainKey; use dhttp::{ - certificate::CertificateChainKey, home::{ DhttpHome, HomeScope, LoadDhttpHomeError, identity::{ @@ -111,6 +112,7 @@ impl snafu::FromString for Error { } } +#[cfg(test)] fn certificate_chain_key_from_identity( identity: &dhttp::identity::Identity, ) -> Result, Error> { @@ -120,16 +122,6 @@ fn certificate_chain_key_from_identity( } } -fn generate_private_key_and_csr(name: &Name<'_>) -> Result<(String, String), Error> { - let mut material = flow::key_material::LazyKeyMaterial::for_name(name.clone()); - let csr_pem = material.csr_pem()?.to_string(); - let key_pem = material - .key_pem() - .expect("requesting a CSR generates its key first") - .to_string(); - Ok((key_pem, csr_pem)) -} - #[tracing::instrument()] async fn load_current_settings(dhttp_home: &DhttpHome) -> Result, Error> { match dhttp_home.load_settings().await { diff --git a/genmeta-identity/src/cli/flow/auth_plan.rs b/genmeta-identity/src/cli/flow/auth_plan.rs index 5a8b37a..e184ae0 100644 --- a/genmeta-identity/src/cli/flow/auth_plan.rs +++ b/genmeta-identity/src/cli/flow/auth_plan.rs @@ -168,25 +168,6 @@ where } } -pub(crate) async fn first_auth_candidate( - dhttp_home: &DhttpHome, - target: &IdentityTarget, - remote: RemoteTargetState, -) -> Result { - let mut runner = AuthCandidateRunner::new( - HomeExactIdentityLoader::new(dhttp_home), - candidate_specs(target, remote), - ); - loop { - match runner.next().await? { - CandidateEvent::Warning(warning) => { - super::transcript::print_warning(&warning); - } - selected => return Ok(selected), - } - } -} - #[cfg(test)] mod tests { use std::{collections::BTreeMap, path::PathBuf}; diff --git a/genmeta-identity/src/cli/flow/local.rs b/genmeta-identity/src/cli/flow/local.rs index 65b9012..c7a6a0c 100644 --- a/genmeta-identity/src/cli/flow/local.rs +++ b/genmeta-identity/src/cli/flow/local.rs @@ -18,6 +18,7 @@ pub(crate) enum LocalIdentityMaterialState { pub(crate) struct LocalIdentityAssessment { pub(crate) certificate: LocalIdentityMaterialState, pub(crate) private_key: LocalIdentityMaterialState, + pub(crate) certificate_target_matches: Option, pub(crate) usage: Option, pub(crate) sequence: Option, pub(crate) valid_from: Option, @@ -379,6 +380,7 @@ async fn assess_profile( return LocalIdentityAssessment { certificate: certificate_state_from_error(&error), private_key: LocalIdentityMaterialState::Present, + certificate_target_matches: None, usage: None, sequence: None, valid_from: None, @@ -397,6 +399,7 @@ async fn assess_profile( let mut assessment = LocalIdentityAssessment { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, + certificate_target_matches: None, usage: None, sequence: None, valid_from: None, @@ -441,6 +444,25 @@ async fn assess_profile( Ok((_, certificate)) => { assessment.valid_from = Some(certificate.validity().not_before.timestamp()); assessment.expires_at = Some(certificate.validity().not_after.timestamp()); + let target_matches = certificate + .subject_alternative_name() + .ok() + .flatten() + .is_some_and(|extension| { + extension.value.general_names.iter().any(|name| { + matches!( + name, + x509_parser::extensions::GeneralName::DNSName(candidate) + if candidate == &profile.name().as_full() + ) + }) + }); + assessment.certificate_target_matches = Some(target_matches); + if !target_matches { + assessment.certificate = LocalIdentityMaterialState::Invalid( + "certificate target does not match identity profile".to_string(), + ); + } } Err(_) => { assessment.certificate = @@ -469,6 +491,14 @@ async fn assess_profile( assessment } +pub(crate) async fn assess_profile_exact( + dhttp_home: &DhttpHome, + name: DhttpName<'_>, +) -> Result { + let profile = dhttp_home.resolve_identity_profile_exactly(name).await?; + Ok(assess_profile(&profile).await) +} + fn certificate_state_from_error( error: &dhttp::home::identity::ssl::LoadCertsError, ) -> LocalIdentityMaterialState { @@ -585,6 +615,7 @@ mod tests { &LocalIdentityAssessment { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, + certificate_target_matches: Some(true), usage: Some(IdentityUsage::BothClientAndServer), sequence: Some(0), valid_from: Some(NOW - 300), @@ -603,6 +634,7 @@ mod tests { &LocalIdentityAssessment { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Present, + certificate_target_matches: Some(true), usage: Some(IdentityUsage::ClientOnly), sequence: Some(2), valid_from: Some(NOW - 300), @@ -621,6 +653,7 @@ mod tests { &LocalIdentityAssessment { certificate: LocalIdentityMaterialState::Present, private_key: LocalIdentityMaterialState::Missing("private key missing"), + certificate_target_matches: Some(true), usage: Some(IdentityUsage::ClientOnly), sequence: Some(3), valid_from: Some(NOW - 300), @@ -641,6 +674,7 @@ mod tests { "certificate does not match local key".to_string(), ), private_key: LocalIdentityMaterialState::Present, + certificate_target_matches: Some(true), usage: Some(IdentityUsage::ClientOnly), sequence: Some(4), valid_from: Some(NOW - 300), diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index dbe70bb..ff7d4df 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -1,196 +1,265 @@ use std::io::IsTerminal; -use dhttp::home::{DhttpHome, HomeScope}; -use snafu::{OptionExt, whatever}; +use dhttp::{ + certificate::CertificateChainKind, + home::{DhttpHome, HomeScope}, +}; +use snafu::{FromString, whatever}; use super::{auth_plan::CandidateEvent, local}; use crate::{ - cert_server::CertServer, - cli::{self, Error, Renew}, + cert_server::{CertServer, CertificateDetail}, + cli::{Error, Renew}, }; -#[derive(Debug, Clone, PartialEq, Eq)] -enum RenewApprovalPlan { - Email, - Identity { auth_domain: String }, +const RENEWED: &str = "Identity successfully renewed on this device."; + +fn renew_not_saved_root_message(short_name: &str) -> String { + format!("Failed to renew: {short_name} not found!") } -#[derive(Debug, Clone)] -struct InteractiveRenewState { - target: Option>, - approval_plan: Option, +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RenewPreflight { + target: super::target::IdentityTarget, + kind: CertificateChainKind, + sequence: u32, } -impl InteractiveRenewState { - fn from_command(_command: &Renew, target: Option>) -> Self { - Self { - target, - approval_plan: None, +impl RenewPreflight { + pub(crate) fn from_summary( + summary: &local::LocalIdentitySummary, + force: bool, + ) -> Result { + match summary.status { + local::LocalIdentityStatus::Ready { .. } + | local::LocalIdentityStatus::Expired { .. } => {} + local::LocalIdentityStatus::Invalid { .. } + | local::LocalIdentityStatus::Incomplete { .. } + if !force => + { + return Err(Error::without_source(format!( + "Failed to renew: {} is {}; use --force only if its certificate remains readable.", + summary.target.short_name(), + summary.status.label() + ))); + } + local::LocalIdentityStatus::Invalid { .. } + | local::LocalIdentityStatus::Incomplete { .. } => {} } + + let kind = match summary.usage { + Some(local::IdentityUsage::BothClientAndServer) => CertificateChainKind::Primary, + Some(local::IdentityUsage::ClientOnly) => CertificateChainKind::Secondary, + None => { + return Err(Self::unrecoverable(&summary.target)); + } + }; + let Some(sequence) = summary.sequence else { + return Err(Self::unrecoverable(&summary.target)); + }; + Ok(Self { + target: summary.target.clone(), + kind, + sequence, + }) } -} -fn renew_not_saved_root_message(short_name: &str) -> String { - format!("Failed to renew: {short_name} not found!") + fn validate_assessment( + &self, + assessment: &local::LocalIdentityAssessment, + ) -> Result<(), Error> { + let actual_kind = match assessment.usage { + Some(local::IdentityUsage::BothClientAndServer) => CertificateChainKind::Primary, + Some(local::IdentityUsage::ClientOnly) => CertificateChainKind::Secondary, + None => return Err(Self::unrecoverable(&self.target)), + }; + if assessment.certificate_target_matches != Some(true) + || actual_kind != self.kind + || assessment.sequence != Some(self.sequence) + { + return Err(Self::unrecoverable(&self.target)); + } + Ok(()) + } + + fn unrecoverable(target: &super::target::IdentityTarget) -> Error { + Error::without_source(format!( + "Failed to renew: {} does not contain a readable certificate with recoverable target and chain metadata.", + target.short_name() + )) + } } -async fn ensure_saved_renew_target( +async fn resolve_preflight( + command: &Renew, dhttp_home: &DhttpHome, - name: dhttp::name::DhttpName<'_>, -) -> Result<(), Error> { - if local::try_load_summary_exact(dhttp_home, name.borrow(), None) - .await? - .is_some() - { - return Ok(()); - } +) -> Result { + let domain = match command.name.as_deref() { + Some(name) => crate::cli::parse_identity_name(name)?, + None => crate::cli::resolve_default_target_name(dhttp_home).await?, + }; + let Some(summary) = local::try_load_summary_exact(dhttp_home, domain.borrow(), None).await? + else { + whatever!("{}", renew_not_saved_root_message(domain.as_partial())); + }; + let preflight = RenewPreflight::from_summary(&summary, command.force)?; + let assessment = local::assess_profile_exact(dhttp_home, domain.borrow()).await?; + preflight.validate_assessment(&assessment)?; + Ok(preflight) +} - whatever!("{}", renew_not_saved_root_message(name.as_partial())); +#[derive(Debug, Clone, Copy)] +enum RenewProof<'a> { + AccessToken(&'a str), + Identity(&'a str), } -fn approval_plan_from_candidate(candidate: CandidateEvent) -> Result { - match candidate { - CandidateEvent::Identity { full_name, .. } => Ok(RenewApprovalPlan::Identity { - auth_domain: full_name, - }), - CandidateEvent::Email => Ok(RenewApprovalPlan::Email), - CandidateEvent::Warning(_) => { - unreachable!("first_auth_candidate consumes warnings") - } - CandidateEvent::Exhausted => { - whatever!("no authentication candidate is available") - } - } +#[derive(Debug)] +enum RequestFailure { + Local(Error), + Remote(crate::cert_server::Error), } -async fn validate_and_save_renew( - dhttp_home: &DhttpHome, - domain: dhttp::name::DhttpName<'_>, - kind: dhttp::certificate::CertificateChainKind, - sequence: u32, - key_pem: &str, - detail: &crate::cert_server::CertificateDetail, -) -> Result<(), Error> { - super::install::validate_and_save( - dhttp_home, - detail, - &super::install::InstallExpectation { - target: domain, - kind, - sequence: Some(sequence), - }, - key_pem, - ) - .await?; - Ok(()) +async fn request_renewal( + cert_server: &CertServer, + proof: RenewProof<'_>, + preflight: &RenewPreflight, + device_name: &str, + key_material: &mut super::key_material::LazyKeyMaterial, +) -> Result { + key_material.ensure_key().map_err(RequestFailure::Local)?; + super::progress::run(super::progress::RENEW_IDENTITY, async { + let csr_pem = key_material + .csr_pem() + .map_err(RequestFailure::Local)? + .to_string(); + let result = match proof { + RenewProof::AccessToken(token) => { + cert_server + .renew_cert( + token, + preflight.target.full_name(), + preflight.kind.as_str(), + preflight.sequence, + Some(device_name), + &csr_pem, + ) + .await + } + RenewProof::Identity(identity) => { + cert_server + .renew_cert_with_identity( + identity, + preflight.target.full_name(), + preflight.kind.as_str(), + preflight.sequence, + Some(device_name), + &csr_pem, + ) + .await + } + }; + result.map_err(RequestFailure::Remote) + }) + .await } -async fn resolve_target( - command: &Renew, - dhttp_home: &DhttpHome, -) -> Result, Error> { - match command.name.as_deref() { - Some(name) => cli::parse_identity_name(name), - None => cli::resolve_default_target_name(dhttp_home).await, +fn renew_remote_error(error: crate::cert_server::Error, target: &str) -> Error { + if error.is_api_code("cert_sequence_not_found") { + let message = error.to_string(); + return Error::with_source( + Box::new(error), + format!( + "{message}\nRun `genmeta identity apply {target}` to request a new certificate chain." + ), + ); } + error.into() } -async fn run_interactive( +fn print_auth_rejection(name: &str, error: &crate::cert_server::Error) { + super::transcript::print_warning(&format!( + "Cannot authenticate with {name}: {error}; trying the next authentication method" + )); +} + +async fn request_with_candidates( command: &Renew, dhttp_home: &DhttpHome, - home_scope: HomeScope, cert_server: &CertServer, -) -> Result<(), Error> { - let initial_target = match command.name.as_deref() { - Some(name) => cli::parse_identity_name(name)?, - None => cli::resolve_default_target_name(dhttp_home).await?, - }; - let mut state = InteractiveRenewState::from_command(command, Some(initial_target)); + preflight: &RenewPreflight, + device_name: &str, + interactive: bool, + key_material: &mut super::key_material::LazyKeyMaterial, +) -> Result { + let specs = super::auth_plan::candidate_specs( + &preflight.target, + super::target::RemoteTargetState::Exists, + ); + let loader = super::auth_plan::HomeExactIdentityLoader::new(dhttp_home); + let mut candidates = super::auth_plan::AuthCandidateRunner::new(loader, specs); + let mut last_rejection = None; loop { - let domain = state - .target - .clone() - .whatever_context::<_, Error>("interactive renew target is unavailable")?; - ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; - - if state.approval_plan.is_none() { - let target = crate::cli::flow::target::IdentityTarget::parse(domain.as_partial())?; - let candidate = super::auth_plan::first_auth_candidate( - dhttp_home, - &target, - crate::cli::flow::target::RemoteTargetState::Exists, + match candidates.next().await? { + CandidateEvent::Warning(warning) => super::transcript::print_warning(&warning), + CandidateEvent::Identity { + short_name, + full_name, + } => match request_renewal( + cert_server, + RenewProof::Identity(&full_name), + preflight, + device_name, + key_material, ) - .await?; - state.approval_plan = Some(approval_plan_from_candidate(candidate)?); - continue; - } - - let approval_plan = state - .approval_plan - .clone() - .whatever_context::<_, Error>("interactive renew approval plan is unavailable")?; - - let identity_profile = dhttp_home.resolve_identity_profile(domain.borrow()).await?; - let local_identity = identity_profile.load_identity().await?; - let chain_key = cli::certificate_chain_key_from_identity(&local_identity)? - .whatever_context::<_, Error>("local identity does not expose a certificate chain")?; - let chain_kind = chain_key.kind(); - let kind = chain_kind.as_str(); - let sequence = chain_key.sequence().get(); - let device_name = - super::device::resolve_device_name(command.device_name.as_deref(), home_scope); - let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; - - let detail = match approval_plan { - RenewApprovalPlan::Email => { + .await + { + Ok(detail) => return Ok(detail), + Err(RequestFailure::Local(error)) => return Err(error), + Err(RequestFailure::Remote(error)) + if crate::auth::classify_identity_attempt(&error) + == crate::auth::AuthAttemptDisposition::TryNext => + { + print_auth_rejection(&short_name, &error); + last_rejection = Some(error); + } + Err(RequestFailure::Remote(error)) => { + return Err(renew_remote_error(error, preflight.target.short_name())); + } + }, + CandidateEvent::Email => { let token = super::email::run_cert_server_email_session( cert_server, - super::email::EmailLogin::Domain(domain.as_full().to_string()), + super::email::EmailLogin::Domain(preflight.target.full_name().to_string()), command.email.as_deref(), command.verify_code.as_deref(), - true, + interactive, ) .await?; - super::progress::run( - super::progress::RENEW_IDENTITY, - cert_server.renew_cert( - &token, - domain.as_full(), - kind, - sequence, - Some(&device_name), - &csr_pem, - ), + return match request_renewal( + cert_server, + RenewProof::AccessToken(&token), + preflight, + device_name, + key_material, ) - .await? + .await + { + Ok(detail) => Ok(detail), + Err(RequestFailure::Local(error)) => Err(error), + Err(RequestFailure::Remote(error)) => { + Err(renew_remote_error(error, preflight.target.short_name())) + } + }; } - RenewApprovalPlan::Identity { auth_domain } => { - super::progress::run( - super::progress::RENEW_IDENTITY, - cert_server.renew_cert_with_identity( - &auth_domain, - domain.as_full(), - kind, - sequence, - Some(&device_name), - &csr_pem, - ), - ) - .await? + CandidateEvent::Exhausted => { + if let Some(error) = last_rejection { + return Err(error.into()); + } + whatever!("no authentication candidate is available"); } - }; - - validate_and_save_renew( - dhttp_home, - domain.borrow(), - chain_kind, - sequence, - &key_pem, - &detail, - ) - .await?; - return Ok(()); + } } } @@ -200,80 +269,37 @@ pub(crate) async fn run( home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - let is_interactive = std::io::stdin().is_terminal(); - if is_interactive { - return run_interactive(command, dhttp_home, home_scope, cert_server).await; - } - let domain = resolve_target(command, dhttp_home).await?; - ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; - let target = crate::cli::flow::target::IdentityTarget::parse(domain.as_partial())?; - let approval_plan = approval_plan_from_candidate( - super::auth_plan::first_auth_candidate( - dhttp_home, - &target, - crate::cli::flow::target::RemoteTargetState::Exists, - ) - .await?, - )?; - let identity_profile = dhttp_home.resolve_identity_profile(domain.borrow()).await?; - let local_identity = identity_profile.load_identity().await?; - let chain_key = cli::certificate_chain_key_from_identity(&local_identity)? - .whatever_context::<_, Error>("local identity does not expose a certificate chain")?; - let chain_kind = chain_key.kind(); - let kind = chain_kind.as_str(); - let sequence = chain_key.sequence().get(); + let interactive = std::io::stdin().is_terminal(); + let preflight = resolve_preflight(command, dhttp_home).await?; let device_name = super::device::resolve_device_name(command.device_name.as_deref(), home_scope); - - let (key_pem, csr_pem) = cli::generate_private_key_and_csr(&domain)?; - let detail = match approval_plan { - RenewApprovalPlan::Email => { - let token = super::email::run_cert_server_email_session( - cert_server, - super::email::EmailLogin::Domain(domain.as_full().to_string()), - command.email.as_deref(), - command.verify_code.as_deref(), - false, - ) - .await?; - super::progress::run( - super::progress::RENEW_IDENTITY, - cert_server.renew_cert( - &token, - domain.as_full(), - kind, - sequence, - Some(&device_name), - &csr_pem, - ), - ) - .await? - } - RenewApprovalPlan::Identity { auth_domain } => { - super::progress::run( - super::progress::RENEW_IDENTITY, - cert_server.renew_cert_with_identity( - &auth_domain, - domain.as_full(), - kind, - sequence, - Some(&device_name), - &csr_pem, - ), - ) - .await? - } - }; - - validate_and_save_renew( + let mut key_material = + super::key_material::LazyKeyMaterial::for_name(preflight.target.dhttp_name()); + let detail = request_with_candidates( + command, + dhttp_home, + cert_server, + &preflight, + &device_name, + interactive, + &mut key_material, + ) + .await?; + let key_pem = key_material + .key_pem() + .expect("a renewal response requires the request key"); + super::install::validate_and_save( dhttp_home, - domain.borrow(), - chain_kind, - sequence, - &key_pem, &detail, + &super::install::InstallExpectation { + target: preflight.target.dhttp_name(), + kind: preflight.kind, + sequence: Some(preflight.sequence), + }, + key_pem, ) .await?; + super::transcript::print_line(RENEWED); Ok(()) } @@ -286,11 +312,14 @@ mod tests { use dhttp::home::{DhttpHome, HomeScope}; - use super::{ - CandidateEvent, RenewApprovalPlan, approval_plan_from_candidate, - renew_not_saved_root_message, + use super::{RenewPreflight, renew_not_saved_root_message, renew_remote_error}; + use crate::cli::{ + Renew, + flow::{ + local::{IdentityUsage, LocalIdentityStatus, LocalIdentitySummary}, + target::IdentityTarget, + }, }; - use crate::cli::Renew; fn unique_test_home_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() @@ -308,25 +337,87 @@ mod tests { crate::cert_server::CertServer::new("https://license.genmeta.net").unwrap() } + fn summary(status: LocalIdentityStatus, facts: bool) -> LocalIdentitySummary { + LocalIdentitySummary { + target: IdentityTarget::parse("alice.smith").unwrap(), + usage: facts.then_some(IdentityUsage::ClientOnly), + sequence: facts.then_some(2), + valid_from: Some(1_700_000_000), + expires_at: Some(1_900_000_000), + status, + dir: PathBuf::from("/tmp/alice.smith"), + is_default: false, + } + } + #[test] - fn renew_prefers_ready_identity_non_interactively() { - assert_eq!( - approval_plan_from_candidate(CandidateEvent::Identity { - short_name: "alice.smith".to_string(), - full_name: "alice.smith.dhttp.net".to_string(), - }) - .unwrap(), - RenewApprovalPlan::Identity { - auth_domain: "alice.smith.dhttp.net".to_string() - } + fn renew_preflight_accepts_healthy_near_expiry_and_expired_without_force() { + for summary in [ + summary( + LocalIdentityStatus::Ready { + expires_at: 1_900_000_000, + }, + true, + ), + summary( + LocalIdentityStatus::Ready { + expires_at: 1_800_000_001, + }, + true, + ), + summary( + LocalIdentityStatus::Expired { + expired_at: 1_700_000_000, + }, + true, + ), + ] { + let preflight = RenewPreflight::from_summary(&summary, false).unwrap(); + assert_eq!( + preflight.kind, + dhttp::certificate::CertificateChainKind::Secondary + ); + assert_eq!(preflight.sequence, 2); + } + } + + #[test] + fn force_only_recovers_nonready_material_with_certificate_facts() { + let incomplete = summary( + LocalIdentityStatus::Incomplete { + detail: "private key missing".to_string(), + }, + true, ); + let invalid = summary( + LocalIdentityStatus::Invalid { + detail: "certificate does not match local key".to_string(), + }, + true, + ); + let missing_certificate = summary( + LocalIdentityStatus::Incomplete { + detail: "certificate missing".to_string(), + }, + false, + ); + + assert!(RenewPreflight::from_summary(&incomplete, false).is_err()); + assert!(RenewPreflight::from_summary(&incomplete, true).is_ok()); + assert!(RenewPreflight::from_summary(&invalid, true).is_ok()); + assert!(RenewPreflight::from_summary(&missing_certificate, true).is_err()); } #[test] - fn renew_without_ready_identity_uses_email_non_interactively() { + fn missing_chain_error_keeps_server_message_and_appends_apply_hint() { + let error = crate::cert_server::Error::Api { + status: reqwest::StatusCode::NOT_FOUND, + code: "cert_sequence_not_found".to_string(), + message: "certificate chain was not found".to_string(), + }; assert_eq!( - approval_plan_from_candidate(CandidateEvent::Email).unwrap(), - RenewApprovalPlan::Email, + renew_remote_error(error, "alice.smith").to_string(), + "certificate chain was not found\nRun `genmeta identity apply alice.smith` to request a new certificate chain." ); } @@ -339,7 +430,7 @@ mod tests { } #[tokio::test] - async fn renew_reports_saved_local_requirement_when_named_identity_is_missing() { + async fn renew_reports_saved_local_requirement_before_network_or_email() { let home_path = unique_test_home_path("renew-unsaved"); let dhttp_home = DhttpHome::new(home_path); let command = Renew { @@ -353,8 +444,15 @@ mod tests { let error = super::run(&command, &dhttp_home, HomeScope::User, &dummy_cert_server()) .await .unwrap_err(); - let rendered = error.to_string(); - assert_eq!(rendered, "Failed to renew: alice.smith not found!"); + assert_eq!(error.to_string(), "Failed to renew: alice.smith not found!"); + } + + #[test] + fn renewed_success_copy_is_visible_and_stable() { + assert_eq!( + super::RENEWED, + "Identity successfully renewed on this device." + ); } } From 4f2e6aaf16101d5ca6bc552a1d3a35bfb08377e1 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:38:48 +0800 Subject: [PATCH 17/27] feat(identity): align default and query behavior --- genmeta-identity/src/cli.rs | 33 ++++----- .../src/cli/flow/default_identity.rs | 70 ++++++++++++++++++- genmeta-identity/src/cli/flow/output.rs | 9 +++ 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 7421a0a..c2b53fb 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -671,24 +671,25 @@ mod tests { #[tokio::test] async fn default_reports_unsaved_identity_non_interactively() { - let home_path = unique_test_home_path("default-unsaved"); - let dhttp_home = DhttpHome::new(home_path); - let command = Default { - name: Some("alice.smith".to_string()), - verbose: false, - force: false, - }; + for force in [false, true] { + let home_path = unique_test_home_path("default-unsaved"); + let dhttp_home = DhttpHome::new(home_path); + let command = Default { + name: Some("alice.smith".to_string()), + verbose: false, + force, + }; - let error = command - .run(&dhttp_home, HomeScope::User, &dummy_cert_server()) - .await - .unwrap_err(); - let rendered = error.to_string(); + let error = command + .run(&dhttp_home, HomeScope::User, &dummy_cert_server()) + .await + .unwrap_err(); - assert_eq!( - rendered, - "Failed to set default identity: alice.smith not found!" - ); + assert_eq!( + error.to_string(), + "Failed to set default identity: alice.smith not found!" + ); + } } #[tokio::test] diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index 0b09fe7..9f91a3f 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -13,6 +13,20 @@ fn default_not_found_message(short_name: &str) -> String { format!("Failed to set default identity: {short_name} not found!") } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DefaultSetDecision { + Allow, + RequireForce, +} + +fn default_set_decision(summary: &local::LocalIdentitySummary, force: bool) -> DefaultSetDecision { + if summary.status.is_ready() || force { + DefaultSetDecision::Allow + } else { + DefaultSetDecision::RequireForce + } +} + async fn set_default_summary( dhttp_home: &DhttpHome, current_config: Option, @@ -73,7 +87,7 @@ pub(crate) async fn run( whatever!("{}", default_not_found_message(target.short_name())); }; - if !summary.status.is_ready() && !command.force { + if default_set_decision(&summary, command.force) == DefaultSetDecision::RequireForce { whatever!( "{} is {} and cannot be set as the default identity without --force", summary.target.short_name(), @@ -86,7 +100,26 @@ pub(crate) async fn run( #[cfg(test)] mod tests { - use super::default_not_found_message; + use std::path::PathBuf; + + use super::{DefaultSetDecision, default_not_found_message, default_set_decision}; + use crate::cli::flow::{ + local::{IdentityUsage, LocalIdentityStatus, LocalIdentitySummary}, + target::IdentityTarget, + }; + + fn summary(status: LocalIdentityStatus) -> LocalIdentitySummary { + LocalIdentitySummary { + target: IdentityTarget::parse("alice.smith").unwrap(), + usage: Some(IdentityUsage::BothClientAndServer), + sequence: Some(0), + valid_from: Some(1_700_000_000), + expires_at: Some(1_900_000_000), + status, + dir: PathBuf::from("/tmp/alice.smith"), + is_default: false, + } + } #[test] fn missing_default_target_matches_the_edited_document() { @@ -95,4 +128,37 @@ mod tests { "Failed to set default identity: alice.smith not found!" ); } + + #[test] + fn default_setter_requires_force_for_every_nonready_status() { + let ready = summary(LocalIdentityStatus::Ready { + expires_at: 1_900_000_000, + }); + assert_eq!( + default_set_decision(&ready, false), + DefaultSetDecision::Allow + ); + + for nonready in [ + LocalIdentityStatus::Expired { + expired_at: 1_700_000_000, + }, + LocalIdentityStatus::Invalid { + detail: "certificate is invalid".to_string(), + }, + LocalIdentityStatus::Incomplete { + detail: "private key is missing".to_string(), + }, + ] { + let summary = summary(nonready); + assert_eq!( + default_set_decision(&summary, false), + DefaultSetDecision::RequireForce + ); + assert_eq!( + default_set_decision(&summary, true), + DefaultSetDecision::Allow + ); + } + } } diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index dc28cc5..0b56c8a 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -433,6 +433,15 @@ mod tests { assert!(rendered.contains("\n\n- tablet.alice.smith [incomplete]:")); } + #[test] + fn verbose_default_info_and_verbose_list_share_the_same_block() { + let summary = near_expiry(); + let expected = format_info(&summary, NOW, false); + let inventory = build_inventory(vec![summary]); + + assert_eq!(render_verbose_inventory(&inventory, NOW, false), expected); + } + #[test] fn saved_choice_label_remains_stable() { let profile = ready_default(); From 886601fdb0367255e0a369c5ea7a974568d09ccb Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 01:49:40 +0800 Subject: [PATCH 18/27] refactor(identity): bound apply attempt state --- genmeta-identity/src/cli/flow/apply.rs | 49 +++++++++++++++++--------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index faa621d..f29b186 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -289,20 +289,33 @@ fn print_auth_rejection(name: &str, error: &crate::cert_server::Error) { #[derive(Debug)] enum ApplyAttempt { - Certificate(CertificateDetail), + Certificate(Box), ReplanMissing, } -async fn attempt_apply_with_candidates( - command: &Apply, - dhttp_home: &DhttpHome, - cert_server: &CertServer, - resolved: &mut ResolvedApplyTarget, +struct ApplyAttemptContext<'a> { + command: &'a Apply, + dhttp_home: &'a DhttpHome, + cert_server: &'a CertServer, kind: IdentityKind, - device_name: &str, + device_name: &'a str, interactive: bool, - key_material: &mut super::key_material::LazyKeyMaterial, + key_material: &'a mut super::key_material::LazyKeyMaterial, +} + +async fn attempt_apply_with_candidates( + resolved: &mut ResolvedApplyTarget, + context: ApplyAttemptContext<'_>, ) -> Result { + let ApplyAttemptContext { + command, + dhttp_home, + cert_server, + kind, + device_name, + interactive, + key_material, + } = context; let specs = super::auth_plan::candidate_specs(&resolved.target, resolved.remote); let loader = super::auth_plan::HomeExactIdentityLoader::new(dhttp_home); let mut candidates = super::auth_plan::AuthCandidateRunner::new(loader, specs); @@ -344,7 +357,7 @@ async fn attempt_apply_with_candidates( ) .await { - Ok(detail) => return Ok(ApplyAttempt::Certificate(detail)), + Ok(detail) => return Ok(ApplyAttempt::Certificate(Box::new(detail))), Err(RequestFailure::Local(error)) => return Err(error), Err(RequestFailure::Remote(error)) => { match crate::auth::classify_identity_attempt(&error) { @@ -419,7 +432,7 @@ async fn attempt_apply_with_candidates( } Err(RequestFailure::Remote(error)) => return Err(error.into()), }; - return Ok(ApplyAttempt::Certificate(detail)); + return Ok(ApplyAttempt::Certificate(Box::new(detail))); } CandidateEvent::Exhausted => { if let Some(error) = last_rejection { @@ -496,14 +509,16 @@ pub(crate) async fn run( loop { match attempt_apply_with_candidates( - command, - dhttp_home, - cert_server, &mut resolved, - kind, - &device_name, - interactive, - &mut key_material, + ApplyAttemptContext { + command, + dhttp_home, + cert_server, + kind, + device_name: &device_name, + interactive, + key_material: &mut key_material, + }, ) .await? { From 69b6cee71081cebdd5518a662133027f4d0322a0 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 02:29:45 +0800 Subject: [PATCH 19/27] fix(identity): preserve noninteractive apply lifecycle --- genmeta-identity/src/cli/flow/apply.rs | 41 +++++++++++++++++++++-- genmeta-identity/src/cli/flow/progress.rs | 17 ++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index f29b186..f08fbea 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -287,6 +287,21 @@ fn print_auth_rejection(name: &str, error: &crate::cert_server::Error) { )); } +fn should_register_with_parent(target: &IdentityTarget, remote: RemoteTargetState) -> bool { + target.level() == IdentityLevel::SubIdentity + && matches!( + remote, + RemoteTargetState::Missing | RemoteTargetState::Unknown + ) +} + +fn should_register_with_email(remote: RemoteTargetState) -> bool { + matches!( + remote, + RemoteTargetState::Missing | RemoteTargetState::Unknown + ) +} + #[derive(Debug)] enum ApplyAttempt { Certificate(Box), @@ -330,7 +345,7 @@ async fn attempt_apply_with_candidates( short_name, full_name, } => { - if resolved.remote == RemoteTargetState::Missing { + if should_register_with_parent(&resolved.target, resolved.remote) { match register_with_parent(cert_server, &resolved.target, &full_name).await { Ok(outcome) => { print_new_name(outcome); @@ -386,7 +401,7 @@ async fn attempt_apply_with_candidates( ) .await?; - if resolved.remote == RemoteTargetState::Missing { + if should_register_with_email(resolved.remote) { let outcome = register_with_email(cert_server, &resolved.target, &token, interactive) .await?; @@ -643,4 +658,26 @@ mod tests { ] ); } + + #[test] + fn explicit_targets_register_only_before_the_proof_that_can_own_them() { + let root = IdentityTarget::parse("alice.smith").unwrap(); + let child = IdentityTarget::parse("phone.alice.smith").unwrap(); + + assert!(!should_register_with_parent( + &root, + RemoteTargetState::Unknown + )); + assert!(should_register_with_parent( + &child, + RemoteTargetState::Unknown + )); + assert!(!should_register_with_parent( + &child, + RemoteTargetState::Exists + )); + assert!(should_register_with_email(RemoteTargetState::Unknown)); + assert!(should_register_with_email(RemoteTargetState::Missing)); + assert!(!should_register_with_email(RemoteTargetState::Exists)); + } } diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index 57f679f..744b8fc 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -1,4 +1,7 @@ -use std::future::Future; +use std::{ + future::Future, + io::{self, IsTerminal}, +}; use tracing::{Instrument, info_span}; use tracing_indicatif::span_ext::IndicatifSpanExt; @@ -47,7 +50,7 @@ pub(crate) async fn run( span.pb_start(); let result = future.instrument(span.clone()).await; if result.is_ok() { - span.pb_set_finish_message(copy.success); + retain_success(&span, copy.success); } drop(span); result @@ -65,12 +68,20 @@ pub(crate) fn run_sync( operation() }; if result.is_ok() { - span.pb_set_finish_message(copy.success); + retain_success(&span, copy.success); } drop(span); result } +fn retain_success(span: &tracing::Span, success: &'static str) { + if io::stderr().is_terminal() { + span.pb_set_finish_message(success); + } else { + super::transcript::print_line(success); + } +} + #[cfg(test)] mod tests { use std::sync::{ From ebcead33c7b99390c6fcce09d969d2f8f50b49fb Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 02:30:09 +0800 Subject: [PATCH 20/27] docs(identity): record the consistent CLI lifecycle --- CHANGELOG.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 545459b..c77a339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `genmeta identity apply [name]` is now the single create-or-update flow; the former `identity create` command has been removed. -- Missing eligible sub-identities now enter registration naturally during - `identity apply`, without a separate registration flag. Names entered by an - interactive prompt receive an early certserver availability check. +- Existing identities, new root identities, and new direct sub-identities now + share the same `identity apply` lifecycle. Names entered by an interactive + prompt receive an early local and certserver availability check. - Bare `genmeta identity renew` targets the configured default identity, and renew no longer accepts `--default`. - Identity authentication selects the first usable proof from the target, @@ -27,9 +27,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 failures remain terminal instead of changing proof. - Verification-code recovery keeps attempt limits at the code prompt without resending automatically, and treats blocked accounts as terminal. -- Compact identity output uses `(default)` and retains abnormal states. Detail - output keeps the chain in the summary, then shows `Issuer`, `Valid from`, - `Expires`/`Expired`, `dir`, and an invalid/incomplete reason when available. +- `apply`, `renew`, and `default` now give `--force` command-specific meanings: + local replacement approval, recoverable renewal preflight, and non-ready + default selection respectively. Force never bypasses authentication, + certificate validation, payment confirmation, or a missing local target. +- Removed legacy public identity flow switches, including `--auth`, + `--register-if-missing`, `--replace-local`, `--send-code`, and + `--allow-nonready`. +- Compact identity output uses `(default)` and retains abnormal states. The + shared detail renderer reports usage, sequence, profile directory, validity, + reason, and warning fields; `default -v`, `info`, and `list -v` use the same + format. +- Renewal preserves the original certificate-chain kind and sequence, performs + strict local preflight, installs validated material transactionally, and + prints a visible success result. + +### Fixed + +- Identity authentication now degrades only after unusable local material or + explicit authorization rejection; transport, parsing, quota, and unknown + failures remain terminal. +- Generated private keys stay in memory until a validated certificate can be + installed, and local certificate/key replacement rolls back on commit + failure without deleting service files. ## [0.8.0-beta.3] - 2026-07-09 From b1dccf2af1dfedc87f2463dfaacac4e94b50d9b8 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 14 Jul 2026 15:01:30 +0800 Subject: [PATCH 21/27] fix(identity): harden reviewed lifecycle boundaries --- genmeta-identity/src/auth.rs | 51 +++++- genmeta-identity/src/cert_server.rs | 122 +++++++++++++- genmeta-identity/src/checkout.rs | 149 ++++++++++++------ genmeta-identity/src/cli/flow/apply.rs | 51 ++++-- genmeta-identity/src/cli/flow/auth_plan.rs | 11 +- genmeta-identity/src/cli/flow/email.rs | 58 ++++++- genmeta-identity/src/cli/flow/install.rs | 12 ++ genmeta-identity/src/cli/flow/local.rs | 104 +++++++++++- genmeta-identity/src/cli/flow/recovery.rs | 1 + genmeta-identity/src/cli/flow/registration.rs | 79 +++++++--- genmeta-identity/src/cli/flow/renew.rs | 31 +++- genmeta-identity/src/cli/flow/welcome.rs | 49 +++++- genmeta-identity/src/cli/validator.rs | 6 +- 13 files changed, 612 insertions(+), 112 deletions(-) diff --git a/genmeta-identity/src/auth.rs b/genmeta-identity/src/auth.rs index 5f97a9b..716aa68 100644 --- a/genmeta-identity/src/auth.rs +++ b/genmeta-identity/src/auth.rs @@ -9,18 +9,29 @@ pub(crate) fn classify_identity_attempt( error: &crate::cert_server::Error, ) -> AuthAttemptDisposition { match error { - crate::cert_server::Error::Api { code, .. } => match code.as_str() { - "unauthorized" | "domain_forbidden" => AuthAttemptDisposition::TryNext, - "domain_not_found" => AuthAttemptDisposition::ReplanMissingTarget, + crate::cert_server::Error::Api { status, code, .. } => match (*status, code.as_str()) { + (reqwest::StatusCode::UNAUTHORIZED, "unauthorized") + | (reqwest::StatusCode::FORBIDDEN, "domain_forbidden") => { + AuthAttemptDisposition::TryNext + } + (reqwest::StatusCode::NOT_FOUND, "domain_not_found") => { + AuthAttemptDisposition::ReplanMissingTarget + } _ => AuthAttemptDisposition::Terminal, }, + crate::cert_server::Error::DhttpEndpointFromProfile { + source: dhttp::endpoint::LoadEndpointFromPathError::LoadIdentity { .. }, + } => AuthAttemptDisposition::TryNext, crate::cert_server::Error::Request { .. } | crate::cert_server::Error::DhttpEndpoint { .. } | crate::cert_server::Error::DhttpRequest { .. } | crate::cert_server::Error::DhttpRead { .. } | crate::cert_server::Error::IdentityFallbackUnavailable | crate::cert_server::Error::Json { .. } - | crate::cert_server::Error::Whatever { .. } => AuthAttemptDisposition::Terminal, + | crate::cert_server::Error::Whatever { .. } + | crate::cert_server::Error::DhttpEndpointFromProfile { .. } => { + AuthAttemptDisposition::Terminal + } } } @@ -57,6 +68,13 @@ mod tests { classify_identity_attempt(&api(reqwest::StatusCode::CONFLICT, "future_code")), AuthAttemptDisposition::Terminal ); + assert_eq!( + classify_identity_attempt(&api( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "unauthorized" + )), + AuthAttemptDisposition::Terminal + ); } #[test] @@ -72,5 +90,30 @@ mod tests { )), AuthAttemptDisposition::Terminal ); + assert_eq!( + classify_identity_attempt(&api( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "domain_not_found" + )), + AuthAttemptDisposition::Terminal + ); + } + + #[tokio::test] + async fn selected_profile_load_failure_can_try_the_next_proof() { + let missing = std::env::temp_dir().join(format!( + "genmeta-auth-missing-profile-{}", + std::process::id() + )); + let source = match dhttp::endpoint::Endpoint::load_from(missing).await { + Ok(_) => panic!("missing identity profile unexpectedly loaded"), + Err(source) => source, + }; + let error = crate::cert_server::Error::DhttpEndpointFromProfile { source }; + + assert_eq!( + classify_identity_attempt(&error), + AuthAttemptDisposition::TryNext + ); } } diff --git a/genmeta-identity/src/cert_server.rs b/genmeta-identity/src/cert_server.rs index 937d5cc..c7d6e1f 100644 --- a/genmeta-identity/src/cert_server.rs +++ b/genmeta-identity/src/cert_server.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use reqwest::header; use serde::{Deserialize, de::DeserializeOwned}; @@ -21,6 +21,10 @@ pub enum Error { DhttpEndpoint { source: dhttp::endpoint::LoadEndpointError, }, + #[snafu(display("failed to load DHTTP identity endpoint from selected profile"))] + DhttpEndpointFromProfile { + source: dhttp::endpoint::LoadEndpointFromPathError, + }, #[snafu(display("failed to send DHTTP identity request"))] DhttpRequest { source: dhttp::endpoint::client::RequestError, @@ -415,6 +419,15 @@ impl CertServer { Ok(Arc::new(endpoint)) } + async fn identity_endpoint_from_profile( + profile_dir: &Path, + ) -> Result, Error> { + let endpoint = dhttp::endpoint::Endpoint::load_from(profile_dir) + .await + .context(DhttpEndpointFromProfileSnafu)?; + Ok(Arc::new(endpoint)) + } + pub fn new(base_url: impl Into>) -> Result { let root_cert = reqwest::Certificate::from_pem(dhttp::trust::DHTTP_ROOT_CA) .whatever_context("failed to parse DHTTP root certificate")?; @@ -453,6 +466,29 @@ impl CertServer { parse_dhttp_response(response).await } + async fn send_identity_profile_json( + &self, + profile_dir: &Path, + method: http::Method, + path: &str, + body: serde_json::Value, + ) -> Result { + let endpoint = Self::identity_endpoint_from_profile(profile_dir).await?; + let uri = format!("{}{}", self.base_url, path); + let response = endpoint + .new_request() + .method(method) + .uri(uri) + .header( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ) + .body(body.to_string()) + .await + .context(DhttpRequestSnafu)?; + parse_dhttp_response(response).await + } + async fn get_identity( &self, identity_domain: &str, @@ -634,6 +670,26 @@ impl CertServer { .await } + pub(crate) async fn create_subdomain_with_identity_profile( + &self, + profile_dir: &Path, + parent: &str, + label: &str, + expected_amount: Option, + ) -> Result { + self.send_identity_profile_json( + profile_dir, + http::Method::POST, + "/v2/subdomain", + json!({ + "parent": parent, + "label": label, + "expected_amount": expected_amount, + }), + ) + .await + } + pub async fn issue_cert( &self, access_token: &str, @@ -698,6 +754,30 @@ impl CertServer { .await } + pub(crate) async fn issue_cert_with_identity_profile( + &self, + profile_dir: &Path, + domain: &str, + kind: &str, + sequence: Option, + device_name: &str, + csr_pem: &str, + ) -> Result { + self.send_identity_profile_json( + profile_dir, + http::Method::POST, + "/v2/cert", + json!({ + "domain": domain, + "kind": kind, + "sequence": sequence, + "device_name": device_name, + "csr": csr_pem, + }), + ) + .await + } + pub async fn renew_cert( &self, access_token: &str, @@ -747,6 +827,30 @@ impl CertServer { .await } + pub(crate) async fn renew_cert_with_identity_profile( + &self, + profile_dir: &Path, + domain: &str, + kind: &str, + sequence: u32, + device_name: Option<&str>, + csr_pem: &str, + ) -> Result { + self.send_identity_profile_json( + profile_dir, + http::Method::POST, + "/v2/cert/renew", + json!({ + "domain": domain, + "kind": kind, + "sequence": sequence, + "device_name": device_name, + "csr": csr_pem, + }), + ) + .await + } + pub async fn list_certs( &self, access_token: &str, @@ -831,6 +935,14 @@ impl Error { pub fn is_api_code(&self, expected: &str) -> bool { matches!(self.api_code(), Some(code) if code == expected) } + + pub fn is_api(&self, expected_status: reqwest::StatusCode, expected_code: &str) -> bool { + matches!( + self, + Self::Api { status, code, .. } + if *status == expected_status && code == expected_code + ) + } } #[cfg(test)] @@ -867,6 +979,14 @@ mod tests { assert_eq!(error.api_code(), Some("starter_domain_limit_reached")); assert!(error.is_api_code("starter_domain_limit_reached")); assert!(!error.is_api_code("domain_not_found")); + assert!(error.is_api( + reqwest::StatusCode::CONFLICT, + "starter_domain_limit_reached" + )); + assert!(!error.is_api( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "starter_domain_limit_reached" + )); } #[test] diff --git a/genmeta-identity/src/checkout.rs b/genmeta-identity/src/checkout.rs index a622409..6115ee9 100644 --- a/genmeta-identity/src/checkout.rs +++ b/genmeta-identity/src/checkout.rs @@ -1,4 +1,4 @@ -use std::io::IsTerminal; +use std::time::Duration; use qrcode::{QrCode, render::unicode, types::QrError}; use snafu::FromString; @@ -46,10 +46,7 @@ pub fn classify_checkout(response: &CreateDomainResponse) -> CheckoutState { pub fn print_payment_instructions(response: &CreateDomainResponse) { if let Some(payment_entry) = &response.payment_entry { - let include_qr = std::io::stderr().is_terminal(); - let block = payment_instruction_block(&payment_entry.url, include_qr) - .or_else(|_| payment_instruction_block(&payment_entry.url, false)) - .expect("rendering payment instructions without a QR code cannot fail"); + let block = payment_instruction_block_or_link(&payment_entry.url); transcript::print_err_block(&block); } } @@ -63,13 +60,9 @@ fn render_terminal_qr(url: &str) -> Result { .build()) } -pub(crate) fn payment_instruction_block(url: &str, include_qr: bool) -> Result { - let mut block = String::new(); - - if include_qr { - block.push_str(render_terminal_qr(url)?.trim_end()); - block.push_str("\n\n"); - } +pub(crate) fn payment_instruction_block(url: &str) -> Result { + let mut block = render_terminal_qr(url)?.trim_end().to_string(); + block.push_str("\n\n"); block.push_str("[!] Please complete your payment within 15 minutes.\n"); block.push_str(" Open the link below, or scan the QR code above\n\n"); @@ -78,37 +71,79 @@ pub(crate) fn payment_instruction_block(url: &str, include_qr: bool) -> Result String { + payment_instruction_block(url).unwrap_or_else(|_| payment_link_only_instruction_block(url)) +} + +fn payment_link_only_instruction_block(url: &str) -> String { + format!( + "[!] Please complete your payment within 15 minutes.\n Open the link below\n\n Link: {url}" + ) +} + pub async fn wait_for_checkout_completion( cert_server: &crate::cert_server::CertServer, checkout_token: &str, ) -> Result { - crate::cli::flow::progress::run(crate::cli::flow::progress::WAIT_FOR_PAYMENT, async { - loop { - let response = cert_server.get_checkout(checkout_token).await?; - match classify_checkout(&response) { - CheckoutState::Completed => return Ok(response), - CheckoutState::Expired => { - return Err(crate::cert_server::Error::without_source( - "checkout expired before payment was completed".to_string(), - )); - } - CheckoutState::Cancelled => { - return Err(crate::cert_server::Error::without_source( - "checkout was cancelled before payment was completed".to_string(), - )); - } - CheckoutState::Failed => { - return Err(crate::cert_server::Error::without_source( - "checkout failed or returned an unsupported terminal state".to_string(), - )); - } - CheckoutState::Pending => { - tokio::time::sleep(std::time::Duration::from_secs(3)).await + wait_for_checkout_completion_until( + cert_server, + checkout_token, + crate::cli::flow::local::now_unix_timestamp().saturating_add(15 * 60), + ) + .await +} + +pub(crate) async fn wait_for_checkout_completion_until( + cert_server: &crate::cert_server::CertServer, + checkout_token: &str, + expires_at: i64, +) -> Result { + let wait = checkout_wait_duration(expires_at, crate::cli::flow::local::now_unix_timestamp()); + if wait.is_zero() { + return Err(checkout_expired_error()); + } + + let poll = + crate::cli::flow::progress::run(crate::cli::flow::progress::WAIT_FOR_PAYMENT, async { + loop { + let response = cert_server.get_checkout(checkout_token).await?; + match classify_checkout(&response) { + CheckoutState::Completed => return Ok(response), + CheckoutState::Expired => return Err(checkout_expired_error()), + CheckoutState::Cancelled => { + return Err(crate::cert_server::Error::without_source( + "checkout was cancelled before payment was completed".to_string(), + )); + } + CheckoutState::Failed => { + return Err(crate::cert_server::Error::without_source( + "checkout failed or returned an unsupported terminal state".to_string(), + )); + } + CheckoutState::Pending => tokio::time::sleep(Duration::from_secs(3)).await, } } - } - }) - .await + }); + match tokio::time::timeout(wait, poll).await { + Ok(result) => result, + Err(_) => Err(checkout_expired_error()), + } +} + +fn checkout_wait_duration(expires_at: i64, now: i64) -> Duration { + const MAX_WAIT_SECONDS: u64 = 15 * 60; + + Duration::from_secs( + u64::try_from(expires_at.saturating_sub(now)) + .unwrap_or(0) + .min(MAX_WAIT_SECONDS), + ) +} + +fn checkout_expired_error() -> crate::cert_server::Error { + crate::cert_server::Error::without_source( + "checkout expired before payment was completed".to_string(), + ) } #[cfg(test)] @@ -150,21 +185,18 @@ mod tests { } #[test] - fn payment_block_omits_only_the_qr_on_a_non_terminal_stream() { - let block = - payment_instruction_block("https://pay.example.test/checkout/ckt_123", false).unwrap(); + fn payment_block_always_keeps_the_qr_above_the_link() { + let block = payment_instruction_block("https://pay.example.test/checkout/ckt_123").unwrap(); - assert!(!block.contains(['â–€', 'â–„', 'â–ˆ'])); - assert_eq!( - block, - "[!] Please complete your payment within 15 minutes.\n Open the link below, or scan the QR code above\n\n Link: https://pay.example.test/checkout/ckt_123" - ); + assert!(block.contains(['â–€', 'â–„', 'â–ˆ'])); + assert!(block.contains( + "Open the link below, or scan the QR code above\n\n Link: https://pay.example.test/checkout/ckt_123" + )); } #[test] fn payment_block_places_compact_qr_before_the_link() { - let rendered = - payment_instruction_block("https://pay.example.test/checkout", true).unwrap(); + let rendered = payment_instruction_block("https://pay.example.test/checkout").unwrap(); let qr = rendered.find('â–ˆ').unwrap(); let notice = rendered .find("[!] Please complete your payment within 15 minutes.") @@ -178,6 +210,16 @@ mod tests { assert!(!rendered.contains("Open link:")); } + #[test] + fn payment_block_falls_back_to_the_link_only_when_qr_encoding_is_impossible() { + let url = format!("https://pay.example.test/{}", "x".repeat(10_000)); + let block = payment_instruction_block_or_link(&url); + + assert!(!block.contains(['â–€', 'â–„', 'â–ˆ'])); + assert!(block.contains("Open the link below\n")); + assert!(block.contains(&format!("Link: {url}"))); + } + #[test] fn unknown_invoice_state_is_a_terminal_failure() { let response: CreateDomainResponse = serde_json::from_str( @@ -187,4 +229,17 @@ mod tests { assert_eq!(classify_checkout(&response), CheckoutState::Failed); } + + #[test] + fn checkout_wait_uses_the_server_deadline_with_a_fifteen_minute_cap() { + assert_eq!(checkout_wait_duration(999, 1_000), Duration::ZERO); + assert_eq!( + checkout_wait_duration(1_030, 1_000), + Duration::from_secs(30) + ); + assert_eq!( + checkout_wait_duration(3_000, 1_000), + Duration::from_secs(15 * 60) + ); + } } diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index f08fbea..785e905 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -1,4 +1,4 @@ -use std::io::IsTerminal; +use std::{io::IsTerminal, path::Path}; use dhttp::home::{DhttpHome, HomeScope}; use snafu::{FromString, whatever}; @@ -58,7 +58,9 @@ async fn prompt_apply_target_with_online_validation( .await; let (local, response) = match inspected { Ok(inspected) => inspected, - Err(Error::CertServer { source }) if source.is_api_code("domain_invalid") => { + Err(Error::CertServer { source }) + if source.is_api(reqwest::StatusCode::BAD_REQUEST, "domain_invalid") => + { crate::cli::flow::transcript::print_err_block( interactive_name_unavailable_message(), ); @@ -150,7 +152,7 @@ async fn resolve_kind(command: &Apply) -> Result { #[derive(Debug, Clone, Copy)] enum CertificateProof<'a> { AccessToken(&'a str), - Identity(&'a str), + IdentityProfile(&'a Path), } #[derive(Debug)] @@ -186,10 +188,10 @@ async fn request_certificate( ) .await } - CertificateProof::Identity(identity) => { + CertificateProof::IdentityProfile(profile_dir) => { cert_server - .issue_cert_with_identity( - identity, + .issue_cert_with_identity_profile( + profile_dir, target.full_name(), kind.as_str(), None, @@ -252,7 +254,7 @@ async fn register_with_email( async fn register_with_parent( cert_server: &CertServer, target: &IdentityTarget, - parent_identity: &str, + parent_profile_dir: &Path, ) -> Result { if target.level() != IdentityLevel::SubIdentity { return Err(RegistrationError::MissingParent { @@ -264,7 +266,7 @@ async fn register_with_parent( super::registration::register_missing_child( &api, target, - RegistrationProof::ParentIdentity(parent_identity), + RegistrationProof::ParentIdentityProfile(parent_profile_dir), ) .await } @@ -287,8 +289,14 @@ fn print_auth_rejection(name: &str, error: &crate::cert_server::Error) { )); } -fn should_register_with_parent(target: &IdentityTarget, remote: RemoteTargetState) -> bool { - target.level() == IdentityLevel::SubIdentity +fn should_register_with_parent( + target: &IdentityTarget, + remote: RemoteTargetState, + candidate_full_name: &str, +) -> bool { + target + .parent() + .is_some_and(|parent| parent.as_full() == candidate_full_name) && matches!( remote, RemoteTargetState::Missing | RemoteTargetState::Unknown @@ -344,9 +352,10 @@ async fn attempt_apply_with_candidates( CandidateEvent::Identity { short_name, full_name, + profile_dir, } => { - if should_register_with_parent(&resolved.target, resolved.remote) { - match register_with_parent(cert_server, &resolved.target, &full_name).await { + if should_register_with_parent(&resolved.target, resolved.remote, &full_name) { + match register_with_parent(cert_server, &resolved.target, &profile_dir).await { Ok(outcome) => { print_new_name(outcome); resolved.remote = RemoteTargetState::Exists; @@ -364,7 +373,7 @@ async fn attempt_apply_with_candidates( match request_certificate( cert_server, - CertificateProof::Identity(&full_name), + CertificateProof::IdentityProfile(&profile_dir), &resolved.target, kind, device_name, @@ -660,21 +669,29 @@ mod tests { } #[test] - fn explicit_targets_register_only_before_the_proof_that_can_own_them() { + fn explicit_targets_register_only_with_the_direct_parent_proof() { let root = IdentityTarget::parse("alice.smith").unwrap(); let child = IdentityTarget::parse("phone.alice.smith").unwrap(); assert!(!should_register_with_parent( &root, - RemoteTargetState::Unknown + RemoteTargetState::Unknown, + "alice.smith.dhttp.net", + )); + assert!(!should_register_with_parent( + &child, + RemoteTargetState::Unknown, + "phone.alice.smith.dhttp.net", )); assert!(should_register_with_parent( &child, - RemoteTargetState::Unknown + RemoteTargetState::Unknown, + "alice.smith.dhttp.net", )); assert!(!should_register_with_parent( &child, - RemoteTargetState::Exists + RemoteTargetState::Exists, + "alice.smith.dhttp.net", )); assert!(should_register_with_email(RemoteTargetState::Unknown)); assert!(should_register_with_email(RemoteTargetState::Missing)); diff --git a/genmeta-identity/src/cli/flow/auth_plan.rs b/genmeta-identity/src/cli/flow/auth_plan.rs index e184ae0..37e9731 100644 --- a/genmeta-identity/src/cli/flow/auth_plan.rs +++ b/genmeta-identity/src/cli/flow/auth_plan.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::{collections::VecDeque, path::PathBuf}; use dhttp::{home::DhttpHome, name::DhttpName}; @@ -19,6 +19,7 @@ pub(crate) enum CandidateEvent { Identity { short_name: String, full_name: String, + profile_dir: PathBuf, }, Warning(String), Email, @@ -138,6 +139,7 @@ where return Ok(CandidateEvent::Identity { short_name: summary.target.short_name().to_string(), full_name: summary.target.full_name().to_string(), + profile_dir: summary.dir, }); } return Ok(CandidateEvent::Warning(self.warning_for(&summary))); @@ -271,7 +273,12 @@ mod tests { assert!(matches!( runner.next().await.unwrap(), - CandidateEvent::Identity { short_name, .. } if short_name == "phone.alice.smith" + CandidateEvent::Identity { + short_name, + profile_dir, + .. + } if short_name == "phone.alice.smith" + && profile_dir == std::path::Path::new("/tmp/phone.alice.smith") )); assert_eq!(runner.loader().requested(), ["phone.alice.smith"]); } diff --git a/genmeta-identity/src/cli/flow/email.rs b/genmeta-identity/src/cli/flow/email.rs index 01a77d9..2cc74d3 100644 --- a/genmeta-identity/src/cli/flow/email.rs +++ b/genmeta-identity/src/cli/flow/email.rs @@ -1,5 +1,7 @@ use std::fmt; +use snafu::FromString; + use super::recovery::{VerificationRecovery, classify_verify_submit_error}; use crate::{ cert_server::CertServer, @@ -43,9 +45,9 @@ impl fmt::Display for EmailSessionError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Cancelled => f.write_str("Email verification was cancelled."), - Self::NonInteractivePairRequired => f.write_str( - "Non-interactive email verification requires both --email and --verify-code.", - ), + Self::NonInteractivePairRequired => { + f.write_str("Email verification requires an interactive terminal for this command.") + } } } } @@ -53,8 +55,6 @@ impl fmt::Display for EmailSessionError { impl std::error::Error for EmailSessionError {} fn session_error(error: EmailSessionError) -> Error { - use snafu::FromString; - Error::without_source(error.to_string()) } @@ -194,6 +194,11 @@ where let (Some(email), Some(code)) = (initial_email, initial_verify_code) else { return Err(session_error(EmailSessionError::NonInteractivePairRequired)); }; + if !crate::cli::validator::is_valid_email(email) { + return Err(Error::without_source( + "Invalid email address. Please enter a valid email address.".to_string(), + )); + } return super::progress::run( super::progress::VERIFY_EMAIL, api.verify(&login, email, code), @@ -202,7 +207,9 @@ where .map_err(Error::from); } - let mut email = initial_email.map(ToOwned::to_owned); + let mut email = initial_email + .filter(|email| crate::cli::validator::is_valid_email(email)) + .map(ToOwned::to_owned); let mut verify_code = initial_verify_code.map(ToOwned::to_owned); let mut sent_to = initial_verify_code .is_some() @@ -514,6 +521,32 @@ mod tests { ); } + #[tokio::test] + async fn invalid_prefilled_email_returns_to_email_input_before_sending() { + let api = FakeEmailApi::accepting("token"); + let ui = ScriptedEmailUi::new([email("alice@example.test"), code("123456")]); + + let token = run_email_session( + &api, + &ui, + EmailLogin::Account, + Some("not-an-email"), + None, + true, + ) + .await + .unwrap(); + + assert_eq!(token, "token"); + assert_eq!( + api.calls(), + [ + "send:alice@example.test", + "verify-account:alice@example.test:123456" + ] + ); + } + #[tokio::test] async fn question_mark_keeps_resend_change_email_and_cancel() { let api = FakeEmailApi::accepting("token"); @@ -689,7 +722,18 @@ mod tests { (Some("a@b.test"), None), (None, Some("000000")), ] { - assert!(run_noninteractive_fixture(email, code).await.is_err()); + let error = match run_noninteractive_fixture(email, code).await { + Ok(_) => panic!("incomplete hidden test input unexpectedly authenticated"), + Err(error) => error, + }; + assert!( + !error.to_string().contains("--verify-code"), + "hidden test input leaked into public error copy: {error}" + ); + assert!( + error.to_string().contains("interactive terminal"), + "{error}" + ); } let api = run_noninteractive_fixture(Some("a@b.test"), Some("000000")) diff --git a/genmeta-identity/src/cli/flow/install.rs b/genmeta-identity/src/cli/flow/install.rs index 0310f73..8accde2 100644 --- a/genmeta-identity/src/cli/flow/install.rs +++ b/genmeta-identity/src/cli/flow/install.rs @@ -38,6 +38,8 @@ pub enum Error { "certificate response sequence {actual} does not match expected sequence {expected}" ))] ResponseSequence { expected: u32, actual: u32 }, + #[snafu(display("certificate response status {actual} is not active"))] + ResponseStatus { actual: String }, #[snafu(display("certificate response is missing DHTTP certificate metadata"))] MissingResponseMetadata, #[snafu(display("certificate response contains invalid DHTTP certificate metadata"))] @@ -140,6 +142,12 @@ fn validate_install_with_verifier( } ); } + ensure!( + detail.status == "active", + error::ResponseStatusSnafu { + actual: &detail.status, + } + ); let response_ski = detail .ski @@ -326,6 +334,10 @@ mod tests { assert_validation_error(&other_sequence_detail, &other_sequence, KEY, "sequence"); assert_validation_error(&detail(), &expected, OTHER_KEY, "private key"); + let mut revoked = detail(); + revoked.status = "revoked".to_string(); + assert_validation_error(&revoked, &expected, KEY, "status"); + let mut missing_response_ski = detail(); missing_response_ski.ski = None; assert_validation_error( diff --git a/genmeta-identity/src/cli/flow/local.rs b/genmeta-identity/src/cli/flow/local.rs index c7a6a0c..989dcf4 100644 --- a/genmeta-identity/src/cli/flow/local.rs +++ b/genmeta-identity/src/cli/flow/local.rs @@ -1,7 +1,8 @@ -use std::{collections::BTreeMap, path::PathBuf}; +use std::{collections::BTreeMap, path::PathBuf, time::Duration}; use dhttp::{home::DhttpHome, identity::extract_dhttp_subject_key_identifier, name::DhttpName}; use futures::TryStreamExt; +use rustls::pki_types::{CertificateDer, UnixTime}; use tokio::fs; use super::target::{IdentityLevel, IdentityTarget}; @@ -157,6 +158,16 @@ pub(crate) fn classify_status( detail: "certificate expiry is unavailable".to_string(), }; }; + let Some(valid_from) = assessment.valid_from else { + return LocalIdentityStatus::Invalid { + detail: "certificate validity start is unavailable".to_string(), + }; + }; + if valid_from > now_unix_timestamp { + return LocalIdentityStatus::Invalid { + detail: "certificate is not valid yet".to_string(), + }; + } if expires_at <= now_unix_timestamp { LocalIdentityStatus::Expired { @@ -488,6 +499,14 @@ async fn assess_profile( } } + if matches!(assessment.certificate, LocalIdentityMaterialState::Present) + && let (Some(valid_from), Some(expires_at)) = (assessment.valid_from, assessment.expires_at) + && !certificate_chain_is_trusted(&certs, valid_from, expires_at, now_unix_timestamp()) + { + assessment.certificate = + LocalIdentityMaterialState::Invalid("certificate chain is not trusted".to_string()); + } + assessment } @@ -526,6 +545,37 @@ fn private_key_state_from_error( } } +fn certificate_chain_is_trusted( + certs: &[CertificateDer<'_>], + valid_from: i64, + expires_at: i64, + now: i64, +) -> bool { + let Some(leaf) = certs.first() else { + return false; + }; + let Some(verify_at) = trust_check_timestamp(valid_from, expires_at, now) else { + return false; + }; + let Ok(verify_at) = u64::try_from(verify_at) else { + return false; + }; + dhttp::trust::dhttp_client_cert_verifier(dhttp::trust::ClientIdentityPolicy::Required) + .verify_client_cert( + leaf, + &certs[1..], + UnixTime::since_unix_epoch(Duration::from_secs(verify_at)), + ) + .is_ok() +} + +fn trust_check_timestamp(valid_from: i64, expires_at: i64, now: i64) -> Option { + if expires_at < valid_from { + return None; + } + Some(now.clamp(valid_from, expires_at)) +} + #[cfg(test)] mod tests { use std::{ @@ -589,6 +639,15 @@ mod tests { assert!(!is_near_expiry(valid_from, expires_at, 1_900)); } + #[test] + fn trust_check_keeps_zero_length_expired_certificates_classifiable() { + assert_eq!( + super::trust_check_timestamp(1_000, 1_000, 1_001), + Some(1_000) + ); + assert_eq!(super::trust_check_timestamp(1_001, 1_000, 1_001), None); + } + #[tokio::test] async fn exact_summary_never_falls_back_to_a_wildcard_profile() { let home_path = unique_test_home_path("exact-only"); @@ -609,6 +668,30 @@ mod tests { tokio::fs::remove_dir_all(home_path).await.unwrap(); } + #[tokio::test] + async fn untrusted_local_certificate_is_invalid_before_authentication() { + const CHAIN: &str = include_str!("../../../tests/fixtures/install/chain.pem"); + const KEY: &str = include_str!("../../../tests/fixtures/install/leaf.key"); + + let home_path = unique_test_home_path("untrusted-local-certificate"); + let home = DhttpHome::new(home_path); + let name = DhttpName::try_from("alice.smith").unwrap(); + home.identity_profile(name.borrow()) + .save_identity(CHAIN.as_bytes(), KEY.as_bytes()) + .await + .unwrap(); + + let summary = super::load_summary_exact(&home, name.borrow(), None) + .await + .unwrap(); + assert_eq!( + summary.status, + LocalIdentityStatus::Invalid { + detail: "certificate chain is not trusted".to_string(), + } + ); + } + #[test] fn classifies_ready_expired_incomplete_and_invalid() { let ready = classify_status( @@ -688,6 +771,25 @@ mod tests { detail: "certificate does not match local key".to_string(), } ); + + let not_valid_yet = classify_status( + &LocalIdentityAssessment { + certificate: LocalIdentityMaterialState::Present, + private_key: LocalIdentityMaterialState::Present, + certificate_target_matches: Some(true), + usage: Some(IdentityUsage::BothClientAndServer), + sequence: Some(0), + valid_from: Some(NOW + 1), + expires_at: Some(NOW + 300), + }, + NOW, + ); + assert_eq!( + not_valid_yet, + LocalIdentityStatus::Invalid { + detail: "certificate is not valid yet".to_string(), + } + ); } #[test] diff --git a/genmeta-identity/src/cli/flow/recovery.rs b/genmeta-identity/src/cli/flow/recovery.rs index 8af5952..5de22e1 100644 --- a/genmeta-identity/src/cli/flow/recovery.rs +++ b/genmeta-identity/src/cli/flow/recovery.rs @@ -41,6 +41,7 @@ pub(crate) fn classify_verify_submit_error( }, crate::cert_server::Error::Request { .. } | crate::cert_server::Error::DhttpEndpoint { .. } + | crate::cert_server::Error::DhttpEndpointFromProfile { .. } | crate::cert_server::Error::DhttpRequest { .. } | crate::cert_server::Error::DhttpRead { .. } | crate::cert_server::Error::IdentityFallbackUnavailable diff --git a/genmeta-identity/src/cli/flow/registration.rs b/genmeta-identity/src/cli/flow/registration.rs index e7b7ef7..da85554 100644 --- a/genmeta-identity/src/cli/flow/registration.rs +++ b/genmeta-identity/src/cli/flow/registration.rs @@ -1,4 +1,4 @@ -use std::io::IsTerminal; +use std::path::Path; use snafu::{FromString, Snafu}; @@ -14,15 +14,15 @@ use crate::{ }; fn is_domain_not_found(error: &crate::cert_server::Error) -> bool { - error.is_api_code("domain_not_found") + error.is_api(reqwest::StatusCode::NOT_FOUND, "domain_not_found") } fn is_domain_conflict(error: &crate::cert_server::Error) -> bool { - error.is_api_code("domain_conflict") + error.is_api(reqwest::StatusCode::CONFLICT, "domain_conflict") } fn is_subdomain_conflict(error: &crate::cert_server::Error) -> bool { - error.is_api_code("subdomain_conflict") || is_domain_conflict(error) + error.is_api(reqwest::StatusCode::CONFLICT, "subdomain_conflict") || is_domain_conflict(error) } fn missing_parent_identity_message(target: &IdentityTarget, parent: &str) -> String { @@ -35,7 +35,7 @@ fn missing_parent_identity_message(target: &IdentityTarget, parent: &str) -> Str #[derive(Debug, Clone, Copy)] pub(crate) enum RegistrationProof<'a> { AccessToken(&'a str), - ParentIdentity(&'a str), + ParentIdentityProfile(&'a Path), } pub(crate) trait RegistrationApi { @@ -56,7 +56,11 @@ pub(crate) trait RegistrationApi { target: &IdentityTarget, ) -> Result; - async fn checkout(&self, token: &str) -> Result; + async fn checkout( + &self, + token: &str, + expires_at: i64, + ) -> Result; } pub(crate) trait RegistrationUi { @@ -79,9 +83,6 @@ pub(crate) enum RegistrationError { #[snafu(transparent)] Prompt { source: prompt::Error }, - #[snafu(display("failed to render the payment QR code"))] - PaymentQr { source: qrcode::types::QrError }, - #[snafu(display("identity registration was declined"))] Declined, @@ -174,17 +175,33 @@ impl RegistrationApi for CertServerRegistrationApi<'_> { .create_subdomain(token, parent.as_full(), label, None) .await? } - RegistrationProof::ParentIdentity(identity) => { + RegistrationProof::ParentIdentityProfile(profile_dir) => { self.cert_server - .create_subdomain_with_identity(identity, parent.as_full(), label, None) + .create_subdomain_with_identity_profile( + profile_dir, + parent.as_full(), + label, + None, + ) .await? } }; Ok(response) } - async fn checkout(&self, token: &str) -> Result { - Ok(crate::checkout::wait_for_checkout_completion(self.cert_server, token).await?) + async fn checkout( + &self, + token: &str, + expires_at: i64, + ) -> Result { + Ok( + crate::checkout::wait_for_checkout_completion_until( + self.cert_server, + token, + expires_at, + ) + .await?, + ) } } @@ -205,9 +222,7 @@ impl RegistrationUi for InquireRegistrationUi { } fn print_payment(&self, url: &str) -> Result<(), RegistrationError> { - let include_qr = std::io::stderr().is_terminal(); - let block = crate::checkout::payment_instruction_block(url, include_qr) - .map_err(|source| RegistrationError::PaymentQr { source })?; + let block = crate::checkout::payment_instruction_block_or_link(url); crate::cli::flow::transcript::print_err_block(&block); Ok(()) } @@ -276,7 +291,9 @@ pub(crate) async fn register_missing_root( } ui.print_payment(&payment_entry.url)?; - let completed = api.checkout(&payment_entry.checkout_token).await?; + let completed = api + .checkout(&payment_entry.checkout_token, payment_entry.expires_at) + .await?; if crate::checkout::classify_checkout(&completed) != crate::checkout::CheckoutState::Completed { return Err(RegistrationError::CheckoutNotCompleted); } @@ -401,7 +418,7 @@ mod tests { ) -> Result { self.calls.lock().unwrap().push(match proof { RegistrationProof::AccessToken(_) => "register-child:token", - RegistrationProof::ParentIdentity(_) => "register-child:parent", + RegistrationProof::ParentIdentityProfile(_) => "register-child:parent", }); Ok(serde_json::from_value(serde_json::json!({ "domain": target.full_name(), @@ -415,7 +432,11 @@ mod tests { .unwrap()) } - async fn checkout(&self, _token: &str) -> Result { + async fn checkout( + &self, + _token: &str, + _expires_at: i64, + ) -> Result { self.calls.lock().unwrap().push("checkout"); let mut completed = self.created.clone(); completed.next_action = "completed".to_string(); @@ -534,7 +555,7 @@ mod tests { register_missing_child( &parent_api, &child, - RegistrationProof::ParentIdentity("alice.smith.dhttp.net") + RegistrationProof::ParentIdentityProfile(std::path::Path::new("/tmp/alice.smith")) ) .await .unwrap(), @@ -553,4 +574,22 @@ mod tests { "Cannot register phone.alice.smith because its parent identity, alice.smith, does not exist." ); } + + #[test] + fn server_failures_cannot_be_reclassified_as_registration_state() { + let conflict = crate::cert_server::Error::Api { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + code: "domain_conflict".to_string(), + message: "internal server error".to_string(), + }; + let missing = crate::cert_server::Error::Api { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + code: "domain_not_found".to_string(), + message: "internal server error".to_string(), + }; + + assert!(!is_domain_conflict(&conflict)); + assert!(!is_subdomain_conflict(&conflict)); + assert!(!is_domain_not_found(&missing)); + } } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index ff7d4df..7789648 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -1,4 +1,4 @@ -use std::io::IsTerminal; +use std::{io::IsTerminal, path::Path}; use dhttp::{ certificate::CertificateChainKind, @@ -111,7 +111,7 @@ async fn resolve_preflight( #[derive(Debug, Clone, Copy)] enum RenewProof<'a> { AccessToken(&'a str), - Identity(&'a str), + IdentityProfile(&'a Path), } #[derive(Debug)] @@ -146,10 +146,10 @@ async fn request_renewal( ) .await } - RenewProof::Identity(identity) => { + RenewProof::IdentityProfile(profile_dir) => { cert_server - .renew_cert_with_identity( - identity, + .renew_cert_with_identity_profile( + profile_dir, preflight.target.full_name(), preflight.kind.as_str(), preflight.sequence, @@ -165,7 +165,7 @@ async fn request_renewal( } fn renew_remote_error(error: crate::cert_server::Error, target: &str) -> Error { - if error.is_api_code("cert_sequence_not_found") { + if error.is_api(reqwest::StatusCode::NOT_FOUND, "cert_sequence_not_found") { let message = error.to_string(); return Error::with_source( Box::new(error), @@ -205,10 +205,11 @@ async fn request_with_candidates( CandidateEvent::Warning(warning) => super::transcript::print_warning(&warning), CandidateEvent::Identity { short_name, - full_name, + profile_dir, + .. } => match request_renewal( cert_server, - RenewProof::Identity(&full_name), + RenewProof::IdentityProfile(&profile_dir), preflight, device_name, key_material, @@ -421,6 +422,20 @@ mod tests { ); } + #[test] + fn server_failure_with_reused_code_does_not_offer_apply_recovery() { + let error = crate::cert_server::Error::Api { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + code: "cert_sequence_not_found".to_string(), + message: "internal server error".to_string(), + }; + + assert_eq!( + renew_remote_error(error, "alice.smith").to_string(), + "internal server error" + ); + } + #[test] fn renew_not_saved_message_matches_the_edited_document() { assert_eq!( diff --git a/genmeta-identity/src/cli/flow/welcome.rs b/genmeta-identity/src/cli/flow/welcome.rs index 3afbea7..3f72329 100644 --- a/genmeta-identity/src/cli/flow/welcome.rs +++ b/genmeta-identity/src/cli/flow/welcome.rs @@ -209,6 +209,14 @@ async fn path_exists(path: &Path) -> Result { } async fn write_new_file(path: &Path, contents: &[u8]) -> Result<(), WelcomeServiceError> { + write_new_file_transaction(path, contents, || Ok(())).await +} + +async fn write_new_file_transaction( + path: &Path, + contents: &[u8], + before_finish: impl FnOnce() -> std::io::Result<()>, +) -> Result<(), WelcomeServiceError> { let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); let mut file = options @@ -217,11 +225,25 @@ async fn write_new_file(path: &Path, contents: &[u8]) -> Result<(), WelcomeServi .context(welcome_service_error::CreateFileSnafu { path: path.to_path_buf(), })?; - file.write_all(contents) - .await - .context(welcome_service_error::WriteFileSnafu { + let write_result = async { + file.write_all(contents).await?; + before_finish()?; + file.flush().await + } + .await; + if let Err(source) = write_result { + drop(file); + if let Err(source) = fs::remove_file(path).await { + return Err(welcome_service_error::RollbackDeleteSnafu { + path: path.to_path_buf(), + } + .into_error(source)); + } + return Err(welcome_service_error::WriteFileSnafu { path: path.to_path_buf(), - })?; + } + .into_error(source)); + } Ok(()) } @@ -396,6 +418,25 @@ mod tests { assert!(!profile.server_conf_path().exists()); } + #[tokio::test] + async fn write_failure_removes_the_new_partial_file() { + let home_path = unique_test_home_path("partial-file-rollback"); + tokio::fs::create_dir_all(&home_path).await.unwrap(); + let path = home_path.join("server.conf"); + + let error = super::write_new_file_transaction(&path, b"partial", || { + Err(std::io::Error::other("injected write failure")) + }) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("welcome service file"), + "{error}" + ); + assert!(!path.exists(), "partial welcome file was not removed"); + } + #[test] fn welcome_success_copy_is_one_line() { assert_eq!( diff --git a/genmeta-identity/src/cli/validator.rs b/genmeta-identity/src/cli/validator.rs index 2639940..f9f58b9 100644 --- a/genmeta-identity/src/cli/validator.rs +++ b/genmeta-identity/src/cli/validator.rs @@ -46,9 +46,13 @@ impl StringValidator for KindValidator { #[derive(Debug, Clone, Copy)] pub struct EmailValidator; +pub(crate) fn is_valid_email(input: &str) -> bool { + input.contains('@') && input.contains('.') +} + impl StringValidator for EmailValidator { fn validate(&self, input: &str) -> Result { - if input.contains('@') && input.contains('.') { + if is_valid_email(input) { Ok(Validation::Valid) } else { Ok(validation_failed( From c5344a0ef53dd570da90f0ead09dd2cdbc9d6377 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 15 Jul 2026 01:16:13 +0800 Subject: [PATCH 22/27] feat(identity): add certificate audit writer --- Cargo.lock | 20 +- Cargo.toml | 1 + genmeta-identity/Cargo.toml | 1 + genmeta-identity/src/cli.rs | 2 + genmeta-identity/src/cli/certificate_log.rs | 196 ++++++++++++++++++++ 5 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 genmeta-identity/src/cli/certificate_log.rs diff --git a/Cargo.lock b/Cargo.lock index 232ce11..f924b2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,14 +1015,13 @@ dependencies = [ [[package]] name = "dhttp" version = "0.5.0-beta.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dedfca271d296f9c0d9fee052d337495aacb4d1b49eb4b29f09af9cd446757b1" dependencies = [ "bon", "bytes", "dhttp-access", "dhttp-home", "dhttp-identity", + "dhttp-log", "dyns", "futures", "h3x", @@ -1038,8 +1037,6 @@ dependencies = [ [[package]] name = "dhttp-access" version = "0.4.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbcd9d2805ace5bcac3c112e14b9df13d02f38f2a8a77897acb1cec536fe0715" dependencies = [ "chrono", "clap", @@ -1062,8 +1059,6 @@ dependencies = [ [[package]] name = "dhttp-home" version = "0.4.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af7727cd3f09f4f81bb928c3f7e77587a1a622796413bcbb8348e9a51b24f49" dependencies = [ "dhttp-identity", "dirs", @@ -1092,6 +1087,18 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "dhttp-log" +version = "0.1.0" +dependencies = [ + "chrono", + "dhttp-identity", + "http", + "sha2 0.10.9", + "snafu 0.9.1", + "x509-parser", +] + [[package]] name = "digest" version = "0.10.7" @@ -1675,6 +1682,7 @@ version = "0.4.0-beta.3" dependencies = [ "base64", "bytes", + "chrono", "clap", "crossterm", "dhttp", diff --git a/Cargo.toml b/Cargo.toml index ca16553..ba97b0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ tracing-indicatif = { version = "0.3" } # utils base64 = "0.22" bytes = "1" +chrono = "0.4" crossterm = { version = "0.29" } dirs = { version = "6" } sea-orm = { version = "1", features = ["runtime-tokio-rustls"] } diff --git a/genmeta-identity/Cargo.toml b/genmeta-identity/Cargo.toml index af3cbc0..e4f5579 100644 --- a/genmeta-identity/Cargo.toml +++ b/genmeta-identity/Cargo.toml @@ -19,6 +19,7 @@ crossterm = { workspace = true, optional = true } base64 = { workspace = true } bytes = { workspace = true } +chrono = { workspace = true } futures = { workspace = true } dhttp = { workspace = true } rankey = { workspace = true } diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index c2b53fb..669a53a 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -1,6 +1,8 @@ pub mod flow; pub mod prompt; pub mod validator; + +mod certificate_log; use std::io::IsTerminal; use clap::Parser; diff --git a/genmeta-identity/src/cli/certificate_log.rs b/genmeta-identity/src/cli/certificate_log.rs new file mode 100644 index 0000000..dd0c015 --- /dev/null +++ b/genmeta-identity/src/cli/certificate_log.rs @@ -0,0 +1,196 @@ +use std::path::PathBuf; + +use dhttp::{ + home::identity::IdentityProfile, + log::cert::{CertificateAction, CertificateLogRecord, DefaultCertificateFormatter}, +}; +use snafu::{OptionExt, ResultExt, Snafu}; +use tokio::io::AsyncWriteExt; + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub(crate) enum WriteCertificateLogError { + #[snafu(display("failed to load committed identity {}", profile.name()))] + LoadIdentity { + profile: Box, + source: dhttp::home::identity::ssl::LoadIdentityError, + }, + #[snafu(display("committed identity {} has no leaf certificate", profile.name()))] + MissingLeaf { profile: Box }, + #[snafu(display( + "failed to extract DHTTP certificate chain key from committed identity {}", + profile.name() + ))] + Chain { + profile: Box, + source: dhttp::identity::ExtractDhttpSubjectKeyIdentifierError, + }, + #[snafu(display("failed to construct certificate log record for {}", profile.name()))] + BuildRecord { + profile: Box, + source: dhttp::log::cert::CertificateLogRecordFromLeafDerError, + }, + #[snafu(display("failed to format certificate log record for {}", profile.name()))] + Format { + profile: Box, + source: dhttp::log::FormatError, + }, + #[snafu(display("failed to create certificate log directory {}", path.display()))] + CreateDirectory { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to open certificate log {}", path.display()))] + Open { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to append certificate log {}", path.display()))] + Write { + path: PathBuf, + source: std::io::Error, + }, +} + +pub(crate) async fn write_after_commit( + profile: &IdentityProfile, + action: CertificateAction, +) -> Result<(), WriteCertificateLogError> { + let identity = + profile + .load_identity() + .await + .context(write_certificate_log_error::LoadIdentitySnafu { + profile: Box::new(profile.clone()), + })?; + let leaf = identity + .certs() + .first() + .context(write_certificate_log_error::MissingLeafSnafu { + profile: Box::new(profile.clone()), + })?; + let chain = identity + .dhttp_subject_key_identifier() + .context(write_certificate_log_error::ChainSnafu { + profile: Box::new(profile.clone()), + })? + .chain() + .clone(); + let record = CertificateLogRecord::from_leaf_der( + chrono::Local::now().fixed_offset(), + action, + chain, + leaf.as_ref(), + ) + .context(write_certificate_log_error::BuildRecordSnafu { + profile: Box::new(profile.clone()), + })?; + let formatted = DefaultCertificateFormatter::format(&record).context( + write_certificate_log_error::FormatSnafu { + profile: Box::new(profile.clone()), + }, + )?; + + let directory = profile.logs_dir(); + tokio::fs::create_dir_all(&directory) + .await + .context(write_certificate_log_error::CreateDirectorySnafu { path: &directory })?; + let path = profile.cert_log_path(); + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .await + .context(write_certificate_log_error::OpenSnafu { path: &path })?; + file.write_all(formatted.as_bytes()) + .await + .context(write_certificate_log_error::WriteSnafu { path: &path }) +} + +#[cfg(test)] +mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use base64::Engine as _; + use dhttp::{ + home::{DhttpHome, identity::IdentityProfile}, + log::cert::CertificateAction, + name::DhttpName, + }; + + use super::write_after_commit; + + struct CommittedIdentityFixture { + profile: IdentityProfile, + home_path: PathBuf, + } + + impl Drop for CommittedIdentityFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.home_path); + } + } + + async fn committed_identity_fixture(label: &str) -> CommittedIdentityFixture { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let home_path = std::env::temp_dir().join(format!( + "genmeta-identity-certificate-log-{label}-{}-{nonce}", + std::process::id() + )); + let home = DhttpHome::new(home_path.clone()); + let name: DhttpName<'static> = "fixture.example".parse().unwrap(); + let profile = home.identity_profile(name); + let encoded = base64::engine::general_purpose::STANDARD + .encode(include_bytes!("../../tests/fixtures/valid.der")); + let mut cert_pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for line in encoded.as_bytes().chunks(64) { + cert_pem.push_str(std::str::from_utf8(line).unwrap()); + cert_pem.push('\n'); + } + cert_pem.push_str("-----END CERTIFICATE-----\n"); + let key_pem = rankey::generate_secp384r1_key().unwrap(); + profile + .save_identity(cert_pem.as_bytes(), key_pem.as_bytes()) + .await + .unwrap(); + CommittedIdentityFixture { profile, home_path } + } + + #[tokio::test] + async fn committed_identity_appends_one_complete_record() { + let fixture = committed_identity_fixture("writes-record").await; + write_after_commit(&fixture.profile, CertificateAction::Apply) + .await + .unwrap(); + let bytes = tokio::fs::read(fixture.profile.cert_log_path()) + .await + .unwrap(); + assert_eq!(bytes.iter().filter(|byte| **byte == b'\n').count(), 1); + assert!( + bytes + .windows(b" APPLY ".len()) + .any(|window| window == b" APPLY ") + ); + } + + #[tokio::test] + async fn logging_failure_does_not_remove_committed_material() { + let fixture = committed_identity_fixture("log-failure").await; + tokio::fs::create_dir_all(fixture.profile.cert_log_path()) + .await + .unwrap(); + assert!( + write_after_commit(&fixture.profile, CertificateAction::Renew) + .await + .is_err() + ); + assert!(fixture.profile.ssl_dir().join("fullchain.crt").exists()); + assert!(fixture.profile.ssl_dir().join("privkey.pem").exists()); + } +} From 15b5c2e2af9fefc63e0dfee06dcb128394f8668a Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 15 Jul 2026 01:17:52 +0800 Subject: [PATCH 23/27] feat(identity): log committed certificate lifecycle --- genmeta-identity/src/cli/flow/apply.rs | 29 ++++++++++++++++++++++++ genmeta-identity/src/cli/flow/install.rs | 27 +++++++++++++++++----- genmeta-identity/src/cli/flow/renew.rs | 1 + 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index 785e905..9b56549 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -468,6 +468,14 @@ async fn attempt_apply_with_candidates( } } +fn certificate_action(resolved: &ResolvedApplyTarget) -> dhttp::log::cert::CertificateAction { + if resolved.local.is_some() { + dhttp::log::cert::CertificateAction::Replace + } else { + dhttp::log::cert::CertificateAction::Apply + } +} + async fn install_and_finish( dhttp_home: &DhttpHome, resolved: &ResolvedApplyTarget, @@ -488,6 +496,7 @@ async fn install_and_finish( sequence: None, }, key_pem, + certificate_action(resolved), ) .await?; super::transcript::print_line(INSTALLED); @@ -599,6 +608,26 @@ mod tests { } } + #[test] + fn certificate_action_tracks_exact_local_material_lifecycle() { + assert_eq!( + certificate_action(&resolved_with(None)), + dhttp::log::cert::CertificateAction::Apply, + ); + assert_eq!( + certificate_action(&resolved_with(Some(LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".to_string(), + }))), + dhttp::log::cert::CertificateAction::Replace, + ); + assert_eq!( + certificate_action(&resolved_with(Some(LocalIdentityStatus::Ready { + expires_at: 1_900_000_000, + }))), + dhttp::log::cert::CertificateAction::Replace, + ); + } + #[test] fn apply_copy_matches_the_approved_transcript() { assert_eq!( diff --git a/genmeta-identity/src/cli/flow/install.rs b/genmeta-identity/src/cli/flow/install.rs index 8accde2..37124a2 100644 --- a/genmeta-identity/src/cli/flow/install.rs +++ b/genmeta-identity/src/cli/flow/install.rs @@ -214,13 +214,22 @@ pub(crate) async fn validate_and_save( detail: &CertificateDetail, expected: &InstallExpectation<'_>, key_pem: &str, + action: dhttp::log::cert::CertificateAction, ) -> Result<(), Error> { validate_install(detail, expected, key_pem)?; - dhttp_home - .identity_profile(expected.target.borrow()) + let profile = dhttp_home.identity_profile(expected.target.borrow()); + profile .save_identity(detail.cert_pem.as_bytes(), key_pem.as_bytes()) .await - .context(error::SaveIdentitySnafu) + .context(error::SaveIdentitySnafu)?; + if let Err(error) = crate::cli::certificate_log::write_after_commit(&profile, action).await { + tracing::warn!( + identity = %profile.name(), + error = %snafu::Report::from_error(&error), + "failed to append certificate log after identity commit" + ); + } + Ok(()) } #[cfg(test)] @@ -378,9 +387,15 @@ mod tests { invalid.domain = "other.smith.dhttp.net".to_string(); assert!( - validate_and_save(&home, &invalid, &expectation(name.borrow()), KEY) - .await - .is_err() + validate_and_save( + &home, + &invalid, + &expectation(name.borrow()), + KEY, + dhttp::log::cert::CertificateAction::Apply, + ) + .await + .is_err() ); assert!(!home_path.exists()); } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 7789648..a16772b 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -298,6 +298,7 @@ pub(crate) async fn run( sequence: Some(preflight.sequence), }, key_pem, + dhttp::log::cert::CertificateAction::Renew, ) .await?; super::transcript::print_line(RENEWED); From d9ac57d9703acdf42b347f1d523bea8960354846 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 15 Jul 2026 01:18:04 +0800 Subject: [PATCH 24/27] docs(identity): record certificate audit lifecycle --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c77a339..200bf70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `genmeta id` is a visible alias for `genmeta identity`. - `genmeta identity default -v` shows the default identity's full details. +- Successful identity apply, replacement, and renewal append a post-commit + certificate audit record to the selected profile's `cert.log`; audit-write + failures do not roll back installed identity material. ### Changed From c44659da17996aac3a45d16aab4a53c68d74a831 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 15 Jul 2026 17:02:31 +0800 Subject: [PATCH 25/27] feat: discover all online ssh sequences --- genmeta-ssh/src/connect.rs | 96 +++++++++++++++++++++++++++++++++---- genmeta-ssh/src/sequence.rs | 70 +++++++++++++++++---------- 2 files changed, 132 insertions(+), 34 deletions(-) diff --git a/genmeta-ssh/src/connect.rs b/genmeta-ssh/src/connect.rs index a5e5cf6..7cfdb87 100644 --- a/genmeta-ssh/src/connect.rs +++ b/genmeta-ssh/src/connect.rs @@ -1,7 +1,9 @@ -use std::{io::IsTerminal, sync::Arc}; +use std::{io::IsTerminal, num::NonZeroUsize, sync::Arc}; use dhttp::{ - ddns::resolvers::endpoint_candidates::ResolveEndpointCandidates, + ddns::resolvers::endpoint_candidates::{ + EndpointCandidates, EndpointLookup, ResolveEndpointCandidates, + }, dquic, endpoint::Endpoint, h3x::{ @@ -73,6 +75,26 @@ fn connect_path(uri: &http::Uri) -> &str { uri.path() } +fn primary_discovery_lookup() -> EndpointLookup { + EndpointLookup::all().with_record_limit(NonZeroUsize::MIN) +} + +fn needs_primary_discovery(authority: &http::uri::Authority) -> bool { + crate::config::authority_sequence(authority).is_none() +} + +async fn lookup_primary_candidates( + resolver: &R, + authority: &str, +) -> std::io::Result +where + R: ResolveEndpointCandidates + ?Sized, +{ + resolver + .lookup_endpoint_candidates(authority, primary_discovery_lookup()) + .await +} + pub async fn build_endpoint(config: &Config) -> Result, Error> { let identity = match &config.id { Some(config) => Some(Arc::new( @@ -108,7 +130,7 @@ async fn selected_uri(endpoint: &Endpoint, config: &Config) -> Result Result() { - resolvers - .lookup_endpoint_candidates(authority.as_str()) + lookup_primary_candidates(resolvers, authority.as_str()) .await - .map_err(std::io::Error::other) .context(connect_error::LookupCandidatesSnafu)? } else if let Some(deferred) = any.downcast_ref::< dhttp::ddns::resolvers::deferred::DeferredResolver, >() { - deferred - .lookup_endpoint_candidates(authority.as_str()) + lookup_primary_candidates(deferred, authority.as_str()) .await .context(connect_error::LookupCandidatesSnafu)? } else { @@ -197,8 +216,47 @@ pub async fn connect(config: &Config) -> Result { #[cfg(test)] mod tests { + use std::{fmt, sync::Mutex}; + + use dhttp::{ + ddns::resolvers::endpoint_candidates::{EndpointCandidateFuture, SequenceQuery}, + dquic::qresolve::{Resolve, ResolveFuture}, + }; + use futures::{FutureExt, StreamExt}; + use super::*; + #[derive(Debug, Default)] + struct RecordingResolver { + requests: Mutex>, + } + + impl fmt::Display for RecordingResolver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("recording resolver") + } + } + + impl Resolve for RecordingResolver { + fn lookup<'a>(&'a self, _name: &'a str) -> ResolveFuture<'a> { + async { Ok(futures::stream::empty().boxed()) }.boxed() + } + } + + impl ResolveEndpointCandidates for RecordingResolver { + fn lookup_endpoint_candidates<'a>( + &'a self, + name: &'a str, + lookup: EndpointLookup, + ) -> EndpointCandidateFuture<'a> { + self.requests + .lock() + .unwrap() + .push((name.to_owned(), lookup)); + async { Ok(EndpointCandidates::default()) }.boxed() + } + } + #[test] fn connection_builder_registers_webtransport_protocol_layer() { let builder = connection_builder(); @@ -231,4 +289,26 @@ mod tests { assert_eq!(connect_path(&uri), "/ssh/yiyue"); } + + #[tokio::test] + async fn primary_discovery_requests_all_sequences_with_one_record_each() { + let resolver = RecordingResolver::default(); + + lookup_primary_candidates(&resolver, "alice.device") + .await + .unwrap(); + + let requests = resolver.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].0, "alice.device"); + assert_eq!(requests[0].1.sequences, SequenceQuery::All); + assert_eq!(requests[0].1.record_limit, Some(NonZeroUsize::MIN)); + } + + #[test] + fn explicit_sequence_authority_does_not_need_discovery() { + let authority: http::uri::Authority = "alice.device:7".parse().unwrap(); + + assert!(!needs_primary_discovery(&authority)); + } } diff --git a/genmeta-ssh/src/sequence.rs b/genmeta-ssh/src/sequence.rs index 41d7e7f..6c79053 100644 --- a/genmeta-ssh/src/sequence.rs +++ b/genmeta-ssh/src/sequence.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, fmt, io::IsTerminal}; +use std::{collections::HashMap, fmt, io::IsTerminal}; use dhttp::{ certificate::{CertificateChainKind, CertificateSequence}, @@ -26,44 +26,50 @@ pub(crate) fn merge_candidates( ddns: EndpointCandidates, certs: impl IntoIterator, ) -> Vec { - let mut rows = BTreeMap::::new(); + let mut rows = Vec::::new(); + let mut sequence_indexes = HashMap::::new(); for group in ddns.groups { if group.chain.kind() != CertificateChainKind::Primary { continue; } - rows.insert( - group.chain.sequence().get(), - SshPrimaryCandidate { + let sequence = group.chain.sequence().get(); + if let Some(index) = sequence_indexes.get(&sequence).copied() { + rows[index].endpoint_count += group.endpoints.len(); + } else { + sequence_indexes.insert(sequence, rows.len()); + rows.push(SshPrimaryCandidate { sequence: group.chain.sequence(), online: true, endpoint_count: group.endpoints.len(), device_name: None, cert_status: None, - }, - ); + }); + } } for cert in certs { - rows.entry(cert.sequence.get()) - .and_modify(|row| { - if row.device_name.is_none() { - row.device_name.clone_from(&cert.device_name); - } - if row.cert_status.is_none() { - row.cert_status.clone_from(&cert.status); - } - }) - .or_insert_with(|| SshPrimaryCandidate { + if let Some(index) = sequence_indexes.get(&cert.sequence.get()).copied() { + let row = &mut rows[index]; + if row.device_name.is_none() { + row.device_name.clone_from(&cert.device_name); + } + if row.cert_status.is_none() { + row.cert_status.clone_from(&cert.status); + } + } else { + sequence_indexes.insert(cert.sequence.get(), rows.len()); + rows.push(SshPrimaryCandidate { sequence: cert.sequence, online: false, endpoint_count: 0, device_name: cert.device_name, cert_status: cert.status, }); + } } - rows.into_values().collect() + rows } #[derive(Debug, Clone)] @@ -268,31 +274,43 @@ mod tests { } #[test] - fn merge_enriches_online_candidate_and_adds_offline_candidate() { + fn merge_preserves_online_order_then_appends_offline_certificate_order() { let candidates = merge_candidates( EndpointCandidates { - groups: vec![group(0, 2)], + groups: vec![group(2, 2), group(1, 1)], }, [ CertPrimaryMetadata { - sequence: CertificateSequence::from(0u8), + sequence: CertificateSequence::from(1u8), device_name: Some("MacBook Pro".to_string()), status: Some("active".to_string()), }, CertPrimaryMetadata { - sequence: CertificateSequence::from(1u8), + sequence: CertificateSequence::from(3u8), device_name: Some("ThinkPad".to_string()), status: Some("active".to_string()), }, + CertPrimaryMetadata { + sequence: CertificateSequence::from(0u8), + device_name: Some("Server".to_string()), + status: Some("expired".to_string()), + }, ], ); - assert_eq!(candidates.len(), 2); + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.sequence.get()) + .collect::>(), + vec![2, 1, 3, 0] + ); assert!(candidates[0].online); assert_eq!(candidates[0].endpoint_count, 2); - assert_eq!(candidates[0].device_name.as_deref(), Some("MacBook Pro")); - assert!(!candidates[1].online); - assert_eq!(candidates[1].device_name.as_deref(), Some("ThinkPad")); + assert_eq!(candidates[1].device_name.as_deref(), Some("MacBook Pro")); + assert!(!candidates[2].online); + assert_eq!(candidates[2].device_name.as_deref(), Some("ThinkPad")); + assert_eq!(candidates[3].device_name.as_deref(), Some("Server")); } #[test] From db9df4dfa4782633559661cb92a1f5f2ef12f40d Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 15 Jul 2026 17:48:37 +0800 Subject: [PATCH 26/27] feat: select server-ranked ssh sequence --- genmeta-ssh/Cargo.toml | 1 - genmeta-ssh/src/connect.rs | 11 +-- genmeta-ssh/src/sequence.rs | 154 +++++++++--------------------------- 3 files changed, 40 insertions(+), 126 deletions(-) diff --git a/genmeta-ssh/Cargo.toml b/genmeta-ssh/Cargo.toml index 5901f7d..98bfa9e 100644 --- a/genmeta-ssh/Cargo.toml +++ b/genmeta-ssh/Cargo.toml @@ -17,7 +17,6 @@ dirs = { workspace = true } futures = { workspace = true } genmeta-identity = { workspace = true, default-features = false } dhttp = { workspace = true } -inquire = { workspace = true } dshell = { workspace = true, features = ["cli", "client", "config"] } http = { workspace = true } h3x = { workspace = true, features = ["webtransport"] } diff --git a/genmeta-ssh/src/connect.rs b/genmeta-ssh/src/connect.rs index 7cfdb87..ac8dbd5 100644 --- a/genmeta-ssh/src/connect.rs +++ b/genmeta-ssh/src/connect.rs @@ -1,4 +1,4 @@ -use std::{io::IsTerminal, num::NonZeroUsize, sync::Arc}; +use std::{num::NonZeroUsize, sync::Arc}; use dhttp::{ ddns::resolvers::endpoint_candidates::{ @@ -153,13 +153,8 @@ async fn selected_uri(endpoint: &Endpoint, config: &Config) -> Result) -> fmt::Result { - let status = if self.candidate.online { - self.candidate.cert_status.as_deref().unwrap_or("online") - } else { - "offline" - }; - let device = self - .candidate - .device_name - .as_deref() - .unwrap_or("unknown device"); - let line = format!( - "primary:{} {device} {status}", - self.candidate.sequence.get() - ); - if self.ansi && !self.candidate.online { - use crossterm::style::Stylize; - write!(f, "{}", line.dim()) - } else { - f.write_str(&line) - } - } -} - #[derive(Debug, Snafu)] #[snafu(module(sequence_error))] pub enum Error { @@ -110,66 +79,19 @@ pub enum Error { "No primary sequences were found for {target}.\n\nThe device may not have published DNS endpoints yet, and no certificate metadata was available" ))] NoCandidates { target: String }, - - #[snafu(display( - "Cannot choose a primary sequence without interactive input.\n\nAvailable primary sequences:\n{available}\nRun again with a sequence, for example:\n genmeta ssh {example}" - ))] - NonInteractive { - available: CandidateListDisplay, - example: String, - }, - - #[snafu(display("failed to prompt for primary sequence"))] - Prompt { source: inquire::InquireError }, } -#[derive(Debug, Clone)] -pub struct CandidateListDisplay(Vec); - -impl fmt::Display for CandidateListDisplay { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for candidate in &self.0 { - let status = if candidate.online { - "online" - } else { - "offline" - }; - writeln!(f, " primary:{} {status}", candidate.sequence.get())?; - } - Ok(()) - } -} - -pub(crate) fn choose_non_interactive( +pub(crate) fn choose_server_ranked( target: &str, candidates: &[SshPrimaryCandidate], ) -> Result { - let first = candidates.first().ok_or_else(|| Error::NoCandidates { - target: target.to_string(), - })?; - Err(Error::NonInteractive { - available: CandidateListDisplay(candidates.to_vec()), - example: format!("{target}:{}", first.sequence.get()), - }) -} - -pub(crate) fn terminal_is_interactive() -> bool { - std::io::stdin().is_terminal() && std::io::stdout().is_terminal() -} - -pub(crate) fn prompt_for_sequence( - candidates: &[SshPrimaryCandidate], - ansi: bool, -) -> Result { - let choices: Vec<_> = candidates + candidates .iter() - .cloned() - .map(|candidate| CandidateChoice { candidate, ansi }) - .collect(); - let choice = inquire::Select::new("Select a primary sequence to connect:", choices) - .prompt() - .context(sequence_error::PromptSnafu)?; - Ok(choice.candidate.sequence) + .find(|candidate| candidate.online) + .map(|candidate| candidate.sequence) + .ok_or_else(|| Error::NoCandidates { + target: target.to_string(), + }) } pub(crate) fn cert_metadata_from_parts( @@ -314,45 +236,43 @@ mod tests { } #[test] - fn offline_display_is_dimmed_when_ansi_enabled() { - let choice = CandidateChoice { - ansi: true, - candidate: SshPrimaryCandidate { + fn server_ranked_selection_uses_first_online_candidate() { + let mut candidates = vec![ + SshPrimaryCandidate { + sequence: CertificateSequence::from(2u8), + online: true, + endpoint_count: 1, + device_name: None, + cert_status: None, + }, + SshPrimaryCandidate { sequence: CertificateSequence::from(1u8), - online: false, - endpoint_count: 0, - device_name: Some("ThinkPad".to_string()), - cert_status: Some("active".to_string()), + online: true, + endpoint_count: 1, + device_name: None, + cert_status: None, }, - }; - - let rendered = choice.to_string(); - assert!(rendered.contains("primary:1")); - assert!(rendered.contains("\u{1b}")); - assert!(rendered.contains("offline")); - } - - #[test] - fn non_interactive_requires_explicit_sequence_even_for_one_candidate() { - let candidates = vec![SshPrimaryCandidate { - sequence: CertificateSequence::from(0u8), - online: true, - endpoint_count: 1, - device_name: Some("MacBook Pro".to_string()), - cert_status: Some("active".to_string()), - }]; + ]; - let error = choose_non_interactive("alice.device", &candidates).unwrap_err(); - let rendered = error.to_string(); + assert_eq!( + choose_server_ranked("alice.device", &candidates) + .unwrap() + .get(), + 2 + ); - assert!(rendered.contains("Cannot choose a primary sequence without interactive input")); - assert!(rendered.contains("primary:0")); - assert!(rendered.contains("genmeta ssh alice.device:0")); + candidates[0].online = false; + assert_eq!( + choose_server_ranked("alice.device", &candidates) + .unwrap() + .get(), + 1 + ); } #[test] fn empty_candidates_reports_no_primary_sequences() { - let error = choose_non_interactive("alice.device", &[]).unwrap_err(); + let error = choose_server_ranked("alice.device", &[]).unwrap_err(); assert!( error From 3bfdfc9250425789797eab2ebe69161dc41fe332 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 16 Jul 2026 16:29:12 +0800 Subject: [PATCH 27/27] chore: prepare genmeta v0.8.0-beta.4 --- CHANGELOG.md | 28 ++ Cargo.lock | 472 ++++++++---------- Cargo.toml | 24 +- genmeta-access/Cargo.toml | 2 +- genmeta-curl/Cargo.toml | 2 +- genmeta-discover/Cargo.toml | 2 +- genmeta-doctor/Cargo.toml | 2 +- genmeta-identity/Cargo.toml | 2 +- genmeta-identity/src/cli.rs | 16 +- .../src/cli/flow/default_identity.rs | 8 +- genmeta-nat/Cargo.toml | 2 +- genmeta-nslookup/Cargo.toml | 2 +- genmeta-proxy/Cargo.toml | 2 +- genmeta-ssh/Cargo.toml | 2 +- genmeta/Cargo.toml | 2 +- 15 files changed, 267 insertions(+), 301 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 200bf70..4a3dd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0-beta.4] - 2026-07-16 + ### Added - `genmeta id` is a visible alias for `genmeta identity`. @@ -17,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `genmeta ssh` now discovers all online primary sequences, preserves server ranking, and connects through the first online sequence unless an explicit selector is supplied. - `genmeta identity apply [name]` is now the single create-or-update flow; the former `identity create` command has been removed. - Existing identities, new root identities, and new direct sub-identities now @@ -54,6 +57,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 installed, and local certificate/key replacement rolls back on commit failure without deleting service files. +### Fixed + +- Identity fallback errors compile cleanly with the release workflow nightly toolchain. + +### Dependencies + +- Release manifests now target `dhttp` v0.6.0-beta.4, including + `dhttp-access` v0.4.0-beta.2, `dhttp-home` v0.5.0-beta.1, and + `dhttp-log` v0.1.0-beta.1 through the facade; discovery and transport + dependencies target `dyns` v0.7.0-beta.2, `h3x` v0.6.0-beta.4, + `dquic` v0.7.0-beta.4, and `dshell` v0.6.0-beta.3. + +### Components + +- `genmeta` v0.8.0-beta.4 +- `genmeta-curl` v0.7.0-beta.4 +- `genmeta-ssh` v0.7.0-beta.4 +- `genmeta-access` v0.4.0-beta.3 +- `genmeta-identity` v0.4.0-beta.4 +- `genmeta-proxy` v0.4.0-beta.3 +- `genmeta-discover` v0.4.0-beta.3 +- `genmeta-doctor` v0.4.0-beta.3 +- `genmeta-nat` v0.5.0-beta.3 +- `genmeta-nslookup` v0.5.0-beta.3 + ## [0.8.0-beta.3] - 2026-07-09 ### Added diff --git a/Cargo.lock b/Cargo.lock index f924b2b..504a537 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,15 +40,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-build" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" -dependencies = [ - "windows-sys 0.52.0", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -119,9 +110,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -147,7 +138,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -159,7 +150,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -205,7 +196,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -216,7 +207,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -290,14 +281,14 @@ checksum = "3ca6739863c590881f038d033a146c51ddae239186a4327014839fd864f44ed5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -363,7 +354,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -387,7 +378,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -401,9 +392,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "serde_core", @@ -445,18 +436,18 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] [[package]] name = "cc" -version = "1.2.65" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -509,9 +500,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -519,9 +510,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -538,7 +529,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -599,9 +590,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -706,27 +697,27 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -842,7 +833,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -855,7 +846,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -866,7 +857,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -877,7 +868,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -913,9 +904,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", "der_derive", @@ -946,7 +937,7 @@ checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -976,7 +967,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -986,7 +977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1008,13 +999,15 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] [[package]] name = "dhttp" -version = "0.5.0-beta.3" +version = "0.6.0-beta.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d2eb9b7468efa85ff15f00c6e876cee46c2b3c018664b7158b407955d6e931" dependencies = [ "bon", "bytes", @@ -1036,7 +1029,9 @@ dependencies = [ [[package]] name = "dhttp-access" -version = "0.4.0-beta.1" +version = "0.4.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de04ba6e93d5e47b75d8381377a5d710dac52baed5790c56ec93a4e31f7e2756" dependencies = [ "chrono", "clap", @@ -1058,7 +1053,9 @@ dependencies = [ [[package]] name = "dhttp-home" -version = "0.4.0-beta.1" +version = "0.5.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e64e413db3551f260e158d18dcd73906dbebf938399dd3bcb8a286e612fa0ee" dependencies = [ "dhttp-identity", "dirs", @@ -1089,7 +1086,9 @@ dependencies = [ [[package]] name = "dhttp-log" -version = "0.1.0" +version = "0.1.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e7070b6a8ead2399f5d8f959cd8c8b10ccd769651ed67e0b36d54cc4bce35e" dependencies = [ "chrono", "dhttp-identity", @@ -1164,7 +1163,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1195,9 +1194,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dquic" -version = "0.7.0-beta.2" +version = "0.7.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2945f40d972d12cf06777eddf20b7104378d5a614dcca56800286cff370f489" +checksum = "02b6fd0cd4d134f74c5da30d9ca745a8875e56b139892a37f7806aeac150fb32" dependencies = [ "arc-swap", "dashmap", @@ -1213,9 +1212,9 @@ dependencies = [ [[package]] name = "dshell" -version = "0.6.0-beta.2" +version = "0.6.0-beta.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "024bc3e3dbed799db8ab771e51851a741de22c1caba2c82f98ef557a5be34d5a" +checksum = "218694a1b4424c69b9b3b5c7416cfceb6c39e35b20968caef61bf9b3c953f921" dependencies = [ "base64", "bytes", @@ -1241,9 +1240,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dyns" -version = "0.6.0-beta.3" +version = "0.7.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8916a250117bdecaa9aa721465c646da282dc41523361b63c8cd01693c6869" +checksum = "7186611a2d3156f9de8a836624c8da907e59bb0f198f74b4cf5b675cae17b68d" dependencies = [ "base64", "bitfield-struct", @@ -1259,7 +1258,7 @@ dependencies = [ "http-body-util", "libc", "nom 8.0.0", - "rand 0.10.1", + "rand 0.10.2", "reqwest", "ring", "rustls", @@ -1279,7 +1278,7 @@ version = "0.17.0-rc.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91bbdd377139884fafcad8dc43a760a3e1e681aa26db910257fa6535b70e1829" dependencies = [ - "der 0.8.0", + "der 0.8.1", "digest 0.11.3", "elliptic-curve", "rfc6979", @@ -1343,7 +1342,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1548,7 +1547,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1601,7 +1600,7 @@ dependencies = [ [[package]] name = "genmeta" -version = "0.8.0-beta.3" +version = "0.8.0-beta.4" dependencies = [ "clap", "genmeta-access", @@ -1620,7 +1619,7 @@ dependencies = [ [[package]] name = "genmeta-access" -version = "0.4.0-beta.2" +version = "0.4.0-beta.3" dependencies = [ "clap", "dhttp", @@ -1634,7 +1633,7 @@ dependencies = [ [[package]] name = "genmeta-curl" -version = "0.7.0-beta.3" +version = "0.7.0-beta.4" dependencies = [ "async-compression", "bytes", @@ -1653,7 +1652,7 @@ dependencies = [ [[package]] name = "genmeta-discover" -version = "0.4.0-beta.2" +version = "0.4.0-beta.3" dependencies = [ "clap", "dhttp", @@ -1667,7 +1666,7 @@ dependencies = [ [[package]] name = "genmeta-doctor" -version = "0.4.0-beta.2" +version = "0.4.0-beta.3" dependencies = [ "clap", "genmeta-nat", @@ -1678,7 +1677,7 @@ dependencies = [ [[package]] name = "genmeta-identity" -version = "0.4.0-beta.3" +version = "0.4.0-beta.4" dependencies = [ "base64", "bytes", @@ -1712,7 +1711,7 @@ dependencies = [ [[package]] name = "genmeta-nat" -version = "0.5.0-beta.2" +version = "0.5.0-beta.3" dependencies = [ "clap", "dhttp", @@ -1726,7 +1725,7 @@ dependencies = [ [[package]] name = "genmeta-nslookup" -version = "0.5.0-beta.2" +version = "0.5.0-beta.3" dependencies = [ "clap", "dhttp", @@ -1740,7 +1739,7 @@ dependencies = [ [[package]] name = "genmeta-proxy" -version = "0.4.0-beta.2" +version = "0.4.0-beta.3" dependencies = [ "bytes", "clap", @@ -1760,7 +1759,7 @@ dependencies = [ [[package]] name = "genmeta-ssh" -version = "0.7.0-beta.3" +version = "0.7.0-beta.4" dependencies = [ "clap", "crossterm", @@ -1771,7 +1770,6 @@ dependencies = [ "genmeta-identity", "h3x", "http", - "inquire", "snafu 0.9.1", "tokio", "tracing", @@ -1825,7 +1823,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1836,9 +1834,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -1868,9 +1866,9 @@ dependencies = [ [[package]] name = "h3x" -version = "0.6.0-beta.3" +version = "0.6.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e242a82f0df4f9c7a8a83440a9943468ff99bd3a4f004c47cc717bb155a7a3" +checksum = "54d7e49e7fe31d63059488ac6635d663ba53a8211657faae18d5940757e034ae" dependencies = [ "arc-swap", "async-channel", @@ -2013,9 +2011,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2023,9 +2021,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -2277,9 +2275,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -2297,7 +2295,7 @@ checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2375,7 +2373,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2403,16 +2401,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -2548,9 +2546,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2585,9 +2583,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -2660,19 +2658,6 @@ dependencies = [ "log", ] -[[package]] -name = "netwatcher" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a73c134b9d8ca27ba209646851858cd112fb543b6ecf48019c64b29c91b3b18a" -dependencies = [ - "android-build", - "jni 0.22.4", - "ndk-context", - "nix 0.31.3", - "windows", -] - [[package]] name = "nix" version = "0.29.0" @@ -2729,9 +2714,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2748,7 +2733,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -2770,11 +2755,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2946,7 +2930,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3091,7 +3075,7 @@ version = "0.11.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" dependencies = [ - "der 0.8.0", + "der 0.8.1", "spki 0.8.0-rc.4", ] @@ -3109,9 +3093,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64", "indexmap 2.14.0", @@ -3157,7 +3141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3211,7 +3195,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3231,7 +3215,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "version_check", "yansi", ] @@ -3270,7 +3254,7 @@ dependencies = [ "getset", "nom 8.0.0", "qmacro", - "rand 0.10.1", + "rand 0.10.2", "rustls", "serde", "smallvec", @@ -3287,7 +3271,7 @@ checksum = "92ff2c0dd91cf0e4cacca9e3b1ab8c2bd4bff93c17ba39f509e53f238a1fda42" dependencies = [ "qbase", "qevent", - "rand 0.10.1", + "rand 0.10.2", "thiserror 2.0.18", "tokio", "tracing", @@ -3295,9 +3279,9 @@ dependencies = [ [[package]] name = "qconnection" -version = "0.7.0-beta.1" +version = "0.8.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea5f6581e4d228dfe4c2a973c137f2a405d639ed0cdbdd75fe3baf8d642db89" +checksum = "bd0e696c1bd3b8525f744848368e57f54f7be45464763b07e72bd4692d4a6065" dependencies = [ "bytes", "dashmap", @@ -3354,9 +3338,9 @@ dependencies = [ [[package]] name = "qinterface" -version = "0.6.0" +version = "0.7.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d6859183d5d4547470fd3ea641d700b79e416269f229b03b0c8709e2c98575" +checksum = "ba79cb72588e962cd079efe8fb301d2dc8182edf4d495222c8fa93f0e2d31341" dependencies = [ "bytes", "dashmap", @@ -3364,7 +3348,6 @@ dependencies = [ "futures", "http", "netdev", - "netwatcher", "parking_lot", "qbase", "qevent", @@ -3385,7 +3368,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3423,9 +3406,9 @@ dependencies = [ [[package]] name = "qtraversal" -version = "0.7.0-beta.2" +version = "0.7.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16fc8219fe16d92319e4ccd1c41ea87661a27abfacc4d40e77273b55fa384cf9" +checksum = "854dbea8936703533d83456b6431bcad3996cde0b12f255e9cc582d925dee7aa" dependencies = [ "bon", "bytes", @@ -3436,7 +3419,7 @@ dependencies = [ "qevent", "qinterface", "qresolve", - "rand 0.10.1", + "rand 0.10.2", "smallvec", "snafu 0.9.1", "thiserror 2.0.18", @@ -3447,9 +3430,9 @@ dependencies = [ [[package]] name = "qudp" -version = "0.6.0" +version = "0.7.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce9009cdd2de1ef116833beeccf1a5426a0a8f426ee836fe6c05cba4322b915" +checksum = "85ad34a609083ce5fd9f6972d6d6ec7d5fb1fa2af9a87d6b7f19ec72117a5a50" dependencies = [ "bytes", "cfg-if", @@ -3464,9 +3447,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -3500,9 +3483,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3511,9 +3494,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3521,9 +3504,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3581,7 +3564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f4a0713147f04d0f0ea6e146074adef0ecab3c5ca49fd5f156330b42bc1a363" dependencies = [ "crypto-bigint", - "der 0.8.0", + "der 0.8.1", "ecdsa", "elliptic-curve", "hmac 0.13.0-rc.5", @@ -3589,7 +3572,7 @@ dependencies = [ "pkcs8 0.11.0-rc.11", "primefield", "primeorder", - "rand 0.10.1", + "rand 0.10.2", "rfc6979", "sec1", "serde", @@ -3647,14 +3630,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3664,9 +3647,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3689,7 +3672,7 @@ dependencies = [ "byteorder", "bytes", "futures", - "rand 0.9.4", + "rand 0.9.5", "remoc_macro", "serde", "tokio", @@ -3706,7 +3689,7 @@ checksum = "d89479d9d87f65ef573faf0167dd0a9f40d3a63fd95e7a2935d662fa57dbc30d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3841,7 +3824,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "rkyv", "serde", "serde_json", @@ -3902,9 +3885,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -3985,9 +3968,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4053,7 +4036,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4116,7 +4099,7 @@ dependencies = [ "proc-macro2", "quote", "sea-bae", - "syn 2.0.118", + "syn 2.0.119", "unicode-ident", ] @@ -4179,7 +4162,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "thiserror 2.0.18", ] @@ -4205,7 +4188,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4222,7 +4205,7 @@ checksum = "7a2400ed44a13193820aa528a19f376c3843141a8ce96ff34b11104cc79763f2" dependencies = [ "base16ct", "ctutils", - "der 0.8.0", + "der 0.8.1", "hybrid-array", "subtle", "zeroize", @@ -4284,7 +4267,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4350,14 +4333,14 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4465,15 +4448,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -4527,7 +4510,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4539,14 +4522,14 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4554,9 +4537,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -4578,7 +4561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" dependencies = [ "base64ct", - "der 0.8.0", + "der 0.8.1", ] [[package]] @@ -4645,7 +4628,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4668,7 +4651,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.118", + "syn 2.0.119", "tokio", "url", ] @@ -4704,11 +4687,11 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.6", + "rand 0.8.7", "rsa", "rust_decimal", "serde", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "smallvec", "sqlx-core", @@ -4748,7 +4731,7 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand 0.8.6", + "rand 0.8.7", "rust_decimal", "serde", "serde_json", @@ -4850,9 +4833,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -4876,7 +4859,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4932,7 +4915,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4943,14 +4926,14 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -4999,9 +4982,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -5030,7 +5013,7 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5058,7 +5041,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5098,9 +5081,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -5122,9 +5105,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime", @@ -5143,9 +5126,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -5230,7 +5213,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5392,9 +5375,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5542,7 +5525,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5656,27 +5639,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -5690,17 +5652,6 @@ dependencies = [ "windows-strings", ] -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -5709,7 +5660,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5720,7 +5671,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5729,16 +5680,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - [[package]] name = "windows-registry" version = "0.6.1" @@ -5850,15 +5791,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5993,9 +5925,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -6028,7 +5960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e21aad3a769f25f3d2d0cbf30ea8b50a1d602354bd6ab687fad112821608ba6" dependencies = [ "const-oid 0.10.2", - "der 0.8.0", + "der 0.8.1", "sha1 0.11.0-rc.5", "signature 3.0.0-rc.10", "spki 0.8.0-rc.4", @@ -6078,28 +6010,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6119,7 +6051,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -6140,7 +6072,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6173,14 +6105,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index ba97b0d..2ce3e2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ indicatif = "0.18" whoami = "2" # network -h3x = "0.6.0-beta.3" +h3x = "0.6.0-beta.4" http = "1" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["server", "tokio"] } @@ -97,19 +97,19 @@ async-compression = { version = "0.4", features = [ ] } # workspace -genmeta-access = { path = "genmeta-access", version = "0.4.0-beta.2" } -dhttp = "0.5.0-beta.3" -genmeta-curl = { path = "genmeta-curl", version = "0.7.0-beta.3" } -genmeta-discover = { path = "genmeta-discover", version = "0.4.0-beta.2" } -genmeta-doctor = { path = "genmeta-doctor", version = "0.4.0-beta.2" } -genmeta-identity = { path = "genmeta-identity", version = "0.4.0-beta.3", default-features = false } -genmeta-nat = { path = "genmeta-nat", version = "0.5.0-beta.2" } -genmeta-nslookup = { path = "genmeta-nslookup", version = "0.5.0-beta.2" } -genmeta-ssh = { path = "genmeta-ssh", version = "0.7.0-beta.3" } -genmeta-proxy = { path = "genmeta-proxy", version = "0.4.0-beta.2" } +genmeta-access = { path = "genmeta-access", version = "0.4.0-beta.3" } +dhttp = "0.6.0-beta.4" +genmeta-curl = { path = "genmeta-curl", version = "0.7.0-beta.4" } +genmeta-discover = { path = "genmeta-discover", version = "0.4.0-beta.3" } +genmeta-doctor = { path = "genmeta-doctor", version = "0.4.0-beta.3" } +genmeta-identity = { path = "genmeta-identity", version = "0.4.0-beta.4", default-features = false } +genmeta-nat = { path = "genmeta-nat", version = "0.5.0-beta.3" } +genmeta-nslookup = { path = "genmeta-nslookup", version = "0.5.0-beta.3" } +genmeta-ssh = { path = "genmeta-ssh", version = "0.7.0-beta.4" } +genmeta-proxy = { path = "genmeta-proxy", version = "0.4.0-beta.3" } # DShell -dshell = { version = "0.6.0-beta.2", features = [ +dshell = { version = "0.6.0-beta.3", features = [ "config", ] } diff --git a/genmeta-access/Cargo.toml b/genmeta-access/Cargo.toml index 4d50a21..cd38767 100644 --- a/genmeta-access/Cargo.toml +++ b/genmeta-access/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-access" description = "access control rule management" -version = "0.4.0-beta.2" +version = "0.4.0-beta.3" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-curl/Cargo.toml b/genmeta-curl/Cargo.toml index 58ceef9..b8bb397 100644 --- a/genmeta-curl/Cargo.toml +++ b/genmeta-curl/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-curl" description = "curl-like DHTTP/3 client" -version = "0.7.0-beta.3" +version = "0.7.0-beta.4" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-discover/Cargo.toml b/genmeta-discover/Cargo.toml index 4ac70b5..8d99669 100644 --- a/genmeta-discover/Cargo.toml +++ b/genmeta-discover/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-discover" description = "mdns discover services" -version = "0.4.0-beta.2" +version = "0.4.0-beta.3" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-doctor/Cargo.toml b/genmeta-doctor/Cargo.toml index dcb4d34..9d1f7c5 100644 --- a/genmeta-doctor/Cargo.toml +++ b/genmeta-doctor/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-doctor" description = "diagnosing and fixing environment issues" -version = "0.4.0-beta.2" +version = "0.4.0-beta.3" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-identity/Cargo.toml b/genmeta-identity/Cargo.toml index e4f5579..a1997bb 100644 --- a/genmeta-identity/Cargo.toml +++ b/genmeta-identity/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-identity" description = "managing identities for genmeta network" -version = "0.4.0-beta.3" +version = "0.4.0-beta.4" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index 669a53a..f65c4b4 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -157,9 +157,11 @@ async fn resolve_default_target_name(dhttp_home: &DhttpHome) -> Result Ok(name), - None => whatever!( - "No default identity configured. Use `genmeta identity default ` to set one." - ), + None => { + whatever!( + "No default identity configured. Use `genmeta identity default ` to set one." + ); + } } } @@ -284,9 +286,11 @@ impl Info { Some(n) => parse_identity_name(n)?, None => match default_name.clone() { Some(n) => n, - None => whatever!( - "No default identity configured. Use `genmeta identity default ` to set one." - ), + None => { + whatever!( + "No default identity configured. Use `genmeta identity default ` to set one." + ); + } }, }; let Some(summary) = flow::local::try_load_summary_exact( diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index 9f91a3f..c6ae8a5 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -55,9 +55,11 @@ pub(crate) async fn run( let Some(name) = command.name.as_deref() else { let name = match configured_default_name.as_ref() { Some(name) => name.borrow(), - None => whatever!( - "No default identity configured. Use `genmeta identity default ` to set one." - ), + None => { + whatever!( + "No default identity configured. Use `genmeta identity default ` to set one." + ); + } }; let summary = local::load_summary_exact(dhttp_home, name, configured_default_name.clone()).await?; diff --git a/genmeta-nat/Cargo.toml b/genmeta-nat/Cargo.toml index 1219438..ea62b70 100644 --- a/genmeta-nat/Cargo.toml +++ b/genmeta-nat/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-nat" description = "Diagnose network and environment issues" -version = "0.5.0-beta.2" +version = "0.5.0-beta.3" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-nslookup/Cargo.toml b/genmeta-nslookup/Cargo.toml index de86317..696baf9 100644 --- a/genmeta-nslookup/Cargo.toml +++ b/genmeta-nslookup/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-nslookup" description = "resolve domain names" -version = "0.5.0-beta.2" +version = "0.5.0-beta.3" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-proxy/Cargo.toml b/genmeta-proxy/Cargo.toml index 80eb476..be1e39c 100644 --- a/genmeta-proxy/Cargo.toml +++ b/genmeta-proxy/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-proxy" description = "forward proxy routing .dhttp.net requests over DHTTP/3" -version = "0.4.0-beta.2" +version = "0.4.0-beta.3" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-ssh/Cargo.toml b/genmeta-ssh/Cargo.toml index 98bfa9e..abf9e9c 100644 --- a/genmeta-ssh/Cargo.toml +++ b/genmeta-ssh/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-ssh" description = "DShell client" -version = "0.7.0-beta.3" +version = "0.7.0-beta.4" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta/Cargo.toml b/genmeta/Cargo.toml index 6f2061d..a9a0165 100644 --- a/genmeta/Cargo.toml +++ b/genmeta/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta" description = "Genmeta Binary Utilities" -version = "0.8.0-beta.3" +version = "0.8.0-beta.4" edition.workspace = true license.workspace = true repository.workspace = true