lib

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

error.rs (9994B)


      1 //! Normalized, secret-safe signing failures.
      2 
      3 use core::fmt;
      4 
      5 use radroots_protocol::{
      6     error::v1::{Class, Descriptor as ProtocolDescriptor, ErrorReport, KnownCode, RecoveryAction},
      7     runtime::v1::OperationId,
      8 };
      9 
     10 use crate::recovery::RemoteEffect;
     11 
     12 #[cfg(feature = "std")]
     13 use std::boxed::Box;
     14 
     15 /// Stable native signing failure kinds.
     16 ///
     17 /// These variants describe the signing contract rather than any concrete
     18 /// signer library. Additive variants remain possible before 1.0.
     19 #[non_exhaustive]
     20 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     21 pub enum Kind {
     22     InvalidArgument,
     23     AuthorizationDenied,
     24     SignerCapabilityMissing,
     25     SignerUnavailable,
     26     SignerRejected,
     27     SignerTimeout,
     28     SignerCancelled,
     29     SignerOutputInvalid,
     30     DeadlineExceeded,
     31     InternalError,
     32 }
     33 
     34 /// One signing error descriptor generated from the native-to-protocol map.
     35 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     36 pub struct Descriptor {
     37     kind: Kind,
     38     code: KnownCode,
     39     message: &'static str,
     40 }
     41 
     42 impl Descriptor {
     43     #[must_use]
     44     pub const fn kind(self) -> Kind {
     45         self.kind
     46     }
     47 
     48     #[must_use]
     49     pub const fn known_code(self) -> KnownCode {
     50         self.code
     51     }
     52 
     53     #[must_use]
     54     pub const fn code(self) -> &'static str {
     55         self.code.as_str()
     56     }
     57 
     58     #[must_use]
     59     pub const fn class(self) -> Class {
     60         self.protocol_descriptor().class
     61     }
     62 
     63     #[must_use]
     64     pub const fn retryable(self) -> bool {
     65         self.protocol_descriptor().retryable
     66     }
     67 
     68     #[must_use]
     69     pub const fn recovery_actions(self) -> &'static [RecoveryAction] {
     70         self.protocol_descriptor().recovery_actions
     71     }
     72 
     73     #[must_use]
     74     pub const fn message(self) -> &'static str {
     75         self.message
     76     }
     77 
     78     const fn protocol_descriptor(self) -> ProtocolDescriptor {
     79         self.code.descriptor()
     80     }
     81 }
     82 
     83 macro_rules! signing_error_catalog {
     84     ($( $variant:ident => ($code:ident, $message:literal) ),+ $(,)?) => {
     85         impl Kind {
     86             /// Every native signing failure kind in stable catalog order.
     87             pub const ALL: &'static [Self] = &[$(Self::$variant),+];
     88 
     89             /// Returns metadata generated from this package's single mapping
     90             /// and the protocol catalog's single class/recovery authority.
     91             #[must_use]
     92             pub const fn descriptor(self) -> Descriptor {
     93                 match self {
     94                     $(Self::$variant => Descriptor {
     95                         kind: Self::$variant,
     96                         code: KnownCode::$code,
     97                         message: $message,
     98                     },)+
     99                 }
    100             }
    101         }
    102 
    103         /// Complete stable native signing error catalog.
    104         pub const CATALOG: &[Descriptor] = &[
    105             $(Descriptor {
    106                 kind: Kind::$variant,
    107                 code: KnownCode::$code,
    108                 message: $message,
    109             },)+
    110         ];
    111     };
    112 }
    113 
    114 signing_error_catalog! {
    115     InvalidArgument => (InvalidArgument, "signing request is invalid"),
    116     AuthorizationDenied => (AuthorizationDenied, "signing authorization was denied"),
    117     SignerCapabilityMissing => (SignerCapabilityMissing, "required signer capability is missing"),
    118     SignerUnavailable => (SignerUnavailable, "signer is unavailable"),
    119     SignerRejected => (SignerRejected, "signer rejected the request"),
    120     SignerTimeout => (SignerTimeout, "signer timed out"),
    121     SignerCancelled => (SignerCancelled, "signing was cancelled"),
    122     SignerOutputInvalid => (SignerOutputInvalid, "signer output did not match the frozen draft"),
    123     DeadlineExceeded => (DeadlineExceeded, "signing deadline was exceeded"),
    124     InternalError => (InternalError, "internal signing failure"),
    125 }
    126 
    127 /// A normalized signing failure with an optional native source.
    128 ///
    129 /// Display and debug output are stable and never copy source text. Under the
    130 /// `std` feature callers may inspect the explicit `source()` chain for local
    131 /// diagnostics; protocol conversion always discards it.
    132 pub struct Error {
    133     kind: Kind,
    134     remote_effect: RemoteEffect,
    135     #[cfg(feature = "std")]
    136     source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
    137 }
    138 
    139 impl Error {
    140     /// Creates a source-free normalized failure.
    141     #[must_use]
    142     pub const fn new(kind: Kind) -> Self {
    143         Self {
    144             kind,
    145             remote_effect: RemoteEffect::None,
    146             #[cfg(feature = "std")]
    147             source: None,
    148         }
    149     }
    150 
    151     /// Preserves a native source without exposing it through display, debug,
    152     /// or protocol serialization.
    153     #[cfg(feature = "std")]
    154     pub fn with_source<E>(kind: Kind, source: E) -> Self
    155     where
    156         E: std::error::Error + Send + Sync + 'static,
    157     {
    158         Self {
    159             kind,
    160             remote_effect: RemoteEffect::None,
    161             source: Some(Box::new(source)),
    162         }
    163     }
    164 
    165     /// Marks that a failed remote invocation may already have taken effect.
    166     #[must_use]
    167     pub const fn with_possible_remote_effect(mut self) -> Self {
    168         self.remote_effect = RemoteEffect::MayHaveOccurred;
    169         self
    170     }
    171 
    172     #[must_use]
    173     pub const fn kind(&self) -> Kind {
    174         self.kind
    175     }
    176 
    177     #[must_use]
    178     pub const fn remote_effect(&self) -> RemoteEffect {
    179         self.remote_effect
    180     }
    181 
    182     #[must_use]
    183     pub const fn descriptor(&self) -> Descriptor {
    184         self.kind.descriptor()
    185     }
    186 
    187     #[must_use]
    188     pub const fn code(&self) -> &'static str {
    189         self.descriptor().code()
    190     }
    191 
    192     #[must_use]
    193     pub const fn class(&self) -> Class {
    194         self.descriptor().class()
    195     }
    196 
    197     #[must_use]
    198     pub const fn retryable(&self) -> bool {
    199         self.descriptor().retryable()
    200     }
    201 
    202     #[must_use]
    203     pub const fn recovery_actions(&self) -> &'static [RecoveryAction] {
    204         self.descriptor().recovery_actions()
    205     }
    206 
    207     /// Produces the versioned boundary report without copying source text.
    208     #[must_use]
    209     pub fn to_report(&self, operation_id: Option<OperationId>) -> ErrorReport {
    210         ErrorReport::redacted_from_source(self.descriptor().known_code(), operation_id, None)
    211     }
    212 }
    213 
    214 impl fmt::Debug for Error {
    215     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    216         let mut value = formatter.debug_struct("Error");
    217         value.field("kind", &self.kind);
    218         value.field("remote_effect", &self.remote_effect);
    219         #[cfg(feature = "std")]
    220         value.field("source", &self.source.as_ref().map(|_| "[redacted]"));
    221         value.finish()
    222     }
    223 }
    224 
    225 impl fmt::Display for Error {
    226     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    227         formatter.write_str(self.descriptor().message())
    228     }
    229 }
    230 
    231 impl core::error::Error for Error {
    232     #[cfg(feature = "std")]
    233     fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
    234         self.source
    235             .as_deref()
    236             .map(|source| source as &(dyn core::error::Error + 'static))
    237     }
    238 }
    239 
    240 impl From<&Error> for ErrorReport {
    241     fn from(error: &Error) -> Self {
    242         error.to_report(None)
    243     }
    244 }
    245 
    246 #[cfg(test)]
    247 mod tests {
    248     use super::*;
    249 
    250     #[cfg(feature = "std")]
    251     use std::{collections::BTreeSet, error::Error as _};
    252 
    253     #[test]
    254     fn catalog_codes_are_unique_and_metadata_matches_protocol_authority() {
    255         assert_eq!(CATALOG.len(), Kind::ALL.len());
    256         let mut codes = alloc_or_std_set();
    257         for (index, descriptor) in CATALOG.iter().copied().enumerate() {
    258             assert!(codes.insert(descriptor.code()));
    259             assert_eq!(descriptor.kind(), Kind::ALL[index]);
    260             assert_eq!(descriptor.kind().descriptor(), descriptor);
    261             let protocol = descriptor.known_code().descriptor();
    262             assert_eq!(descriptor.class(), protocol.class);
    263             assert_eq!(descriptor.retryable(), protocol.retryable);
    264             assert_eq!(descriptor.recovery_actions(), protocol.recovery_actions);
    265             assert!(!descriptor.message().is_empty());
    266             let report = Error::new(descriptor.kind()).to_report(None);
    267             assert_eq!(report.code().known_code(), Some(descriptor.known_code()));
    268             assert_eq!(report.class(), descriptor.class());
    269             assert_eq!(report.retryable(), descriptor.retryable());
    270             assert_eq!(report.recovery_actions(), descriptor.recovery_actions());
    271             assert_eq!(report.message().as_str(), "[redacted]");
    272         }
    273     }
    274 
    275     #[cfg(feature = "std")]
    276     #[test]
    277     fn native_source_is_preserved_but_diagnostics_and_reports_are_redacted() {
    278         #[derive(Debug)]
    279         struct SensitiveSource;
    280 
    281         impl fmt::Display for SensitiveSource {
    282             fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    283                 formatter.write_str("nsec1-do-not-disclose")
    284             }
    285         }
    286 
    287         impl std::error::Error for SensitiveSource {}
    288 
    289         let error = Error::with_source(Kind::SignerUnavailable, SensitiveSource);
    290         assert_eq!(
    291             error.source().expect("source").to_string(),
    292             "nsec1-do-not-disclose"
    293         );
    294         assert!(!error.to_string().contains("nsec1"));
    295         assert!(!format!("{error:?}").contains("nsec1"));
    296         assert_eq!(error.code(), "signer_unavailable");
    297         assert!(error.retryable());
    298 
    299         let report = error.to_report(Some(OperationId::SyncPush));
    300         assert_eq!(report.code().as_str(), "signer_unavailable");
    301         assert_eq!(report.operation_id(), Some(OperationId::SyncPush));
    302         assert_eq!(report.message().as_str(), "[redacted]");
    303         #[cfg(feature = "serde")]
    304         assert!(
    305             !serde_json::to_string(&report)
    306                 .expect("report")
    307                 .contains("nsec1")
    308         );
    309     }
    310 
    311     #[cfg(feature = "std")]
    312     fn alloc_or_std_set() -> BTreeSet<&'static str> {
    313         BTreeSet::new()
    314     }
    315 
    316     #[cfg(not(feature = "std"))]
    317     fn alloc_or_std_set() -> alloc::collections::BTreeSet<&'static str> {
    318         alloc::collections::BTreeSet::new()
    319     }
    320 }