Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion genmeta-identity/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 20 additions & 6 deletions genmeta-identity/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
}
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 86 additions & 8 deletions genmeta-identity/src/cert_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,6 +58,7 @@ struct ErrorResponse {

#[derive(Debug, Deserialize)]
struct ErrorEnvelope {
#[serde(deserialize_with = "deserialize_error_code")]
code: String,
message: String,
}
Expand All @@ -69,11 +70,51 @@ struct DetailedErrorResponse<T> {

#[derive(Debug, Deserialize)]
struct DetailedErrorEnvelope<T> {
#[serde(deserialize_with = "deserialize_error_code")]
code: String,
message: String,
details: T,
}

fn deserialize_error_code<'de, D>(deserializer: D) -> Result<String, D::Error>
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<String> {
keys.iter()
.find_map(|key| value.get(*key)?.as_str().map(ToOwned::to_owned))
Expand Down Expand Up @@ -114,7 +155,7 @@ fn parse_subdomain_quota_quote(
let Ok(parsed) = serde_json::from_slice::<DetailedErrorResponse<Value>>(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;
Expand Down Expand Up @@ -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)
)
}
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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",
Expand Down
28 changes: 26 additions & 2 deletions genmeta-identity/src/cli/flow/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ mod tests {
&api,
&ui,
EmailLogin::Account,
Some("not-an-email"),
Some("luffy.a@b"),
None,
true,
)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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());
}
}
Loading