From bf4969527bc4dbf1c8a3e16dd445332928f89b39 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 16 Jul 2026 21:12:07 +0800 Subject: [PATCH 1/9] fix(identity): validate email before certserver requests --- genmeta-identity/src/cli/flow/email.rs | 23 ++++++- genmeta-identity/src/cli/validator.rs | 83 +++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/genmeta-identity/src/cli/flow/email.rs b/genmeta-identity/src/cli/flow/email.rs index 2cc74d3..8e30df7 100644 --- a/genmeta-identity/src/cli/flow/email.rs +++ b/genmeta-identity/src/cli/flow/email.rs @@ -530,7 +530,7 @@ mod tests { &api, &ui, EmailLogin::Account, - Some("not-an-email"), + Some("luffy.a@b"), None, true, ) @@ -741,4 +741,25 @@ mod tests { .unwrap(); assert_eq!(api.calls(), ["verify-account:a@b.test:000000"]); } + + #[tokio::test] + async fn noninteractive_email_rejects_the_same_invalid_shape_before_verifying() { + let api = FakeEmailApi::accepting("token"); + let error = run_email_session( + &api, + &ScriptedEmailUi::new([]), + EmailLogin::Account, + Some("luffy.a@b"), + Some("000000"), + false, + ) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "Invalid email address. Please enter a valid email address." + ); + assert!(api.calls().is_empty()); + } } diff --git a/genmeta-identity/src/cli/validator.rs b/genmeta-identity/src/cli/validator.rs index f9f58b9..da3aaa6 100644 --- a/genmeta-identity/src/cli/validator.rs +++ b/genmeta-identity/src/cli/validator.rs @@ -47,7 +47,48 @@ impl StringValidator for KindValidator { pub struct EmailValidator; pub(crate) fn is_valid_email(input: &str) -> bool { - input.contains('@') && input.contains('.') + let trimmed = input.trim(); + if trimmed.is_empty() || trimmed.len() > 254 { + return false; + } + + let Some((local, domain)) = trimmed.split_once('@') else { + return false; + }; + if local.is_empty() || local.len() > 64 { + return false; + } + if local.starts_with('.') || local.ends_with('.') || local.contains("..") { + return false; + } + if domain.contains('@') { + return false; + } + + is_valid_email_domain(&domain.to_ascii_lowercase()) +} + +fn is_valid_email_domain(domain: &str) -> bool { + if domain.is_empty() || domain.len() > 253 { + return false; + } + + let mut label_count = 0; + for label in domain.split('.') { + label_count += 1; + let len = label.len(); + if !(1..=63).contains(&len) + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == b'-') + { + return false; + } + } + + label_count >= 2 } impl StringValidator for EmailValidator { @@ -85,10 +126,48 @@ mod tests { assert!(validate_kind("device").is_err()); } + #[test] + fn email_validation_matches_the_certserver_boundary() { + for valid in [ + "alice@example.com", + "Alice@Example.COM", + "alice.example+billing@example.com", + " alice@example.com ", + ] { + assert!(is_valid_email(valid), "expected valid email: {valid}"); + } + + let too_long_local = format!("{}@example.com", "a".repeat(65)); + let too_long_address = format!("{}@example.com", "a".repeat(243)); + for invalid in [ + "".to_string(), + "alice".to_string(), + "@example.com".to_string(), + "alice@".to_string(), + "alice@example@com".to_string(), + ".alice@example.com".to_string(), + "alice.@example.com".to_string(), + "alice..billing@example.com".to_string(), + "luffy.a@b".to_string(), + "alice@.example.com".to_string(), + "alice@-example.com".to_string(), + "alice@example-.com".to_string(), + "alice@bad_domain.com".to_string(), + "alice@example..com".to_string(), + too_long_local, + too_long_address, + ] { + assert!( + !is_valid_email(&invalid), + "expected invalid email: {invalid}" + ); + } + } + #[test] fn invalid_email_uses_the_approved_actionable_copy() { assert_eq!( - EmailValidator.validate("not-an-email").unwrap(), + EmailValidator.validate("luffy.a@b").unwrap(), Validation::Invalid(inquire::validator::ErrorMessage::from( "Invalid email address. Please enter a valid email address." )) From 94be31cb8be9b95cd51d2a698e6a2ae9a05013b4 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 16 Jul 2026 21:13:55 +0800 Subject: [PATCH 2/9] fix(identity): preserve certserver error codes --- genmeta-identity/src/auth.rs | 26 +++++-- genmeta-identity/src/cert_server.rs | 94 +++++++++++++++++++++-- genmeta-identity/src/cli/flow/email.rs | 5 +- genmeta-identity/src/cli/flow/recovery.rs | 89 ++++++++++++--------- genmeta-identity/src/cli/flow/renew.rs | 4 +- 5 files changed, 163 insertions(+), 55 deletions(-) diff --git a/genmeta-identity/src/auth.rs b/genmeta-identity/src/auth.rs index 716aa68..eace03f 100644 --- a/genmeta-identity/src/auth.rs +++ b/genmeta-identity/src/auth.rs @@ -8,17 +8,20 @@ pub(crate) enum AuthAttemptDisposition { pub(crate) fn classify_identity_attempt( error: &crate::cert_server::Error, ) -> AuthAttemptDisposition { - match error { - crate::cert_server::Error::Api { status, code, .. } => match (*status, code.as_str()) { - (reqwest::StatusCode::UNAUTHORIZED, "unauthorized") - | (reqwest::StatusCode::FORBIDDEN, "domain_forbidden") => { + if let crate::cert_server::Error::Api { status, .. } = error { + return match (*status, error.api_code()) { + (reqwest::StatusCode::UNAUTHORIZED, Some("unauthorized")) + | (reqwest::StatusCode::FORBIDDEN, Some("domain_forbidden")) => { AuthAttemptDisposition::TryNext } - (reqwest::StatusCode::NOT_FOUND, "domain_not_found") => { + (reqwest::StatusCode::NOT_FOUND, Some("domain_not_found")) => { AuthAttemptDisposition::ReplanMissingTarget } _ => AuthAttemptDisposition::Terminal, - }, + }; + } + + match error { crate::cert_server::Error::DhttpEndpointFromProfile { source: dhttp::endpoint::LoadEndpointFromPathError::LoadIdentity { .. }, } => AuthAttemptDisposition::TryNext, @@ -32,6 +35,9 @@ pub(crate) fn classify_identity_attempt( | crate::cert_server::Error::DhttpEndpointFromProfile { .. } => { AuthAttemptDisposition::Terminal } + crate::cert_server::Error::Api { .. } => { + unreachable!("API errors returned from the branch above") + } } } @@ -53,6 +59,10 @@ mod tests { classify_identity_attempt(&api(reqwest::StatusCode::UNAUTHORIZED, "unauthorized")), AuthAttemptDisposition::TryNext ); + assert_eq!( + classify_identity_attempt(&api(reqwest::StatusCode::UNAUTHORIZED, "1002")), + AuthAttemptDisposition::TryNext + ); assert_eq!( classify_identity_attempt(&api(reqwest::StatusCode::FORBIDDEN, "domain_forbidden")), AuthAttemptDisposition::TryNext @@ -83,6 +93,10 @@ mod tests { classify_identity_attempt(&api(reqwest::StatusCode::NOT_FOUND, "domain_not_found")), AuthAttemptDisposition::ReplanMissingTarget ); + assert_eq!( + classify_identity_attempt(&api(reqwest::StatusCode::NOT_FOUND, "1202")), + AuthAttemptDisposition::ReplanMissingTarget + ); assert_eq!( classify_identity_attempt(&api( reqwest::StatusCode::NOT_FOUND, diff --git a/genmeta-identity/src/cert_server.rs b/genmeta-identity/src/cert_server.rs index c7d6e1f..198b062 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("{message}"))] + #[snafu(display("{message} (error code: {code})"))] Api { status: reqwest::StatusCode, code: String, @@ -58,6 +58,7 @@ struct ErrorResponse { #[derive(Debug, Deserialize)] struct ErrorEnvelope { + #[serde(deserialize_with = "deserialize_error_code")] code: String, message: String, } @@ -69,11 +70,51 @@ struct DetailedErrorResponse { #[derive(Debug, Deserialize)] struct DetailedErrorEnvelope { + #[serde(deserialize_with = "deserialize_error_code")] code: String, message: String, details: T, } +fn deserialize_error_code<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum WireErrorCode { + Symbol(String), + Number(u16), + } + + Ok(match WireErrorCode::deserialize(deserializer)? { + WireErrorCode::Symbol(code) => code, + WireErrorCode::Number(code) => code.to_string(), + }) +} + +fn normalize_api_code(code: &str) -> &str { + match code { + "1002" => "unauthorized", + "1101" => "email_invalid", + "1102" => "verify_code_invalid", + "1103" => "verify_code_expired", + "1104" => "verify_code_attempt_exceeded", + "1105" => "verify_code_too_frequent", + "1106" => "verify_code_rate_limited", + "1110" => "user_blocked", + "1201" => "domain_invalid", + "1202" => "domain_not_found", + "1203" => "domain_forbidden", + "1208" => "domain_conflict", + "1211" => "domain_email_not_matched", + "1303" => "subdomain_conflict", + "1304" => "subdomain_quota_exceeded", + "1407" => "cert_sequence_not_found", + _ => code, + } +} + fn extract_string_field(value: &Value, keys: &[&str]) -> Option { keys.iter() .find_map(|key| value.get(*key)?.as_str().map(ToOwned::to_owned)) @@ -114,7 +155,7 @@ fn parse_subdomain_quota_quote( let Ok(parsed) = serde_json::from_slice::>(body) else { return Ok(None); }; - if parsed.error.code != "subdomain_quota_exceeded" { + if normalize_api_code(&parsed.error.code) != "subdomain_quota_exceeded" { return Ok(None); } let _ = &parsed.error.message; @@ -927,20 +968,20 @@ impl Error { pub fn api_code(&self) -> Option<&str> { match self { - Self::Api { code, .. } => Some(code.as_str()), + Self::Api { code, .. } => Some(normalize_api_code(code)), _ => None, } } pub fn is_api_code(&self, expected: &str) -> bool { - matches!(self.api_code(), Some(code) if code == expected) + self.api_code() == Some(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 + Self::Api { status, .. } + if *status == expected_status && self.api_code() == Some(expected_code) ) } } @@ -997,7 +1038,44 @@ mod tests { message: "domain access is forbidden".to_string(), }; - assert_eq!(error.to_string(), "domain access is forbidden"); + assert_eq!( + error.to_string(), + "domain access is forbidden (error code: domain_forbidden)" + ); + } + + #[test] + fn parses_numeric_v2_error_code_and_preserves_the_wire_value() { + let payload = br#"{"error":{"code":1101,"message":"The email address is invalid."}}"#; + let error = parse_error_body(reqwest::StatusCode::BAD_REQUEST, payload).unwrap_err(); + + assert_eq!(error.api_code(), Some("email_invalid")); + assert_eq!( + error.to_string(), + "The email address is invalid. (error code: 1101)" + ); + match error { + Error::Api { code, .. } => assert_eq!(code, "1101"), + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn unknown_numeric_error_code_remains_decimal() { + let payload = br#"{"error":{"code":2999,"message":"future problem"}}"#; + let error = parse_error_body(reqwest::StatusCode::CONFLICT, payload).unwrap_err(); + + assert_eq!(error.api_code(), Some("2999")); + assert_eq!(error.to_string(), "future problem (error code: 2999)"); + } + + #[test] + fn malformed_error_code_stays_a_json_response_error() { + let payload = br#"{"error":{"code":true,"message":"invalid envelope"}}"#; + assert!(matches!( + parse_error_body(reqwest::StatusCode::BAD_REQUEST, payload), + Err(Error::Json { .. }) + )); } #[test] @@ -1045,7 +1123,7 @@ mod tests { fn subdomain_quota_error_details_are_parsed() { let payload = br#"{ "error": { - "code": "subdomain_quota_exceeded", + "code": 1304, "message": "subdomain quota exceeded", "details": { "domain": "phone.alice.smith.dhttp.net", diff --git a/genmeta-identity/src/cli/flow/email.rs b/genmeta-identity/src/cli/flow/email.rs index 8e30df7..bf59961 100644 --- a/genmeta-identity/src/cli/flow/email.rs +++ b/genmeta-identity/src/cli/flow/email.rs @@ -632,7 +632,10 @@ mod tests { assert_eq!(token, "token"); assert_eq!(api.send_count(), 1); - assert_eq!(ui.printed_messages(), ["verification code is incorrect"]); + assert_eq!( + ui.printed_messages(), + ["verification code is incorrect (error code: verify_code_invalid)"] + ); } #[tokio::test] diff --git a/genmeta-identity/src/cli/flow/recovery.rs b/genmeta-identity/src/cli/flow/recovery.rs index 5de22e1..2dd8f93 100644 --- a/genmeta-identity/src/cli/flow/recovery.rs +++ b/genmeta-identity/src/cli/flow/recovery.rs @@ -9,44 +9,30 @@ pub(crate) enum VerificationRecovery { pub(crate) fn classify_verify_submit_error( error: &crate::cert_server::Error, ) -> VerificationRecovery { - match error { - crate::cert_server::Error::Api { - status, - code, - message, - } => match (*status, code.as_str()) { - (reqwest::StatusCode::UNAUTHORIZED, "verify_code_invalid") => { - VerificationRecovery::RetryCode { - message: message.clone(), - } - } - (reqwest::StatusCode::UNAUTHORIZED, "verify_code_expired") => { - VerificationRecovery::OfferResend { - message: message.clone(), - } - } - (reqwest::StatusCode::UNAUTHORIZED, "domain_email_not_matched") => { - VerificationRecovery::ChangeEmail { - message: message.clone(), - } - } - ( - reqwest::StatusCode::TOO_MANY_REQUESTS, + let crate::cert_server::Error::Api { status, .. } = error else { + return VerificationRecovery::Stop; + }; + let message = error.to_string(); + match (*status, error.api_code()) { + (reqwest::StatusCode::UNAUTHORIZED, Some("verify_code_invalid")) => { + VerificationRecovery::RetryCode { message } + } + (reqwest::StatusCode::UNAUTHORIZED, Some("verify_code_expired")) => { + VerificationRecovery::OfferResend { message } + } + (reqwest::StatusCode::UNAUTHORIZED, Some("domain_email_not_matched")) => { + VerificationRecovery::ChangeEmail { message } + } + ( + reqwest::StatusCode::TOO_MANY_REQUESTS, + Some( "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::DhttpEndpointFromProfile { .. } - | 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, + ), + ) + | (reqwest::StatusCode::FORBIDDEN, Some("user_blocked")) => VerificationRecovery::Stop, + _ => VerificationRecovery::Stop, } } @@ -71,7 +57,8 @@ mod tests { "verification code is incorrect", )), VerificationRecovery::RetryCode { - message: "verification code is incorrect".to_string(), + message: "verification code is incorrect (error code: verify_code_invalid)" + .to_string(), } ); assert_eq!( @@ -81,7 +68,7 @@ mod tests { "verification code expired", )), VerificationRecovery::OfferResend { - message: "verification code expired".to_string(), + message: "verification code expired (error code: verify_code_expired)".to_string(), } ); assert_eq!( @@ -91,7 +78,33 @@ mod tests { "email does not match this identity", )), VerificationRecovery::ChangeEmail { - message: "email does not match this identity".to_string(), + message: + "email does not match this identity (error code: domain_email_not_matched)" + .to_string(), + } + ); + } + + #[test] + fn numeric_codes_select_the_same_recovery_and_keep_the_wire_code_in_copy() { + assert_eq!( + classify_verify_submit_error(&api( + reqwest::StatusCode::UNAUTHORIZED, + "1102", + "verification code is incorrect", + )), + VerificationRecovery::RetryCode { + message: "verification code is incorrect (error code: 1102)".to_string(), + } + ); + assert_eq!( + classify_verify_submit_error(&api( + reqwest::StatusCode::UNAUTHORIZED, + "1103", + "verification code expired", + )), + VerificationRecovery::OfferResend { + message: "verification code expired (error code: 1103)".to_string(), } ); } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index a16772b..7cddf06 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -419,7 +419,7 @@ mod tests { }; assert_eq!( 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." + "certificate chain was not found (error code: cert_sequence_not_found)\nRun `genmeta identity apply alice.smith` to request a new certificate chain." ); } @@ -433,7 +433,7 @@ mod tests { assert_eq!( renew_remote_error(error, "alice.smith").to_string(), - "internal server error" + "internal server error (error code: cert_sequence_not_found)" ); } From 2a21a4f4e2a32530811f499276f39dbe2029a754 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 16 Jul 2026 21:15:42 +0800 Subject: [PATCH 3/9] fix(identity): scope progress completion to tty --- genmeta-identity/src/cli/flow/progress.rs | 132 ++++++++++++++-------- 1 file changed, 83 insertions(+), 49 deletions(-) diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index 744b8fc..2593e68 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -6,40 +6,63 @@ use std::{ use tracing::{Instrument, info_span}; use tracing_indicatif::span_ext::IndicatifSpanExt; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompletionPolicy { + Clear, + RetainOnTty, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct ProgressCopy { pub(crate) running: &'static str, - pub(crate) success: &'static str, + success: &'static str, + completion: CompletionPolicy, } impl ProgressCopy { - pub(crate) const fn new(running: &'static str, success: &'static str) -> Self { - Self { running, success } + const fn clear(running: &'static str) -> Self { + Self { + running, + success: "", + completion: CompletionPolicy::Clear, + } + } + + const fn retain_on_tty(running: &'static str, success: &'static str) -> Self { + Self { + running, + success, + completion: CompletionPolicy::RetainOnTty, + } + } + + fn completion_message(self, is_terminal: bool) -> Option<&'static str> { + if !is_terminal { + return None; + } + match self.completion { + CompletionPolicy::Clear => None, + CompletionPolicy::RetainOnTty => Some(self.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( +pub(crate) const CHECK_NAME: ProgressCopy = + ProgressCopy::clear("Checking the validity of this name..."); +pub(crate) const SEND_CODE: ProgressCopy = ProgressCopy::clear("Sending verification code..."); +pub(crate) const VERIFY_EMAIL: ProgressCopy = ProgressCopy::clear("Verifying with email..."); +pub(crate) const GENERATE_KEY: ProgressCopy = ProgressCopy::retain_on_tty( "Generating secp384r1 ECC key pair locally...", "Generated secp384r1 ECC key pair locally.", ); -pub(crate) const REQUEST_CERT: ProgressCopy = ProgressCopy::new( +pub(crate) const REQUEST_CERT: ProgressCopy = ProgressCopy::retain_on_tty( "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 = - ProgressCopy::new("Saving default identity...", "Saved default identity."); + ProgressCopy::clear("Waiting for payment completion..."); +pub(crate) const RENEW_IDENTITY: ProgressCopy = ProgressCopy::clear("Renewing identity..."); +pub(crate) const SAVE_DEFAULT: ProgressCopy = ProgressCopy::clear("Saving default identity..."); pub(crate) async fn run( copy: ProgressCopy, @@ -49,8 +72,10 @@ pub(crate) async fn run( span.pb_set_message(copy.running); span.pb_start(); let result = future.instrument(span.clone()).await; - if result.is_ok() { - retain_success(&span, copy.success); + if result.is_ok() + && let Some(success) = copy.completion_message(io::stderr().is_terminal()) + { + span.pb_set_finish_message(success); } drop(span); result @@ -67,21 +92,15 @@ pub(crate) fn run_sync( let _entered = span.enter(); operation() }; - if result.is_ok() { - retain_success(&span, copy.success); + if result.is_ok() + && let Some(success) = copy.completion_message(io::stderr().is_terminal()) + { + span.pb_set_finish_message(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::{ @@ -94,32 +113,47 @@ mod tests { use tracing_subscriber::{Layer, layer::SubscriberExt, registry::LookupSpan}; use super::{ - CHECK_NAME, GENERATE_KEY, ProgressCopy, RENEW_IDENTITY, REQUEST_CERT, SAVE_DEFAULT, - SEND_CODE, VERIFY_EMAIL, WAIT_FOR_PAYMENT, run, + CHECK_NAME, GENERATE_KEY, RENEW_IDENTITY, REQUEST_CERT, SAVE_DEFAULT, SEND_CODE, + VERIFY_EMAIL, WAIT_FOR_PAYMENT, run, }; #[test] - fn progress_pairs_are_stable() { + fn only_key_and_certificate_request_retain_success_on_tty() { 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." + GENERATE_KEY.completion_message(true), + Some("Generated secp384r1 ECC key pair locally.") ); assert_eq!( - REQUEST_CERT.success, - "Generated CSR and requested certificate." + REQUEST_CERT.completion_message(true), + Some("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."); + + for copy in [ + CHECK_NAME, + SEND_CODE, + VERIFY_EMAIL, + WAIT_FOR_PAYMENT, + RENEW_IDENTITY, + SAVE_DEFAULT, + ] { + assert_eq!(copy.completion_message(true), None, "copy: {copy:?}"); + } + } + + #[test] + fn non_tty_progress_has_no_completion_copy() { + for copy in [ + CHECK_NAME, + SEND_CODE, + VERIFY_EMAIL, + GENERATE_KEY, + REQUEST_CERT, + WAIT_FOR_PAYMENT, + RENEW_IDENTITY, + SAVE_DEFAULT, + ] { + assert_eq!(copy.completion_message(false), None, "copy: {copy:?}"); + } } #[derive(Clone, Default)] From c750cdd1bc5e39a26fb943c0cf6c09da3c1394fe Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 16 Jul 2026 21:16:00 +0800 Subject: [PATCH 4/9] docs(identity): record email and progress fixes --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3dd5d..8ba911e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Renewal preserves the original certificate-chain kind and sequence, performs strict local preflight, installs validated material transactionally, and prints a visible success result. +- Identity progress clears transient name, email, payment, renewal, and default + operations. Interactive terminals retain only local key generation and + certificate-request success; non-interactive execution emits no progress copy. ### Fixed @@ -56,6 +59,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Identity email validation now rejects malformed local/domain boundaries before + contacting certserver and uses the same validation for prompted and explicit + email input. +- Certserver errors accept current numeric codes and legacy symbolic codes, + preserve the server-provided message, display the wire error code, and use the + same normalized recovery decisions for both representations. ### Fixed From 0870d9a109dd0fc0e7f336974e0d57bc12c3ddf4 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 16 Jul 2026 23:25:03 +0800 Subject: [PATCH 5/9] fix(identity): finalize progress before prompts --- genmeta-identity/src/cli/flow/progress.rs | 31 ++++++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index 2593e68..64b29be 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -3,7 +3,7 @@ use std::{ io::{self, IsTerminal}, }; -use tracing::{Instrument, info_span}; +use tracing::info_span; use tracing_indicatif::span_ext::IndicatifSpanExt; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -71,7 +71,7 @@ pub(crate) async fn run( let span = info_span!("cli_progress", indicatif.pb_show = tracing::field::Empty); span.pb_set_message(copy.running); span.pb_start(); - let result = future.instrument(span.clone()).await; + let result = future.await; if result.is_ok() && let Some(success) = copy.completion_message(io::stderr().is_terminal()) { @@ -88,10 +88,7 @@ pub(crate) fn run_sync( let span = info_span!("cli_progress", indicatif.pb_show = tracing::field::Empty); span.pb_set_message(copy.running); span.pb_start(); - let result = { - let _entered = span.enter(); - operation() - }; + let result = operation(); if result.is_ok() && let Some(success) = copy.completion_message(io::stderr().is_terminal()) { @@ -114,7 +111,7 @@ mod tests { use super::{ CHECK_NAME, GENERATE_KEY, RENEW_IDENTITY, REQUEST_CERT, SAVE_DEFAULT, SEND_CODE, - VERIFY_EMAIL, WAIT_FOR_PAYMENT, run, + VERIFY_EMAIL, WAIT_FOR_PAYMENT, run, run_sync, }; #[test] @@ -195,4 +192,24 @@ mod tests { .unwrap(); assert_eq!(seen.load(Ordering::SeqCst), 1); } + + #[tokio::test(flavor = "current_thread")] + async fn operations_do_not_inherit_the_ui_progress_span() { + let subscriber = tracing_subscriber::registry() + .with(CountLayer::default().with_filter(IndicatifFilter::new(false))); + let _guard = tracing::subscriber::set_default(subscriber); + + let async_parent = run(SEND_CODE, async { + Ok::<_, std::io::Error>(tracing::Span::current().metadata().map(|meta| meta.name())) + }) + .await + .unwrap(); + assert_ne!(async_parent, Some("cli_progress")); + + let sync_parent = run_sync(SEND_CODE, || { + Ok::<_, std::io::Error>(tracing::Span::current().metadata().map(|meta| meta.name())) + }) + .unwrap(); + assert_ne!(sync_parent, Some("cli_progress")); + } } From 0936cdb85925568d72e5ecf3e375b911c0054aa3 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 16 Jul 2026 23:46:10 +0800 Subject: [PATCH 6/9] docs(identity): keep pending fixes unreleased --- CHANGELOG.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba911e..b3db8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Identity progress clears transient name, email, payment, renewal, and default + operations. Interactive terminals retain only local key generation and + certificate-request success; non-interactive execution emits no progress copy. + +### Fixed + +- Identity email validation now rejects malformed local/domain boundaries before + contacting certserver and uses the same validation for prompted and explicit + email input. +- Certserver errors accept current numeric codes and legacy symbolic codes, + preserve the server-provided message, display the wire error code, and use the + same normalized recovery decisions for both representations. + ## [0.8.0-beta.4] - 2026-07-16 ### Added @@ -47,9 +62,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Renewal preserves the original certificate-chain kind and sequence, performs strict local preflight, installs validated material transactionally, and prints a visible success result. -- Identity progress clears transient name, email, payment, renewal, and default - operations. Interactive terminals retain only local key generation and - certificate-request success; non-interactive execution emits no progress copy. ### Fixed @@ -59,12 +71,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. -- Identity email validation now rejects malformed local/domain boundaries before - contacting certserver and uses the same validation for prompted and explicit - email input. -- Certserver errors accept current numeric codes and legacy symbolic codes, - preserve the server-provided message, display the wire error code, and use the - same normalized recovery decisions for both representations. ### Fixed From 41593501dfb8cd5723fe9f7193ff4fdaec6317fd Mon Sep 17 00:00:00 2001 From: eareimu Date: Fri, 17 Jul 2026 01:08:33 +0800 Subject: [PATCH 7/9] fix(identity): clarify local csr generation --- CHANGELOG.md | 4 ++-- genmeta-identity/src/cli/flow/progress.rs | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3db8a0..decb9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Identity progress clears transient name, email, payment, renewal, and default - operations. Interactive terminals retain only local key generation and - certificate-request success; non-interactive execution emits no progress copy. + operations. Interactive terminals retain only local key generation and locally + generated CSR completion; non-interactive execution emits no progress copy. ### Fixed diff --git a/genmeta-identity/src/cli/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index 64b29be..48b603f 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -56,8 +56,8 @@ pub(crate) const GENERATE_KEY: ProgressCopy = ProgressCopy::retain_on_tty( "Generated secp384r1 ECC key pair locally.", ); pub(crate) const REQUEST_CERT: ProgressCopy = ProgressCopy::retain_on_tty( - "Generating CSR and requesting certificate...", - "Generated CSR and requested certificate.", + "Generating CSR locally and requesting certificate...", + "Generated CSR locally and requested certificate.", ); pub(crate) const WAIT_FOR_PAYMENT: ProgressCopy = ProgressCopy::clear("Waiting for payment completion..."); @@ -120,9 +120,13 @@ mod tests { GENERATE_KEY.completion_message(true), Some("Generated secp384r1 ECC key pair locally.") ); + assert_eq!( + REQUEST_CERT.running, + "Generating CSR locally and requesting certificate..." + ); assert_eq!( REQUEST_CERT.completion_message(true), - Some("Generated CSR and requested certificate.") + Some("Generated CSR locally and requested certificate.") ); for copy in [ From edaf06bc858561d055e68cf1e50960bce2715323 Mon Sep 17 00:00:00 2001 From: eareimu Date: Fri, 17 Jul 2026 01:20:48 +0800 Subject: [PATCH 8/9] fix(release): render channel-aware scoop metadata --- xtask/Cargo.lock | 4 ++-- xtask/Cargo.toml | 2 +- xtask/templates/gmutils.json.in | 5 +---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/xtask/Cargo.lock b/xtask/Cargo.lock index 1bcf7ff..9df94ad 100644 --- a/xtask/Cargo.lock +++ b/xtask/Cargo.lock @@ -938,8 +938,8 @@ dependencies = [ [[package]] name = "genmeta-xtask-release" -version = "0.2.0-beta.6" -source = "git+https://github.com/genmeta/genmeta-xtask-release.git?tag=v0.2.0-beta.6#ef5b17211280e32af619f229e76b1036b1576d5c" +version = "0.2.0-beta.9" +source = "git+https://github.com/genmeta/genmeta-xtask-release.git?tag=v0.2.0-beta.9#f1df79e88602a9f1cf814103fbe8da4acc858d90" dependencies = [ "aws-credential-types", "aws-sdk-s3", diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 35ea76d..ecf6339 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -12,5 +12,5 @@ publish = false resolver = "3" [dependencies] -genmeta-xtask-release = { git = "https://github.com/genmeta/genmeta-xtask-release.git", tag = "v0.2.0-beta.8", version = "0.2.0-beta.8" } +genmeta-xtask-release = { git = "https://github.com/genmeta/genmeta-xtask-release.git", tag = "v0.2.0-beta.9", version = "0.2.0-beta.9" } snafu = "0.9" diff --git a/xtask/templates/gmutils.json.in b/xtask/templates/gmutils.json.in index 149610e..ec82699 100644 --- a/xtask/templates/gmutils.json.in +++ b/xtask/templates/gmutils.json.in @@ -8,9 +8,6 @@ "genmeta.exe", "genmeta-ssh.bat" ], - "checkver": { - "url": "https://download.dhttp.net/scoop/gmutils.json", - "re": "\\\"version\\\"\\\\s*:\\\\s*\\\"([^\\\"]+)\\\"" - }, + "checkver": {{scoop.checkver.json}}, "autoupdate": {{scoop.autoupdate.json}} } From e8ecddc583bccdcd9d227fa0e0f03d72b1e3f185 Mon Sep 17 00:00:00 2001 From: eareimu Date: Fri, 17 Jul 2026 01:23:39 +0800 Subject: [PATCH 9/9] chore: prepare genmeta v0.8.0-beta.5 --- CHANGELOG.md | 28 +++++++++++++++++++++++----- Cargo.lock | 4 ++-- Cargo.toml | 2 +- genmeta-identity/Cargo.toml | 2 +- genmeta/Cargo.toml | 2 +- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index decb9e6..e3dede4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,20 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0-beta.5] - 2026-07-17 + ### Changed - Identity progress clears transient name, email, payment, renewal, and default - operations. Interactive terminals retain only local key generation and locally - generated CSR completion; non-interactive execution emits no progress copy. + operations. Interactive terminals retain only local key generation and + locally generated CSR completion; non-interactive execution emits no progress + copy. +- Scoop package metadata derives its `checkver` URL from the selected stable or + preview channel and uses the canonical version-matching expression. ### Fixed -- Identity email validation now rejects malformed local/domain boundaries before +- Identity email validation rejects malformed local and domain boundaries before contacting certserver and uses the same validation for prompted and explicit email input. - Certserver errors accept current numeric codes and legacy symbolic codes, - preserve the server-provided message, display the wire error code, and use the - same normalized recovery decisions for both representations. + preserve the server-provided message, display the original wire error code, + and use the same normalized recovery decisions for both representations. +- Identity progress operations no longer inherit the display-only progress span, + ensuring completion is finalized before subsequent output and prompts. + +### Dependencies + +- Release packaging uses `genmeta-xtask-release` v0.2.0-beta.9 for + channel-aware Scoop metadata. + +### Components + +- `genmeta` v0.8.0-beta.5 +- `genmeta-identity` v0.4.0-beta.5 +- All other gmutils workspace member versions are unchanged from v0.8.0-beta.4. ## [0.8.0-beta.4] - 2026-07-16 diff --git a/Cargo.lock b/Cargo.lock index 504a537..63ada96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1600,7 +1600,7 @@ dependencies = [ [[package]] name = "genmeta" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" dependencies = [ "clap", "genmeta-access", @@ -1677,7 +1677,7 @@ dependencies = [ [[package]] name = "genmeta-identity" -version = "0.4.0-beta.4" +version = "0.4.0-beta.5" dependencies = [ "base64", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 2ce3e2f..1d6459c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ 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-identity = { path = "genmeta-identity", version = "0.4.0-beta.5", 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" } diff --git a/genmeta-identity/Cargo.toml b/genmeta-identity/Cargo.toml index a1997bb..56651a1 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.4" +version = "0.4.0-beta.5" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta/Cargo.toml b/genmeta/Cargo.toml index a9a0165..450ed1a 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.4" +version = "0.8.0-beta.5" edition.workspace = true license.workspace = true repository.workspace = true