lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

v1.rs (42014B)


      1 //! Stable serialized error-report contract generation 1.
      2 //!
      3 //! Native crate errors preserve their source chains in their owning packages.
      4 //! This module accepts only validated, secret-safe data at the serialization
      5 //! boundary and cannot contain a native source error.
      6 
      7 use alloc::{
      8     string::{String, ToString},
      9     vec::Vec,
     10 };
     11 use core::fmt;
     12 
     13 use crate::{
     14     runtime::v1::OperationId,
     15     schema::{Metadata, ModuleVersion, Registry},
     16 };
     17 
     18 /// Error-report schema generation.
     19 pub const SCHEMA_VERSION: u16 = 1;
     20 /// Stable error-report schema identity.
     21 pub const SCHEMA_ID: &str = "radroots.protocol.error_report.v1";
     22 /// Replacement used when a native source message is not explicitly safe.
     23 pub const REDACTED_MESSAGE: &str = "[redacted]";
     24 const MAX_CODE_BYTES: usize = 96;
     25 const MAX_CAPABILITY_ID_BYTES: usize = 128;
     26 const MAX_SAFE_MESSAGE_BYTES: usize = 256;
     27 const MAX_DETAIL_ENTRIES: usize = 32;
     28 const MAX_DETAIL_TEXT_BYTES: usize = 128;
     29 
     30 /// Stable error class.
     31 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     32 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     33 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     34 pub enum Class {
     35     Validation,
     36     Contract,
     37     Storage,
     38     Resource,
     39     Conflict,
     40     Operation,
     41     Authorization,
     42     Signer,
     43     Network,
     44     Sync,
     45     Runtime,
     46     Projection,
     47     Query,
     48     Capability,
     49     Privacy,
     50     Security,
     51     Maintenance,
     52     Internal,
     53     Unknown,
     54 }
     55 
     56 impl Class {
     57     /// Returns the stable wire identity for this class.
     58     pub const fn as_str(self) -> &'static str {
     59         match self {
     60             Self::Validation => "validation",
     61             Self::Contract => "contract",
     62             Self::Storage => "storage",
     63             Self::Resource => "resource",
     64             Self::Conflict => "conflict",
     65             Self::Operation => "operation",
     66             Self::Authorization => "authorization",
     67             Self::Signer => "signer",
     68             Self::Network => "network",
     69             Self::Sync => "sync",
     70             Self::Runtime => "runtime",
     71             Self::Projection => "projection",
     72             Self::Query => "query",
     73             Self::Capability => "capability",
     74             Self::Privacy => "privacy",
     75             Self::Security => "security",
     76             Self::Maintenance => "maintenance",
     77             Self::Internal => "internal",
     78             Self::Unknown => "unknown",
     79         }
     80     }
     81 }
     82 
     83 /// Stable recovery action vocabulary established by the SDK surface.
     84 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     85 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
     86 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     87 pub enum RecoveryAction {
     88     InspectLocalStores,
     89     ConfigureStorage,
     90     InspectGeoNamesAsset,
     91     RetryOperationWithSameIdempotencyKey,
     92     ConfigureTransportTargets,
     93     ConfigureGeoNamesCache,
     94     ConfigureSigner,
     95     FixRequest,
     96     SelectAuthorizedActor,
     97     CompleteSignerAuthentication,
     98     RetryAfterTransportFailure,
     99     RetryGeoNamesDownload,
    100     EnableRequiredFeature,
    101     RecreateClient,
    102 }
    103 
    104 impl RecoveryAction {
    105     /// Returns the stable wire identity for this recovery action.
    106     pub const fn as_str(self) -> &'static str {
    107         match self {
    108             Self::InspectLocalStores => "inspect_local_stores",
    109             Self::ConfigureStorage => "configure_storage",
    110             Self::InspectGeoNamesAsset => "inspect_geo_names_asset",
    111             Self::RetryOperationWithSameIdempotencyKey => {
    112                 "retry_operation_with_same_idempotency_key"
    113             }
    114             Self::ConfigureTransportTargets => "configure_transport_targets",
    115             Self::ConfigureGeoNamesCache => "configure_geo_names_cache",
    116             Self::ConfigureSigner => "configure_signer",
    117             Self::FixRequest => "fix_request",
    118             Self::SelectAuthorizedActor => "select_authorized_actor",
    119             Self::CompleteSignerAuthentication => "complete_signer_authentication",
    120             Self::RetryAfterTransportFailure => "retry_after_transport_failure",
    121             Self::RetryGeoNamesDownload => "retry_geonames_download",
    122             Self::EnableRequiredFeature => "enable_required_feature",
    123             Self::RecreateClient => "recreate_client",
    124         }
    125     }
    126 }
    127 
    128 /// One generated catalog descriptor.
    129 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    130 pub struct Descriptor {
    131     pub code: KnownCode,
    132     pub class: Class,
    133     pub retryable: bool,
    134     pub recovery_actions: &'static [RecoveryAction],
    135 }
    136 
    137 macro_rules! error_catalog {
    138     ($( $variant:ident => ($value:literal, $class:ident, $retryable:literal, [$($action:ident),* $(,)?]) ),+ $(,)?) => {
    139         /// A code known to this protocol generation.
    140         #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    141         pub enum KnownCode {
    142             $( $variant, )+
    143         }
    144 
    145         impl KnownCode {
    146             /// Every code defined by this generation.
    147             pub const ALL: &'static [Self] = &[$(Self::$variant),+];
    148 
    149             /// Returns the stable wire identity.
    150             pub const fn as_str(self) -> &'static str {
    151                 match self {
    152                     $(Self::$variant => $value,)+
    153                 }
    154             }
    155 
    156             /// Parses a code known to this generation.
    157             pub fn parse(value: &str) -> Option<Self> {
    158                 match value {
    159                     $($value => Some(Self::$variant),)+
    160                     _ => None,
    161                 }
    162             }
    163 
    164             /// Returns the generated descriptor from the same authority.
    165             pub const fn descriptor(self) -> Descriptor {
    166                 match self {
    167                     $(Self::$variant => Descriptor {
    168                         code: Self::$variant,
    169                         class: Class::$class,
    170                         retryable: $retryable,
    171                         recovery_actions: &[$(RecoveryAction::$action),*],
    172                     },)+
    173                 }
    174             }
    175         }
    176 
    177         /// Complete generated error catalog.
    178         pub const CATALOG: &[Descriptor] = &[
    179             $(Descriptor {
    180                 code: KnownCode::$variant,
    181                 class: Class::$class,
    182                 retryable: $retryable,
    183                 recovery_actions: &[$(RecoveryAction::$action),*],
    184             },)+
    185         ];
    186     };
    187 }
    188 
    189 error_catalog! {
    190     InvalidArgument => ("invalid_argument", Validation, false, [FixRequest]),
    191     UnsupportedContractVersion => ("unsupported_contract_version", Contract, false, [EnableRequiredFeature]),
    192     UnsupportedProfileSchema => ("unsupported_profile_schema", Storage, false, [InspectLocalStores]),
    193     SchemaTooNew => ("schema_too_new", Storage, false, [EnableRequiredFeature]),
    194     NotFound => ("not_found", Resource, false, [FixRequest]),
    195     AmbiguousTrade => ("ambiguous_trade", Conflict, false, [FixRequest]),
    196     StaleListingRevision => ("stale_listing_revision", Conflict, false, [FixRequest]),
    197     PreconditionChanged => ("precondition_changed", Conflict, true, [RetryOperationWithSameIdempotencyKey]),
    198     RevisionRequired => ("revision_required", Conflict, false, [FixRequest]),
    199     InventoryUnavailable => ("inventory_unavailable", Conflict, false, [RetryOperationWithSameIdempotencyKey]),
    200     IdempotencyConflict => ("idempotency_conflict", Conflict, false, [RetryOperationWithSameIdempotencyKey]),
    201     OperationInProgress => ("operation_in_progress", Operation, true, [RetryOperationWithSameIdempotencyKey]),
    202     ApprovalRequired => ("approval_required", Authorization, false, [SelectAuthorizedActor]),
    203     ApprovalInvalid => ("approval_invalid", Authorization, false, [SelectAuthorizedActor]),
    204     ApprovalExpired => ("approval_expired", Authorization, false, [SelectAuthorizedActor]),
    205     ApprovalReplayed => ("approval_replayed", Authorization, false, [SelectAuthorizedActor]),
    206     AuthorizationDenied => ("authorization_denied", Authorization, false, [SelectAuthorizedActor]),
    207     SignerCapabilityMissing => ("signer_capability_missing", Signer, false, [ConfigureSigner]),
    208     SignerUnavailable => ("signer_unavailable", Signer, true, [ConfigureSigner]),
    209     SignerRejected => ("signer_rejected", Signer, false, [SelectAuthorizedActor]),
    210     SignerTimeout => ("signer_timeout", Signer, true, [RetryAfterTransportFailure]),
    211     SignerCancelled => ("signer_cancelled", Signer, false, [ConfigureSigner]),
    212     SignerOutputInvalid => ("signer_output_invalid", Signer, false, [ConfigureSigner]),
    213     RelayAuthRequired => ("relay_auth_required", Network, true, [CompleteSignerAuthentication]),
    214     RelayAuthRejected => ("relay_auth_rejected", Network, false, [CompleteSignerAuthentication]),
    215     RelayPaymentRequired => ("relay_payment_required", Network, false, [ConfigureTransportTargets]),
    216     RelayPolicyRestricted => ("relay_policy_restricted", Network, false, [ConfigureTransportTargets]),
    217     RelayRateLimited => ("relay_rate_limited", Network, true, [RetryAfterTransportFailure]),
    218     RelayPowRequired => ("relay_pow_required", Network, false, [ConfigureTransportTargets]),
    219     TransportPartial => ("transport_partial", Network, true, [RetryAfterTransportFailure]),
    220     TransportOperationUnavailable => ("transport_operation_unavailable", Capability, false, [ConfigureTransportTargets]),
    221     SyncSaturated => ("sync_saturated", Sync, true, [RetryAfterTransportFailure]),
    222     SyncPartial => ("sync_partial", Sync, true, [RetryAfterTransportFailure]),
    223     DeadlineExceeded => ("deadline_exceeded", Runtime, true, [RetryAfterTransportFailure]),
    224     CancelledNoCommit => ("cancelled_no_commit", Runtime, false, [RetryOperationWithSameIdempotencyKey]),
    225     LocalCommittedDeliveryPending => ("local_committed_delivery_pending", Operation, true, [RetryAfterTransportFailure]),
    226     DatabaseBusy => ("database_busy", Storage, true, [InspectLocalStores]),
    227     ProfileWriterInUse => ("profile_writer_in_use", Storage, true, [InspectLocalStores]),
    228     MaintenanceInProgress => ("maintenance_in_progress", Storage, true, [RetryOperationWithSameIdempotencyKey]),
    229     StorageIntegrityFailed => ("storage_integrity_failed", Storage, false, [InspectLocalStores]),
    230     StorageSpaceInsufficient => ("storage_space_insufficient", Storage, true, [InspectLocalStores]),
    231     ProjectionStale => ("projection_stale", Projection, true, [InspectLocalStores]),
    232     ProjectionFailed => ("projection_failed", Projection, true, [InspectLocalStores]),
    233     ProjectionGenerationChanged => ("projection_generation_changed", Projection, true, [InspectLocalStores]),
    234     InvalidCursor => ("invalid_cursor", Query, false, [FixRequest]),
    235     UnsupportedCapability => ("unsupported_capability", Capability, false, [EnableRequiredFeature]),
    236     DmRelayUnconfigured => ("dm_relay_unconfigured", Privacy, false, [ConfigureTransportTargets]),
    237     PrivateDataUnavailable => ("private_data_unavailable", Privacy, false, [FixRequest]),
    238     ValidationPending => ("validation_pending", Validation, true, [RetryOperationWithSameIdempotencyKey]),
    239     ValidationExpired => ("validation_expired", Validation, false, [FixRequest]),
    240     ValidatorSetInvalid => ("validator_set_invalid", Validation, false, [FixRequest]),
    241     MediaPolicyDenied => ("media_policy_denied", Security, false, [FixRequest]),
    242     BackupInvalid => ("backup_invalid", Maintenance, false, [InspectLocalStores]),
    243     BackupAuthenticationFailed => ("backup_authentication_failed", Maintenance, false, [InspectLocalStores]),
    244     RestoreFailed => ("restore_failed", Maintenance, true, [InspectLocalStores]),
    245     Backpressure => ("backpressure", Runtime, true, [RetryAfterTransportFailure]),
    246     MissingStorage => ("missing_storage", Capability, false, [ConfigureStorage]),
    247     SignerWithoutSink => ("signer_without_sink", Validation, false, [ConfigureTransportTargets]),
    248     ClientCloseInProgress => ("client_close_in_progress", Operation, true, [RetryOperationWithSameIdempotencyKey]),
    249     ClientClosing => ("client_closing", Operation, true, [RetryOperationWithSameIdempotencyKey]),
    250     ClientClosed => ("client_closed", Runtime, false, [RecreateClient]),
    251     StorageCloseFailed => ("storage_close_failed", Storage, false, [InspectLocalStores]),
    252     InternalError => ("internal_error", Internal, false, [InspectLocalStores]),
    253 }
    254 
    255 /// A stable code that preserves unknown future values.
    256 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    257 pub struct Code(String);
    258 
    259 impl Code {
    260     /// Creates a code from a known catalog identity.
    261     pub fn known(code: KnownCode) -> Self {
    262         Self(code.as_str().to_string())
    263     }
    264 
    265     /// Parses a canonical code while preserving unknown future values.
    266     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
    267         let value = value.into();
    268         if !valid_identifier(value.as_str(), MAX_CODE_BYTES) {
    269             return Err(Error::InvalidCode);
    270         }
    271         Ok(Self(value))
    272     }
    273 
    274     /// Returns the exact serialized identity.
    275     pub fn as_str(&self) -> &str {
    276         self.0.as_str()
    277     }
    278 
    279     /// Resolves this identity against the current generated catalog.
    280     pub fn known_code(&self) -> Option<KnownCode> {
    281         KnownCode::parse(self.as_str())
    282     }
    283 }
    284 
    285 #[cfg(feature = "serde")]
    286 impl serde::Serialize for Code {
    287     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    288     where
    289         S: serde::Serializer,
    290     {
    291         serializer.serialize_str(self.as_str())
    292     }
    293 }
    294 
    295 #[cfg(feature = "serde")]
    296 impl<'de> serde::Deserialize<'de> for Code {
    297     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    298     where
    299         D: serde::Deserializer<'de>,
    300     {
    301         let value = <String as serde::Deserialize>::deserialize(deserializer)?;
    302         Self::parse(value).map_err(serde::de::Error::custom)
    303     }
    304 }
    305 
    306 /// Validated optional capability identity.
    307 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    308 pub struct CapabilityId(String);
    309 
    310 impl CapabilityId {
    311     /// Parses a canonical public capability identity.
    312     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
    313         let value = value.into();
    314         if !valid_identifier(value.as_str(), MAX_CAPABILITY_ID_BYTES) {
    315             return Err(Error::InvalidCapabilityId);
    316         }
    317         Ok(Self(value))
    318     }
    319 
    320     pub fn as_str(&self) -> &str {
    321         self.0.as_str()
    322     }
    323 }
    324 
    325 #[cfg(feature = "serde")]
    326 impl serde::Serialize for CapabilityId {
    327     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    328     where
    329         S: serde::Serializer,
    330     {
    331         serializer.serialize_str(self.as_str())
    332     }
    333 }
    334 
    335 #[cfg(feature = "serde")]
    336 impl<'de> serde::Deserialize<'de> for CapabilityId {
    337     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    338     where
    339         D: serde::Deserializer<'de>,
    340     {
    341         let value = <String as serde::Deserialize>::deserialize(deserializer)?;
    342         Self::parse(value).map_err(serde::de::Error::custom)
    343     }
    344 }
    345 
    346 /// Validated secret-safe human-readable message.
    347 #[derive(Clone, Debug, Eq, PartialEq)]
    348 pub struct SafeMessage(String);
    349 
    350 impl SafeMessage {
    351     /// Validates explicitly safe application-authored text.
    352     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
    353         let value = value.into();
    354         if value.is_empty()
    355             || value.len() > MAX_SAFE_MESSAGE_BYTES
    356             || value.chars().any(char::is_control)
    357         {
    358             return Err(Error::InvalidSafeMessage);
    359         }
    360         if contains_sensitive_material(value.as_str()) {
    361             return Err(Error::SensitiveMessage);
    362         }
    363         Ok(Self(value))
    364     }
    365 
    366     /// Returns the mandatory redacted source-message replacement.
    367     pub fn redacted() -> Self {
    368         Self(REDACTED_MESSAGE.to_string())
    369     }
    370 
    371     pub fn as_str(&self) -> &str {
    372         self.0.as_str()
    373     }
    374 }
    375 
    376 #[cfg(feature = "serde")]
    377 impl serde::Serialize for SafeMessage {
    378     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    379     where
    380         S: serde::Serializer,
    381     {
    382         serializer.serialize_str(self.as_str())
    383     }
    384 }
    385 
    386 #[cfg(feature = "serde")]
    387 impl<'de> serde::Deserialize<'de> for SafeMessage {
    388     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    389     where
    390         D: serde::Deserializer<'de>,
    391     {
    392         let value = <String as serde::Deserialize>::deserialize(deserializer)?;
    393         Self::parse(value).map_err(serde::de::Error::custom)
    394     }
    395 }
    396 
    397 /// Stable scalar value allowed in safe structured details.
    398 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    399 #[cfg_attr(
    400     feature = "serde",
    401     serde(tag = "kind", content = "value", rename_all = "snake_case")
    402 )]
    403 #[derive(Clone, Debug, Eq, PartialEq)]
    404 pub enum DetailValue {
    405     Text(String),
    406     Bool(bool),
    407     Signed(i64),
    408     Unsigned(u64),
    409 }
    410 
    411 /// One key/value detail entry.
    412 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    413 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    414 #[derive(Clone, Debug, Eq, PartialEq)]
    415 pub struct Detail {
    416     pub key: String,
    417     pub value: DetailValue,
    418 }
    419 
    420 impl Detail {
    421     /// Creates a detail. Collection validation applies the allowlist.
    422     pub fn new(key: impl Into<String>, value: DetailValue) -> Self {
    423         Self {
    424             key: key.into(),
    425             value,
    426         }
    427     }
    428 }
    429 
    430 /// Deterministically ordered, validated safe detail collection.
    431 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    432 #[cfg_attr(
    433     feature = "serde",
    434     serde(try_from = "Vec<Detail>", into = "Vec<Detail>")
    435 )]
    436 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    437 pub struct SafeDetails {
    438     entries: Vec<Detail>,
    439 }
    440 
    441 impl SafeDetails {
    442     /// Validates, sorts, and stores safe details.
    443     pub fn try_new(entries: impl IntoIterator<Item = Detail>) -> Result<Self, Error> {
    444         let mut entries: Vec<_> = entries.into_iter().collect();
    445         if entries.len() > MAX_DETAIL_ENTRIES {
    446             return Err(Error::TooManyDetails);
    447         }
    448         entries.sort_by(|left, right| left.key.cmp(&right.key));
    449         for (index, entry) in entries.iter().enumerate() {
    450             validate_detail(entry)?;
    451             if index > 0 && entries[index - 1].key == entry.key {
    452                 return Err(Error::DuplicateDetailKey);
    453             }
    454         }
    455         Ok(Self { entries })
    456     }
    457 
    458     pub fn entries(&self) -> &[Detail] {
    459         self.entries.as_slice()
    460     }
    461 
    462     pub fn is_empty(&self) -> bool {
    463         self.entries.is_empty()
    464     }
    465 }
    466 
    467 impl TryFrom<Vec<Detail>> for SafeDetails {
    468     type Error = Error;
    469 
    470     fn try_from(entries: Vec<Detail>) -> Result<Self, Self::Error> {
    471         Self::try_new(entries)
    472     }
    473 }
    474 
    475 impl From<SafeDetails> for Vec<Detail> {
    476     fn from(details: SafeDetails) -> Self {
    477         details.entries
    478     }
    479 }
    480 
    481 /// Secret-safe serialized error boundary.
    482 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
    483 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
    484 #[derive(Clone, Debug, Eq, PartialEq)]
    485 pub struct ErrorReport {
    486     schema_version: u16,
    487     code: Code,
    488     class: Class,
    489     retryable: bool,
    490     recovery_actions: Vec<RecoveryAction>,
    491     #[cfg_attr(
    492         feature = "serde",
    493         serde(default, skip_serializing_if = "Option::is_none")
    494     )]
    495     operation_id: Option<OperationId>,
    496     #[cfg_attr(
    497         feature = "serde",
    498         serde(default, skip_serializing_if = "Option::is_none")
    499     )]
    500     capability_id: Option<CapabilityId>,
    501     message: SafeMessage,
    502     #[cfg_attr(feature = "serde", serde(default))]
    503     details: SafeDetails,
    504 }
    505 
    506 impl ErrorReport {
    507     /// Builds a report for a known code from its generated descriptor.
    508     pub fn known(
    509         code: KnownCode,
    510         operation_id: Option<OperationId>,
    511         capability_id: Option<CapabilityId>,
    512         message: SafeMessage,
    513         details: SafeDetails,
    514     ) -> Self {
    515         let descriptor = code.descriptor();
    516         Self {
    517             schema_version: SCHEMA_VERSION,
    518             code: Code::known(code),
    519             class: descriptor.class,
    520             retryable: descriptor.retryable,
    521             recovery_actions: descriptor.recovery_actions.to_vec(),
    522             operation_id,
    523             capability_id,
    524             message,
    525             details,
    526         }
    527     }
    528 
    529     /// Converts an untrusted native source failure without copying its message.
    530     pub fn redacted_from_source(
    531         code: KnownCode,
    532         operation_id: Option<OperationId>,
    533         capability_id: Option<CapabilityId>,
    534     ) -> Self {
    535         Self::known(
    536             code,
    537             operation_id,
    538             capability_id,
    539             SafeMessage::redacted(),
    540             SafeDetails::default(),
    541         )
    542     }
    543 
    544     /// Builds the fail-closed representation of an unknown future code.
    545     pub fn unknown(code: Code) -> Result<Self, Error> {
    546         if code.known_code().is_some() {
    547             return Err(Error::ExpectedUnknownCode);
    548         }
    549         Ok(Self {
    550             schema_version: SCHEMA_VERSION,
    551             code,
    552             class: Class::Unknown,
    553             retryable: false,
    554             recovery_actions: Vec::new(),
    555             operation_id: None,
    556             capability_id: None,
    557             message: SafeMessage::redacted(),
    558             details: SafeDetails::default(),
    559         })
    560     }
    561 
    562     /// Validates version, catalog agreement, and unknown-code policy.
    563     pub fn validate(&self) -> Result<(), Error> {
    564         if self.schema_version != SCHEMA_VERSION {
    565             return Err(Error::UnsupportedSchemaVersion {
    566                 version: self.schema_version,
    567             });
    568         }
    569         if let Some(known) = self.code.known_code() {
    570             let descriptor = known.descriptor();
    571             if self.class != descriptor.class
    572                 || self.retryable != descriptor.retryable
    573                 || self.recovery_actions.as_slice() != descriptor.recovery_actions
    574             {
    575                 return Err(Error::DescriptorMismatch { code: known });
    576             }
    577         } else if self.class != Class::Unknown
    578             || self.retryable
    579             || !self.recovery_actions.is_empty()
    580             || self.operation_id.is_some()
    581             || self.capability_id.is_some()
    582             || self.message.as_str() != REDACTED_MESSAGE
    583             || !self.details.is_empty()
    584         {
    585             return Err(Error::InvalidUnknownCodePolicy);
    586         }
    587         SafeDetails::try_new(self.details.entries.clone())?;
    588         Ok(())
    589     }
    590 
    591     pub const fn schema_version(&self) -> u16 {
    592         self.schema_version
    593     }
    594 
    595     pub fn code(&self) -> &Code {
    596         &self.code
    597     }
    598 
    599     pub const fn class(&self) -> Class {
    600         self.class
    601     }
    602 
    603     pub const fn retryable(&self) -> bool {
    604         self.retryable
    605     }
    606 
    607     pub fn recovery_actions(&self) -> &[RecoveryAction] {
    608         self.recovery_actions.as_slice()
    609     }
    610 
    611     pub const fn operation_id(&self) -> Option<OperationId> {
    612         self.operation_id
    613     }
    614 
    615     pub fn capability_id(&self) -> Option<&CapabilityId> {
    616         self.capability_id.as_ref()
    617     }
    618 
    619     pub fn message(&self) -> &SafeMessage {
    620         &self.message
    621     }
    622 
    623     pub fn details(&self) -> &SafeDetails {
    624         &self.details
    625     }
    626 }
    627 
    628 #[cfg(feature = "serde")]
    629 #[derive(serde::Deserialize)]
    630 #[serde(deny_unknown_fields)]
    631 struct WireReport {
    632     schema_version: u16,
    633     code: Code,
    634     class: Class,
    635     retryable: bool,
    636     recovery_actions: Vec<RecoveryAction>,
    637     #[serde(default)]
    638     operation_id: Option<OperationId>,
    639     #[serde(default)]
    640     capability_id: Option<CapabilityId>,
    641     message: SafeMessage,
    642     #[serde(default)]
    643     details: SafeDetails,
    644 }
    645 
    646 #[cfg(feature = "serde")]
    647 impl<'de> serde::Deserialize<'de> for ErrorReport {
    648     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    649     where
    650         D: serde::Deserializer<'de>,
    651     {
    652         let wire = WireReport::deserialize(deserializer)?;
    653         let report = Self {
    654             schema_version: wire.schema_version,
    655             code: wire.code,
    656             class: wire.class,
    657             retryable: wire.retryable,
    658             recovery_actions: wire.recovery_actions,
    659             operation_id: wire.operation_id,
    660             capability_id: wire.capability_id,
    661             message: wire.message,
    662             details: wire.details,
    663         };
    664         report.validate().map_err(serde::de::Error::custom)?;
    665         Ok(report)
    666     }
    667 }
    668 
    669 /// Exact schema metadata for generated-language authority.
    670 pub const SCHEMAS: &[Metadata] = &[Metadata {
    671     type_name: "ErrorReport",
    672     schema_id: SCHEMA_ID,
    673     schema_version: SCHEMA_VERSION,
    674 }];
    675 
    676 /// Builds the stable error schema registry.
    677 pub fn schema_registry() -> Result<Registry, crate::schema::Error> {
    678     Registry::try_from_metadata(
    679         SCHEMAS
    680             .iter()
    681             .copied()
    682             .map(|metadata| (metadata, ModuleVersion::ErrorV1)),
    683     )
    684 }
    685 
    686 /// Error-report validation failure.
    687 #[derive(Clone, Debug, Eq, PartialEq)]
    688 #[non_exhaustive]
    689 pub enum Error {
    690     InvalidCode,
    691     InvalidCapabilityId,
    692     InvalidSafeMessage,
    693     SensitiveMessage,
    694     TooManyDetails,
    695     InvalidDetailKey,
    696     SensitiveDetailKey,
    697     DuplicateDetailKey,
    698     InvalidDetailText,
    699     ExpectedUnknownCode,
    700     UnsupportedSchemaVersion { version: u16 },
    701     DescriptorMismatch { code: KnownCode },
    702     InvalidUnknownCodePolicy,
    703 }
    704 
    705 impl fmt::Display for Error {
    706     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    707         match self {
    708             Self::InvalidCode => formatter.write_str("invalid error code"),
    709             Self::InvalidCapabilityId => formatter.write_str("invalid capability id"),
    710             Self::InvalidSafeMessage => formatter.write_str("invalid safe message"),
    711             Self::SensitiveMessage => formatter.write_str("sensitive error message rejected"),
    712             Self::TooManyDetails => formatter.write_str("too many safe detail entries"),
    713             Self::InvalidDetailKey => formatter.write_str("invalid safe detail key"),
    714             Self::SensitiveDetailKey => formatter.write_str("sensitive detail key rejected"),
    715             Self::DuplicateDetailKey => formatter.write_str("duplicate safe detail key"),
    716             Self::InvalidDetailText => formatter.write_str("invalid safe detail text"),
    717             Self::ExpectedUnknownCode => formatter.write_str("expected an unknown error code"),
    718             Self::UnsupportedSchemaVersion { version } => {
    719                 write!(
    720                     formatter,
    721                     "unsupported error report schema version {version}"
    722                 )
    723             }
    724             Self::DescriptorMismatch { code } => {
    725                 write!(formatter, "error descriptor mismatch for {}", code.as_str())
    726             }
    727             Self::InvalidUnknownCodePolicy => {
    728                 formatter.write_str("unknown error code violates fail-closed policy")
    729             }
    730         }
    731     }
    732 }
    733 
    734 #[cfg(feature = "std")]
    735 impl std::error::Error for Error {}
    736 
    737 fn validate_detail(detail: &Detail) -> Result<(), Error> {
    738     const ALLOWED_KEYS: &[&str] = &[
    739         "actual",
    740         "available_count",
    741         "committed",
    742         "delivery_pending",
    743         "expected",
    744         "field",
    745         "index",
    746         "limit",
    747         "mode",
    748         "required_count",
    749         "retry_after_ms",
    750         "schema_version",
    751         "status",
    752         "target_count",
    753     ];
    754     if !ALLOWED_KEYS.contains(&detail.key.as_str()) {
    755         if sensitive_identifier(detail.key.as_str()) {
    756             return Err(Error::SensitiveDetailKey);
    757         }
    758         return Err(Error::InvalidDetailKey);
    759     }
    760     if let DetailValue::Text(value) = &detail.value
    761         && (value.is_empty()
    762             || value.len() > MAX_DETAIL_TEXT_BYTES
    763             || !value.bytes().all(|byte| {
    764                 byte.is_ascii_lowercase()
    765                     || byte.is_ascii_digit()
    766                     || matches!(byte, b'_' | b'-' | b'.')
    767             })
    768             || contains_sensitive_material(value.as_str()))
    769     {
    770         return Err(Error::InvalidDetailText);
    771     }
    772     Ok(())
    773 }
    774 
    775 fn valid_identifier(value: &str, max_bytes: usize) -> bool {
    776     !value.is_empty()
    777         && value.len() <= max_bytes
    778         && value
    779             .bytes()
    780             .next()
    781             .is_some_and(|byte| byte.is_ascii_lowercase())
    782         && value.bytes().all(|byte| {
    783             byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.')
    784         })
    785 }
    786 
    787 fn sensitive_identifier(value: &str) -> bool {
    788     let lower = value.to_ascii_lowercase();
    789     [
    790         "authorization",
    791         "cookie",
    792         "credential",
    793         "mnemonic",
    794         "password",
    795         "private_key",
    796         "raw_event",
    797         "secret",
    798         "seed",
    799         "signature",
    800         "token",
    801     ]
    802     .iter()
    803     .any(|marker| lower.contains(marker))
    804 }
    805 
    806 fn contains_sensitive_material(value: &str) -> bool {
    807     let lower = value.to_ascii_lowercase();
    808     sensitive_identifier(value)
    809         || lower.contains("bearer ")
    810         || lower.contains("://")
    811         || lower.contains("sk_")
    812         || lower.contains("nsec1")
    813         || lower.contains("-----begin ")
    814         || lower.contains("private key")
    815 }
    816 
    817 #[cfg(test)]
    818 mod tests {
    819     use alloc::collections::BTreeSet;
    820 
    821     use super::*;
    822 
    823     #[test]
    824     fn class_and_recovery_labels_are_explicit_and_stable() {
    825         assert_eq!(Class::Validation.as_str(), "validation");
    826         assert_eq!(Class::Unknown.as_str(), "unknown");
    827         assert_eq!(
    828             RecoveryAction::InspectLocalStores.as_str(),
    829             "inspect_local_stores"
    830         );
    831         assert_eq!(RecoveryAction::RecreateClient.as_str(), "recreate_client");
    832         assert_eq!(
    833             RecoveryAction::RetryOperationWithSameIdempotencyKey.as_str(),
    834             "retry_operation_with_same_idempotency_key"
    835         );
    836     }
    837 
    838     #[test]
    839     fn generated_catalog_is_complete_unique_and_self_consistent() {
    840         assert_eq!(CATALOG.len(), 63);
    841         assert_eq!(KnownCode::ALL.len(), CATALOG.len());
    842         let mut codes = BTreeSet::new();
    843         for (index, descriptor) in CATALOG.iter().enumerate() {
    844             assert!(codes.insert(descriptor.code.as_str()));
    845             assert_eq!(descriptor.code, KnownCode::ALL[index]);
    846             assert_eq!(descriptor.code.descriptor(), *descriptor);
    847             assert_eq!(
    848                 KnownCode::parse(descriptor.code.as_str()),
    849                 Some(descriptor.code)
    850             );
    851             assert!(!descriptor.recovery_actions.is_empty());
    852         }
    853     }
    854 
    855     #[cfg(feature = "serde")]
    856     #[test]
    857     fn known_report_round_trips_with_exact_v1_shape() {
    858         let details = SafeDetails::try_new([
    859             Detail::new("retry_after_ms", DetailValue::Unsigned(250)),
    860             Detail::new("status", DetailValue::Text("rate_limited".to_string())),
    861         ])
    862         .expect("safe details");
    863         let report = ErrorReport::known(
    864             KnownCode::RelayRateLimited,
    865             Some(OperationId::SyncPush),
    866             Some(CapabilityId::parse("nostr").expect("capability")),
    867             SafeMessage::parse("Relay rate limit requires a later retry").expect("safe message"),
    868             details,
    869         );
    870         report.validate().expect("report");
    871 
    872         let json = serde_json::to_string(&report).expect("serialize");
    873         let decoded: ErrorReport = serde_json::from_str(json.as_str()).expect("deserialize");
    874         assert_eq!(decoded, report);
    875         assert_eq!(
    876             serde_json::to_value(report).expect("value"),
    877             serde_json::json!({
    878                 "schema_version": 1,
    879                 "code": "relay_rate_limited",
    880                 "class": "network",
    881                 "retryable": true,
    882                 "recovery_actions": ["retry_after_transport_failure"],
    883                 "operation_id": "sync.push",
    884                 "capability_id": "nostr",
    885                 "message": "Relay rate limit requires a later retry",
    886                 "details": [
    887                     {"key": "retry_after_ms", "value": {"kind": "unsigned", "value": 250}},
    888                     {"key": "status", "value": {"kind": "text", "value": "rate_limited"}}
    889                 ]
    890             })
    891         );
    892     }
    893 
    894     #[cfg(feature = "serde")]
    895     #[test]
    896     fn unknown_codes_are_preserved_but_fail_closed() {
    897         let report = ErrorReport::unknown(Code::parse("future_failure").expect("code"))
    898             .expect("unknown report");
    899         let json = serde_json::to_string(&report).expect("serialize");
    900         let decoded: ErrorReport = serde_json::from_str(json.as_str()).expect("deserialize");
    901         assert_eq!(decoded.code().as_str(), "future_failure");
    902         assert_eq!(decoded.class(), Class::Unknown);
    903         assert!(!decoded.retryable());
    904         assert!(decoded.recovery_actions().is_empty());
    905         assert_eq!(decoded.message().as_str(), REDACTED_MESSAGE);
    906 
    907         let invalid = json.replace("\"unknown\"", "\"network\"");
    908         assert!(serde_json::from_str::<ErrorReport>(invalid.as_str()).is_err());
    909     }
    910 
    911     #[cfg(feature = "serde")]
    912     #[test]
    913     fn unknown_fields_and_versions_fail_closed() {
    914         let report = ErrorReport::redacted_from_source(KnownCode::InternalError, None, None);
    915         let mut value = serde_json::to_value(report).expect("value");
    916         value["schema_version"] = serde_json::json!(2);
    917         assert!(serde_json::from_value::<ErrorReport>(value.clone()).is_err());
    918         value["schema_version"] = serde_json::json!(1);
    919         value
    920             .as_object_mut()
    921             .expect("object")
    922             .insert("source".to_string(), serde_json::json!("native error"));
    923         assert!(serde_json::from_value::<ErrorReport>(value).is_err());
    924     }
    925 
    926     #[cfg(feature = "serde")]
    927     #[test]
    928     fn native_source_messages_are_redacted_and_secrets_are_rejected() {
    929         for source in [
    930             "Bearer top-secret-token",
    931             "password=hunter2",
    932             "nsec1privatekeymaterial",
    933             "wss://user:password@relay.example.com?token=secret",
    934             "-----BEGIN PRIVATE KEY-----",
    935             "sk_live_sensitive",
    936         ] {
    937             assert!(SafeMessage::parse(source).is_err());
    938             let report = ErrorReport::redacted_from_source(KnownCode::InternalError, None, None);
    939             let json = serde_json::to_string(&report).expect("serialize");
    940             assert!(!json.contains(source));
    941             assert!(json.contains(REDACTED_MESSAGE));
    942         }
    943 
    944         assert_eq!(
    945             SafeDetails::try_new([Detail::new(
    946                 "access_token",
    947                 DetailValue::Text("secret".to_string())
    948             )]),
    949             Err(Error::SensitiveDetailKey)
    950         );
    951     }
    952 
    953     #[test]
    954     fn schema_registry_dispatches_error_report_v1() {
    955         let registry = schema_registry().expect("registry");
    956         assert_eq!(registry.len(), 1);
    957         assert_eq!(registry.descriptors()[0].module(), ModuleVersion::ErrorV1);
    958         assert_eq!(registry.descriptors()[0].id().as_str(), SCHEMA_ID);
    959     }
    960 
    961     #[test]
    962     fn identifier_message_and_detail_validation_cover_bounds() {
    963         let known = Code::known(KnownCode::InternalError);
    964         assert_eq!(known.known_code(), Some(KnownCode::InternalError));
    965         assert_eq!(known.as_str(), "internal_error");
    966         for invalid in ["", "Upper", "1starts_with_digit", "has space", "has/slash"] {
    967             assert_eq!(Code::parse(invalid), Err(Error::InvalidCode));
    968             assert_eq!(
    969                 CapabilityId::parse(invalid),
    970                 Err(Error::InvalidCapabilityId)
    971             );
    972         }
    973         assert_eq!(
    974             Code::parse("a".repeat(MAX_CODE_BYTES + 1)),
    975             Err(Error::InvalidCode)
    976         );
    977         assert_eq!(
    978             CapabilityId::parse("a".repeat(MAX_CAPABILITY_ID_BYTES + 1)),
    979             Err(Error::InvalidCapabilityId)
    980         );
    981         let capability = CapabilityId::parse("transport.nostr-v1").expect("capability");
    982         assert_eq!(capability.as_str(), "transport.nostr-v1");
    983 
    984         assert_eq!(SafeMessage::parse(""), Err(Error::InvalidSafeMessage));
    985         assert_eq!(
    986             SafeMessage::parse("bad\nmessage"),
    987             Err(Error::InvalidSafeMessage)
    988         );
    989         assert_eq!(
    990             SafeMessage::parse("a".repeat(MAX_SAFE_MESSAGE_BYTES + 1)),
    991             Err(Error::InvalidSafeMessage)
    992         );
    993         let message = SafeMessage::parse("A safe diagnostic").expect("message");
    994         assert_eq!(message.as_str(), "A safe diagnostic");
    995         assert_eq!(SafeMessage::redacted().as_str(), REDACTED_MESSAGE);
    996 
    997         let details = SafeDetails::try_new([
    998             Detail::new("status", DetailValue::Text("ready_now".into())),
    999             Detail::new("actual", DetailValue::Signed(-1)),
   1000             Detail::new("committed", DetailValue::Bool(true)),
   1001             Detail::new("limit", DetailValue::Unsigned(5)),
   1002         ])
   1003         .expect("details");
   1004         assert_eq!(details.entries()[0].key, "actual");
   1005         let vector: Vec<Detail> = details.clone().into();
   1006         assert_eq!(SafeDetails::try_from(vector).expect("converted"), details);
   1007         assert_eq!(
   1008             SafeDetails::try_new([Detail::new("unknown", DetailValue::Bool(true))]),
   1009             Err(Error::InvalidDetailKey)
   1010         );
   1011         assert_eq!(
   1012             SafeDetails::try_new([Detail::new("private_key", DetailValue::Bool(true))]),
   1013             Err(Error::SensitiveDetailKey)
   1014         );
   1015         assert_eq!(
   1016             SafeDetails::try_new([Detail::new("status", DetailValue::Text(String::new()))]),
   1017             Err(Error::InvalidDetailText)
   1018         );
   1019         assert_eq!(
   1020             SafeDetails::try_new([Detail::new("status", DetailValue::Text("BAD".into()))]),
   1021             Err(Error::InvalidDetailText)
   1022         );
   1023         assert_eq!(
   1024             SafeDetails::try_new([Detail::new(
   1025                 "status",
   1026                 DetailValue::Text("a".repeat(MAX_DETAIL_TEXT_BYTES + 1))
   1027             )]),
   1028             Err(Error::InvalidDetailText)
   1029         );
   1030         assert_eq!(
   1031             SafeDetails::try_new([Detail::new(
   1032                 "status",
   1033                 DetailValue::Text("nsec1secret".into())
   1034             )]),
   1035             Err(Error::InvalidDetailText)
   1036         );
   1037         assert_eq!(
   1038             SafeDetails::try_new([
   1039                 Detail::new("status", DetailValue::Bool(true)),
   1040                 Detail::new("status", DetailValue::Bool(false)),
   1041             ]),
   1042             Err(Error::DuplicateDetailKey)
   1043         );
   1044         let too_many = (0..=MAX_DETAIL_ENTRIES)
   1045             .map(|index| Detail::new("status", DetailValue::Unsigned(index as u64)))
   1046             .collect::<Vec<_>>();
   1047         assert_eq!(SafeDetails::try_new(too_many), Err(Error::TooManyDetails));
   1048     }
   1049 
   1050     #[test]
   1051     fn report_validation_and_error_messages_cover_fail_closed_policy() {
   1052         assert_eq!(
   1053             ErrorReport::unknown(Code::known(KnownCode::InternalError)),
   1054             Err(Error::ExpectedUnknownCode)
   1055         );
   1056         let report = ErrorReport::known(
   1057             KnownCode::RelayRateLimited,
   1058             Some(OperationId::SyncPush),
   1059             Some(CapabilityId::parse("nostr").expect("capability")),
   1060             SafeMessage::parse("Retry later").expect("message"),
   1061             SafeDetails::default(),
   1062         );
   1063         assert_eq!(report.schema_version(), 1);
   1064         assert_eq!(report.operation_id(), Some(OperationId::SyncPush));
   1065         assert_eq!(
   1066             report.capability_id().map(CapabilityId::as_str),
   1067             Some("nostr")
   1068         );
   1069         assert!(report.details().is_empty());
   1070 
   1071         let mut invalid = report.clone();
   1072         invalid.schema_version = 2;
   1073         assert_eq!(
   1074             invalid.validate(),
   1075             Err(Error::UnsupportedSchemaVersion { version: 2 })
   1076         );
   1077         for mutate in 0..3 {
   1078             let mut invalid = report.clone();
   1079             match mutate {
   1080                 0 => invalid.class = Class::Unknown,
   1081                 1 => invalid.retryable = false,
   1082                 _ => invalid.recovery_actions.clear(),
   1083             }
   1084             assert_eq!(
   1085                 invalid.validate(),
   1086                 Err(Error::DescriptorMismatch {
   1087                     code: KnownCode::RelayRateLimited
   1088                 })
   1089             );
   1090         }
   1091 
   1092         let unknown =
   1093             ErrorReport::unknown(Code::parse("future_failure").expect("code")).expect("unknown");
   1094         let mut variants = Vec::new();
   1095         let mut value = unknown.clone();
   1096         value.class = Class::Network;
   1097         variants.push(value);
   1098         let mut value = unknown.clone();
   1099         value.retryable = true;
   1100         variants.push(value);
   1101         let mut value = unknown.clone();
   1102         value
   1103             .recovery_actions
   1104             .push(RecoveryAction::RetryAfterTransportFailure);
   1105         variants.push(value);
   1106         let mut value = unknown.clone();
   1107         value.operation_id = Some(OperationId::SyncPush);
   1108         variants.push(value);
   1109         let mut value = unknown.clone();
   1110         value.capability_id = Some(CapabilityId::parse("nostr").expect("capability"));
   1111         variants.push(value);
   1112         let mut value = unknown.clone();
   1113         value.message = SafeMessage::parse("Not redacted").expect("message");
   1114         variants.push(value);
   1115         let mut value = unknown;
   1116         value.details =
   1117             SafeDetails::try_new([Detail::new("status", DetailValue::Text("failed".into()))])
   1118                 .expect("details");
   1119         variants.push(value);
   1120         for invalid in variants {
   1121             assert_eq!(invalid.validate(), Err(Error::InvalidUnknownCodePolicy));
   1122         }
   1123 
   1124         let errors = [
   1125             Error::InvalidCode,
   1126             Error::InvalidCapabilityId,
   1127             Error::InvalidSafeMessage,
   1128             Error::SensitiveMessage,
   1129             Error::TooManyDetails,
   1130             Error::InvalidDetailKey,
   1131             Error::SensitiveDetailKey,
   1132             Error::DuplicateDetailKey,
   1133             Error::InvalidDetailText,
   1134             Error::ExpectedUnknownCode,
   1135             Error::UnsupportedSchemaVersion { version: 2 },
   1136             Error::DescriptorMismatch {
   1137                 code: KnownCode::InternalError,
   1138             },
   1139             Error::InvalidUnknownCodePolicy,
   1140         ];
   1141         for error in errors {
   1142             assert!(!error.to_string().is_empty());
   1143         }
   1144     }
   1145 }