use crate::primitives::ContentHash;
#[cfg(doc)]
use crate::revocation::RevocationEffect;
use crate::revocation::{RevocationRefusal, UnknownReason};
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 candidate unverifiable: {0}")]
RevocationCandidateUnverifiable(String),
#[error("revocation status unknown ({reason}): {detail}")]
RevocationUnknown {
reason: UnknownReason,
source: Box<AcdpError>,
detail: String,
},
#[error("key revoked — {0}")]
KeyRevoked(Box<RevocationRefusal>),
#[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::RevocationUnknown { reason, .. } = self {
return *reason == UnknownReason::Transient;
}
matches!(
self,
AcdpError::KeyResolutionUnreachable(_)
| AcdpError::RateLimited(_)
| AcdpError::CrossRegistryResolutionFailed(_)
| AcdpError::RegistryInternal(_)
| AcdpError::Http(_)
)
}
pub fn unknown_reason(&self) -> UnknownReason {
match self {
AcdpError::RevocationUnknown { reason, .. } => *reason,
AcdpError::SearchTruncated(_)
| AcdpError::RevocationDiscoveryBudgetExceeded(_)
| AcdpError::IncompleteLineage { .. } => UnknownReason::Truncated,
AcdpError::RevocationCandidateUnverifiable(_) => UnknownReason::UnverifiableCandidate,
e if e.is_transient() => UnknownReason::Transient,
_ => UnknownReason::Unanswerable,
}
}
pub fn is_key_not_authorized(&self) -> bool {
matches!(
self,
AcdpError::KeyNotAuthorized(_) | AcdpError::KeyRevoked(_)
)
}
pub fn revocation_refusal(&self) -> Option<&RevocationRefusal> {
match self {
AcdpError::KeyRevoked(r) => Some(r),
_ => None,
}
}
pub fn is_ssrf_policy_refusal(&self) -> bool {
matches!(self, AcdpError::SchemaViolation(m) if m.starts_with(SSRF_POLICY_PREFIX))
}
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}"))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SsrfDnsRefusal {
message: String,
}
impl SsrfDnsRefusal {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl std::fmt::Display for SsrfDnsRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for SsrfDnsRefusal {}
pub const SSRF_POLICY_PREFIX: &str = "SSRF policy:";
pub fn is_ssrf_refusal(err: &(dyn std::error::Error + 'static)) -> bool {
let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = cur {
if e.downcast_ref::<SsrfDnsRefusal>().is_some()
|| e.to_string().starts_with(SSRF_POLICY_PREFIX)
{
return true;
}
cur = e.source();
}
false
}
pub fn error_chain_message(err: &(dyn std::error::Error + 'static)) -> String {
let mut msg = err.to_string();
let mut source = err.source();
while let Some(s) = source {
msg.push_str(": ");
msg.push_str(&s.to_string());
source = s.source();
}
msg
}
#[cfg(feature = "reqwest")]
impl From<reqwest::Error> for AcdpError {
fn from(e: reqwest::Error) -> Self {
let msg = error_chain_message(&e);
if is_ssrf_refusal(&e) {
AcdpError::SchemaViolation(format!("{SSRF_POLICY_PREFIX} request refused: {msg}"))
} else 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());
assert!(!AcdpError::RevocationCandidateUnverifiable("x".into()).is_transient());
}
#[derive(Debug)]
struct Wrap(
&'static str,
Option<Box<dyn std::error::Error + Send + Sync>>,
);
impl std::fmt::Display for Wrap {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Wrap {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.1
.as_deref()
.map(|e| e as &(dyn std::error::Error + 'static))
}
}
#[test]
fn is_ssrf_refusal_finds_typed_marker_in_source_chain() {
let marker = SsrfDnsRefusal::new("forbidden address (no marker text)");
assert!(is_ssrf_refusal(&marker));
let nested = Wrap(
"error sending request",
Some(Box::new(Wrap(
"client error (Connect)",
Some(Box::new(marker)),
))),
);
assert!(is_ssrf_refusal(&nested));
assert_eq!(
error_chain_message(&nested),
"error sending request: client error (Connect): forbidden address (no marker text)"
);
}
#[test]
fn is_ssrf_refusal_falls_back_to_message_text() {
let stringified = Wrap(
"outer",
Some(Box::new(Wrap("SSRF policy: DNS answer is forbidden", None))),
);
assert!(is_ssrf_refusal(&stringified));
}
#[test]
fn is_ssrf_refusal_fallback_requires_prefix() {
let mention = Wrap(
"upstream said: SSRF policy: blocked",
Some(Box::new(Wrap("see SSRF policy docs", None))),
);
assert!(!is_ssrf_refusal(&mention));
}
#[test]
fn is_ssrf_policy_refusal_matches_prefixed_schema_violation_only() {
assert!(
AcdpError::SchemaViolation("SSRF policy: request refused: x".into())
.is_ssrf_policy_refusal()
);
assert!(!AcdpError::SchemaViolation("title too long".into()).is_ssrf_policy_refusal());
assert!(!AcdpError::SchemaViolation("note: SSRF policy: x".into()).is_ssrf_policy_refusal());
assert!(!AcdpError::Http("SSRF policy: x".into()).is_ssrf_policy_refusal());
assert!(!AcdpError::KeyResolution("SSRF policy: x".into()).is_ssrf_policy_refusal());
}
#[test]
fn is_ssrf_refusal_false_for_plain_transport_errors() {
let plain = Wrap(
"error sending request",
Some(Box::new(Wrap(
"tcp connect error: Connection refused",
None,
))),
);
assert!(!is_ssrf_refusal(&plain));
let io = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
assert!(!is_ssrf_refusal(&io));
}
#[test]
fn ssrf_refusal_classification_is_not_transient() {
assert!(!AcdpError::SchemaViolation("SSRF policy: x".into()).is_transient());
assert!(AcdpError::Http("connection failed: tcp reset".into()).is_transient());
}
}