use crate::primitives::ContentHash;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum AcdpError {
#[error("JCS canonicalization failed: {0}")]
Canonicalization(String),
#[error("content_hash mismatch\n stored: {stored}\n recomputed: {recomputed}")]
HashMismatch {
stored: ContentHash,
recomputed: ContentHash,
},
#[error("context substitution: requested {requested}, registry served {served}")]
ContextIdMismatch {
requested: String,
served: String,
},
#[error(
"incomplete lineage {lineage_id}: registry-served members did not satisfy the \
expected membership (expected ctx_id: {ctx_id:?})"
)]
IncompleteLineage {
lineage_id: String,
ctx_id: Option<String>,
},
#[error("search truncated: {0}")]
SearchTruncated(String),
#[error("revocation discovery budget exceeded: {0}")]
RevocationDiscoveryBudgetExceeded(String),
#[error("revocation auto-discovery failed: {source}")]
RevocationDiscoveryFailed {
source: Box<AcdpError>,
},
#[error("registry rejected hash_mismatch: {0}")]
RemoteHashMismatch(String),
#[error("data_ref hash mismatch: {0}")]
DataRefHashMismatch(String),
#[error("invalid signature: {0}")]
InvalidSignature(String),
#[error("key resolution failed: {0}")]
KeyResolution(String),
#[error("key resolution unreachable (transient): {0}")]
KeyResolutionUnreachable(String),
#[error("key not authorized: {0}")]
KeyNotAuthorized(String),
#[error("invalid body: {0}")]
InvalidBody(String),
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("schema violation: {0}")]
SchemaViolation(String),
#[error("payload too large: {0}")]
PayloadTooLarge(String),
#[error("embedded data reference too large: {0}")]
EmbeddedTooLarge(String),
#[error("unsupported algorithm: {0}")]
UnsupportedAlgorithm(String),
#[error("unsupported media type: {0}")]
UnsupportedMediaType(String),
#[error("not implemented: {0}")]
NotImplemented(String),
#[error("not found: {0}")]
NotFound(String),
#[error("not authorized: {0}")]
NotAuthorized(String),
#[error("rate limited: {0}")]
RateLimited(String),
#[error("search cursor expired")]
CursorExpired,
#[error("invalid cursor: {0}")]
InvalidCursor(String),
#[error("superseded target rejected ({reason:?}): {message}")]
SupersededTarget {
reason: SupersessionReason,
message: String,
},
#[error("duplicate publish: {0}")]
DuplicatePublish(String),
#[error("cross-registry resolution failed: {0}")]
CrossRegistryResolutionFailed(String),
#[error("invalid registry receipt: {0}")]
InvalidReceipt(String),
#[error("invalid transparency-log proof: {0}")]
InvalidLogProof(String),
#[error("invalid witness cosignature: {0}")]
InvalidWitnessCosignature(String),
#[error("immutable field: {0}")]
ImmutableField(String),
#[error("invalid lifecycle transition: {0}")]
InvalidLifecycleTransition(String),
#[error("registry internal error: {0}")]
RegistryInternal(String),
#[error("registry returned error: {0:?}")]
Registry(crate::wire_error::WireError),
#[error("serialization failed: {0}")]
Serialization(String),
#[error("HTTP error: {0}")]
Http(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum SupersessionReason {
NotFound,
LineageMismatch,
VersionMismatch,
AlreadySuperseded,
CrossRegistrySupersessionUnsupported,
LineageWalkFailed,
RevocationTypeMismatch,
#[serde(other)]
Other,
}
impl AcdpError {
pub fn is_transient(&self) -> bool {
if let AcdpError::RevocationDiscoveryFailed { source } = self {
return source.is_transient();
}
matches!(
self,
AcdpError::KeyResolutionUnreachable(_)
| AcdpError::RateLimited(_)
| AcdpError::CrossRegistryResolutionFailed(_)
| AcdpError::RegistryInternal(_)
| AcdpError::Http(_)
)
}
pub fn from_wire_error(wire: crate::wire_error::WireError) -> Self {
let code = wire.error.code.as_str();
let msg = wire.error.message.clone();
match code {
"invalid_signature" => AcdpError::InvalidSignature(msg),
"hash_mismatch" => AcdpError::RemoteHashMismatch(msg),
"data_ref_hash_mismatch" => AcdpError::DataRefHashMismatch(msg),
"schema_violation" => AcdpError::SchemaViolation(msg),
"not_authorized" => AcdpError::NotAuthorized(msg),
"not_found" => AcdpError::NotFound(msg),
"rate_limited" => AcdpError::RateLimited(msg),
"payload_too_large" => AcdpError::PayloadTooLarge(msg),
"embedded_too_large" => AcdpError::EmbeddedTooLarge(msg),
"key_resolution_failed" => AcdpError::KeyResolution(msg),
"key_resolution_unreachable" => AcdpError::KeyResolutionUnreachable(msg),
"key_not_authorized" => AcdpError::KeyNotAuthorized(msg),
"unsupported_algorithm" => AcdpError::UnsupportedAlgorithm(msg),
"unsupported_media_type" => AcdpError::UnsupportedMediaType(msg),
"not_implemented" => AcdpError::NotImplemented(msg),
"cursor_expired" => AcdpError::CursorExpired,
"invalid_cursor" => AcdpError::InvalidCursor(msg),
"duplicate_publish" => AcdpError::DuplicatePublish(msg),
"cross_registry_resolution_failed" => AcdpError::CrossRegistryResolutionFailed(msg),
"invalid_receipt" => AcdpError::InvalidReceipt(msg),
"invalid_log_proof" => AcdpError::InvalidLogProof(msg),
"invalid_witness_cosignature" => AcdpError::InvalidWitnessCosignature(msg),
"immutable_field" => AcdpError::ImmutableField(msg),
"invalid_lifecycle_transition" => AcdpError::InvalidLifecycleTransition(msg),
"internal_error" => AcdpError::RegistryInternal(msg),
"superseded_target" => {
let reason = wire
.error
.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|v| serde_json::from_value::<SupersessionReason>(v.clone()).ok())
.unwrap_or(SupersessionReason::Other);
AcdpError::SupersededTarget {
reason,
message: msg,
}
}
_ => AcdpError::Registry(wire),
}
}
}
impl From<serde_json::Error> for AcdpError {
fn from(e: serde_json::Error) -> Self {
AcdpError::Serialization(e.to_string())
}
}
impl From<std::io::Error> for AcdpError {
fn from(e: std::io::Error) -> Self {
AcdpError::Http(format!("io error: {e}"))
}
}
#[cfg(feature = "reqwest")]
impl From<reqwest::Error> for AcdpError {
fn from(e: reqwest::Error) -> Self {
let mut msg = e.to_string();
let mut source: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(s) = source {
msg.push_str(&format!(": {s}"));
source = s.source();
}
if e.is_connect() || e.is_timeout() {
AcdpError::Http(format!("connection failed: {msg}"))
} else {
AcdpError::Http(msg)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wire_error::{WireError, WireErrorBody};
use serde_json::json;
fn wire(code: &str, message: &str, details: Option<serde_json::Value>) -> WireError {
WireError {
error: WireErrorBody {
code: code.into(),
message: message.into(),
details,
},
}
}
#[test]
fn all_26_wire_codes_round_trip() {
type Check = fn(&AcdpError) -> bool;
let cases: &[(&str, Check)] = &[
("invalid_signature", |e| {
matches!(e, AcdpError::InvalidSignature(_))
}),
("hash_mismatch", |e| {
matches!(e, AcdpError::RemoteHashMismatch(_))
}),
("data_ref_hash_mismatch", |e| {
matches!(e, AcdpError::DataRefHashMismatch(_))
}),
("schema_violation", |e| {
matches!(e, AcdpError::SchemaViolation(_))
}),
("not_authorized", |e| {
matches!(e, AcdpError::NotAuthorized(_))
}),
("not_found", |e| matches!(e, AcdpError::NotFound(_))),
("superseded_target", |e| {
matches!(e, AcdpError::SupersededTarget { .. })
}),
("unsupported_algorithm", |e| {
matches!(e, AcdpError::UnsupportedAlgorithm(_))
}),
("rate_limited", |e| matches!(e, AcdpError::RateLimited(_))),
("payload_too_large", |e| {
matches!(e, AcdpError::PayloadTooLarge(_))
}),
("embedded_too_large", |e| {
matches!(e, AcdpError::EmbeddedTooLarge(_))
}),
("key_resolution_failed", |e| {
matches!(e, AcdpError::KeyResolution(_))
}),
("key_resolution_unreachable", |e| {
matches!(e, AcdpError::KeyResolutionUnreachable(_))
}),
("key_not_authorized", |e| {
matches!(e, AcdpError::KeyNotAuthorized(_))
}),
("not_implemented", |e| {
matches!(e, AcdpError::NotImplemented(_))
}),
("unsupported_media_type", |e| {
matches!(e, AcdpError::UnsupportedMediaType(_))
}),
("cursor_expired", |e| matches!(e, AcdpError::CursorExpired)),
("invalid_cursor", |e| {
matches!(e, AcdpError::InvalidCursor(_))
}),
("duplicate_publish", |e| {
matches!(e, AcdpError::DuplicatePublish(_))
}),
("cross_registry_resolution_failed", |e| {
matches!(e, AcdpError::CrossRegistryResolutionFailed(_))
}),
("invalid_receipt", |e| {
matches!(e, AcdpError::InvalidReceipt(_))
}),
("invalid_log_proof", |e| {
matches!(e, AcdpError::InvalidLogProof(_))
}),
("invalid_witness_cosignature", |e| {
matches!(e, AcdpError::InvalidWitnessCosignature(_))
}),
("immutable_field", |e| {
matches!(e, AcdpError::ImmutableField(_))
}),
("invalid_lifecycle_transition", |e| {
matches!(e, AcdpError::InvalidLifecycleTransition(_))
}),
("internal_error", |e| {
matches!(e, AcdpError::RegistryInternal(_))
}),
];
assert_eq!(cases.len(), 26);
for (code, expected) in cases {
let err = AcdpError::from_wire_error(wire(code, "msg", None));
assert!(
expected(&err),
"code '{code}' did not map to its typed variant: got {err:?}"
);
}
}
#[test]
fn superseded_target_with_reason_details() {
let w = wire(
"superseded_target",
"lineage mismatch",
Some(json!({"reason": "lineage_mismatch"})),
);
match AcdpError::from_wire_error(w) {
AcdpError::SupersededTarget { reason, .. } => {
assert_eq!(reason, SupersessionReason::LineageMismatch);
}
other => panic!("expected SupersededTarget, got {other:?}"),
}
}
#[test]
fn superseded_target_without_details_falls_back_to_other() {
let w = wire("superseded_target", "?", None);
match AcdpError::from_wire_error(w) {
AcdpError::SupersededTarget { reason, .. } => {
assert_eq!(reason, SupersessionReason::Other);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn unknown_code_passes_through_as_registry() {
let w = wire("unsupported_embedding_model", "reserved future code", None);
assert!(matches!(
AcdpError::from_wire_error(w),
AcdpError::Registry(_)
));
}
#[test]
fn lineage_walk_failed_reason_roundtrip() {
let w = wire(
"superseded_target",
"intermediate not retrievable",
Some(json!({
"reason": "lineage_walk_failed",
"unreachable_ctx_id":
"acdp://r.example.com/12345678-1234-4321-8123-123456781234"
})),
);
match AcdpError::from_wire_error(w) {
AcdpError::SupersededTarget { reason, .. } => {
assert_eq!(reason, SupersessionReason::LineageWalkFailed);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn revocation_type_mismatch_reason_roundtrip() {
assert_eq!(
serde_json::to_value(SupersessionReason::RevocationTypeMismatch).unwrap(),
json!("revocation_type_mismatch")
);
assert_eq!(
serde_json::from_value::<SupersessionReason>(json!("revocation_type_mismatch"))
.unwrap(),
SupersessionReason::RevocationTypeMismatch
);
let w = wire(
"superseded_target",
"non-revocation context cannot supersede a key-revocation target",
Some(json!({"reason": "revocation_type_mismatch"})),
);
match AcdpError::from_wire_error(w) {
AcdpError::SupersededTarget { reason, .. } => {
assert_eq!(reason, SupersessionReason::RevocationTypeMismatch);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn is_transient_for_known_retryables() {
assert!(AcdpError::KeyResolutionUnreachable("x".into()).is_transient());
assert!(AcdpError::RateLimited("x".into()).is_transient());
assert!(AcdpError::CrossRegistryResolutionFailed("x".into()).is_transient());
assert!(AcdpError::RegistryInternal("x".into()).is_transient());
assert!(AcdpError::Http("x".into()).is_transient());
assert!(!AcdpError::SchemaViolation("x".into()).is_transient());
assert!(!AcdpError::InvalidSignature("x".into()).is_transient());
assert!(!AcdpError::NotFound("x".into()).is_transient());
assert!(!AcdpError::DataRefHashMismatch("x".into()).is_transient());
assert!(!AcdpError::InvalidReceipt("x".into()).is_transient());
assert!(!AcdpError::InvalidLogProof("x".into()).is_transient());
assert!(!AcdpError::InvalidWitnessCosignature("x".into()).is_transient());
assert!(!AcdpError::UnsupportedMediaType("x".into()).is_transient());
assert!(!AcdpError::ImmutableField("x".into()).is_transient());
assert!(!AcdpError::InvalidLifecycleTransition("x".into()).is_transient());
assert!(!AcdpError::ContextIdMismatch {
requested: "a".into(),
served: "b".into(),
}
.is_transient());
assert!(!AcdpError::IncompleteLineage {
lineage_id: "lin:sha256:aa".into(),
ctx_id: Some("acdp://r.example.com/x".into()),
}
.is_transient());
assert!(!AcdpError::SearchTruncated("x".into()).is_transient());
assert!(!AcdpError::RevocationDiscoveryBudgetExceeded("x".into()).is_transient());
}
}