diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3dd5d..e3dede4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ 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. +- 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 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 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 ### Added 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-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 2cc74d3..bf59961 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, ) @@ -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] @@ -741,4 +744,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/flow/progress.rs b/genmeta-identity/src/cli/flow/progress.rs index 744b8fc..48b603f 100644 --- a/genmeta-identity/src/cli/flow/progress.rs +++ b/genmeta-identity/src/cli/flow/progress.rs @@ -3,43 +3,66 @@ 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)] +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( - "Generating CSR and requesting certificate...", - "Generated CSR and requested certificate.", +pub(crate) const REQUEST_CERT: ProgressCopy = ProgressCopy::retain_on_tty( + "Generating CSR locally and requesting certificate...", + "Generated CSR locally 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, @@ -48,9 +71,11 @@ 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; - if result.is_ok() { - retain_success(&span, copy.success); + let result = future.await; + if result.is_ok() + && let Some(success) = copy.completion_message(io::stderr().is_terminal()) + { + span.pb_set_finish_message(success); } drop(span); result @@ -63,25 +88,16 @@ 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() - }; - if result.is_ok() { - retain_success(&span, copy.success); + let result = operation(); + 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 +110,51 @@ 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, run_sync, }; #[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.", - ) + GENERATE_KEY.completion_message(true), + Some("Generated secp384r1 ECC key pair locally.") ); - 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." + REQUEST_CERT.running, + "Generating CSR locally and requesting certificate..." ); assert_eq!( - REQUEST_CERT.success, - "Generated CSR and requested certificate." + REQUEST_CERT.completion_message(true), + Some("Generated CSR locally 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)] @@ -161,4 +196,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")); + } } 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)" ); } 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." )) 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 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}} }