lib

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

error.rs (18231B)


      1 //! SDK-owned native errors and secret-safe protocol conversion.
      2 
      3 use std::{error, fmt};
      4 
      5 use radroots_protocol::{
      6     error::v1::{
      7         CapabilityId as ProtocolCapabilityId, Class, ErrorReport, KnownCode, RecoveryAction,
      8         SafeDetails, SafeMessage,
      9     },
     10     runtime::v1::OperationId,
     11 };
     12 
     13 use crate::capability::CapabilityId;
     14 
     15 macro_rules! error_catalog {
     16     ($(
     17         $kind:ident => {
     18             code: $code:ident,
     19             operation: $operation:expr,
     20             capability: $capability:expr,
     21             message: $message:literal,
     22             safe_detail_keys: [$($detail_key:literal),* $(,)?]
     23         }
     24     ),+ $(,)?) => {
     25         /// Stable native SDK error category.
     26         #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     27         #[non_exhaustive]
     28         pub enum ErrorKind {
     29             $($kind,)+
     30         }
     31 
     32         impl ErrorKind {
     33             /// Every SDK error category in catalog order.
     34             pub const ALL: &'static [Self] = &[$(Self::$kind),+];
     35 
     36             /// Returns metadata generated from the single SDK authority.
     37             #[must_use]
     38             pub const fn descriptor(self) -> ErrorDescriptor {
     39                 match self {
     40                     $(Self::$kind => ErrorDescriptor {
     41                         kind: Self::$kind,
     42                         code: KnownCode::$code,
     43                         operation: $operation,
     44                         capability: $capability,
     45                         message: $message,
     46                         safe_detail_keys: &[$($detail_key),*],
     47                     },)+
     48                 }
     49             }
     50         }
     51 
     52         /// Complete SDK-native error metadata catalog.
     53         pub const CATALOG: &[ErrorDescriptor] = &[
     54             $(ErrorDescriptor {
     55                 kind: ErrorKind::$kind,
     56                 code: KnownCode::$code,
     57                 operation: $operation,
     58                 capability: $capability,
     59                 message: $message,
     60                 safe_detail_keys: &[$($detail_key),*],
     61             },)+
     62         ];
     63     };
     64 }
     65 
     66 error_catalog! {
     67     MissingStorage => {
     68         code: MissingStorage,
     69         operation: None,
     70         capability: Some(CapabilityId::CANONICAL_STORAGE),
     71         message: "SDK storage capability is not configured",
     72         safe_detail_keys: []
     73     },
     74     CloseInProgress => {
     75         code: ClientCloseInProgress,
     76         operation: None,
     77         capability: Some(CapabilityId::CANONICAL_STORAGE),
     78         message: "SDK client close is in progress",
     79         safe_detail_keys: []
     80     },
     81     ClientClosing => {
     82         code: ClientClosing,
     83         operation: None,
     84         capability: Some(CapabilityId::CANONICAL_STORAGE),
     85         message: "SDK client close requires completion or retry",
     86         safe_detail_keys: []
     87     },
     88     ClientClosed => {
     89         code: ClientClosed,
     90         operation: None,
     91         capability: Some(CapabilityId::CANONICAL_STORAGE),
     92         message: "SDK client is closed",
     93         safe_detail_keys: []
     94     },
     95     StorageCloseFailed => {
     96         code: StorageCloseFailed,
     97         operation: None,
     98         capability: Some(CapabilityId::CANONICAL_STORAGE),
     99         message: "SDK storage close failed",
    100         safe_detail_keys: []
    101     },
    102     StorageOpenFailed => {
    103         code: InternalError,
    104         operation: None,
    105         capability: Some(CapabilityId::PERSISTENT_STORAGE),
    106         message: "SDK persistent storage open failed",
    107         safe_detail_keys: []
    108     },
    109     StorageSpaceInsufficient => {
    110         code: StorageSpaceInsufficient,
    111         operation: None,
    112         capability: Some(CapabilityId::PERSISTENT_STORAGE),
    113         message: "SDK persistent storage space is insufficient",
    114         safe_detail_keys: []
    115     },
    116     StorageBusy => {
    117         code: DatabaseBusy,
    118         operation: None,
    119         capability: Some(CapabilityId::PERSISTENT_STORAGE),
    120         message: "SDK persistent storage writer is already active",
    121         safe_detail_keys: []
    122     },
    123     StorageSchemaTooNew => {
    124         code: SchemaTooNew,
    125         operation: None,
    126         capability: Some(CapabilityId::PERSISTENT_STORAGE),
    127         message: "SDK persistent storage schema is newer than this runtime",
    128         safe_detail_keys: []
    129     },
    130     StorageUnsupportedSchema => {
    131         code: UnsupportedProfileSchema,
    132         operation: None,
    133         capability: Some(CapabilityId::PERSISTENT_STORAGE),
    134         message: "SDK persistent storage schema is unsupported",
    135         safe_detail_keys: []
    136     },
    137     StorageInspectionFailed => {
    138         code: StorageIntegrityFailed,
    139         operation: None,
    140         capability: Some(CapabilityId::CANONICAL_STORAGE),
    141         message: "SDK storage inspection failed",
    142         safe_detail_keys: []
    143     },
    144     InvalidHostConfiguration => {
    145         code: InvalidArgument,
    146         operation: None,
    147         capability: None,
    148         message: "SDK host configuration is invalid",
    149         safe_detail_keys: []
    150     },
    151     SharedOperationUnavailable => {
    152         code: TransportOperationUnavailable,
    153         operation: None,
    154         capability: None,
    155         message: "SDK shared network operation is unavailable",
    156         safe_detail_keys: []
    157     },
    158 }
    159 
    160 /// Stable metadata for one native SDK failure.
    161 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    162 pub struct ErrorDescriptor {
    163     kind: ErrorKind,
    164     code: KnownCode,
    165     operation: Option<OperationId>,
    166     capability: Option<CapabilityId>,
    167     message: &'static str,
    168     safe_detail_keys: &'static [&'static str],
    169 }
    170 
    171 impl ErrorDescriptor {
    172     /// Returns the native category.
    173     #[must_use]
    174     pub const fn kind(self) -> ErrorKind {
    175         self.kind
    176     }
    177 
    178     /// Returns the stable protocol code.
    179     #[must_use]
    180     pub const fn code(self) -> KnownCode {
    181         self.code
    182     }
    183 
    184     /// Returns the class from the generated protocol authority.
    185     #[must_use]
    186     pub const fn class(self) -> Class {
    187         self.code.descriptor().class
    188     }
    189 
    190     /// Returns retryability from the generated protocol authority.
    191     #[must_use]
    192     pub const fn retryable(self) -> bool {
    193         self.code.descriptor().retryable
    194     }
    195 
    196     /// Returns recovery actions from the generated protocol authority.
    197     #[must_use]
    198     pub const fn recovery_actions(self) -> &'static [RecoveryAction] {
    199         self.code.descriptor().recovery_actions
    200     }
    201 
    202     /// Returns the related operation when the protocol catalog defines one.
    203     #[must_use]
    204     pub const fn operation(self) -> Option<OperationId> {
    205         self.operation
    206     }
    207 
    208     /// Returns the related runtime capability.
    209     #[must_use]
    210     pub const fn capability(self) -> Option<CapabilityId> {
    211         self.capability
    212     }
    213 
    214     /// Returns the secret-safe native display message.
    215     #[must_use]
    216     pub const fn message(self) -> &'static str {
    217         self.message
    218     }
    219 
    220     /// Returns the only structured detail keys permitted for this category.
    221     #[must_use]
    222     pub const fn safe_detail_keys(self) -> &'static [&'static str] {
    223         self.safe_detail_keys
    224     }
    225 }
    226 
    227 /// Native SDK failure retaining an optional private source chain.
    228 pub struct Error {
    229     kind: ErrorKind,
    230     source: Option<Box<dyn error::Error + Send + Sync>>,
    231 }
    232 
    233 impl Error {
    234     pub(crate) fn missing_storage() -> Self {
    235         Self::without_source(ErrorKind::MissingStorage)
    236     }
    237 
    238     pub(crate) fn close_in_progress() -> Self {
    239         Self::without_source(ErrorKind::CloseInProgress)
    240     }
    241 
    242     pub(crate) fn client_closing() -> Self {
    243         Self::without_source(ErrorKind::ClientClosing)
    244     }
    245 
    246     pub(crate) fn client_closed() -> Self {
    247         Self::without_source(ErrorKind::ClientClosed)
    248     }
    249 
    250     pub(crate) fn storage_close_failed(source: radroots_storage::Error) -> Self {
    251         Self {
    252             kind: ErrorKind::StorageCloseFailed,
    253             source: Some(Box::new(source)),
    254         }
    255     }
    256 
    257     #[cfg(feature = "sqlite")]
    258     pub(crate) fn storage_open_failed(source: radroots_storage_sqlite::Error) -> Self {
    259         use radroots_storage_sqlite::Error as SqliteError;
    260 
    261         let kind = match &source {
    262             SqliteError::SpaceInsufficient => ErrorKind::StorageSpaceInsufficient,
    263             SqliteError::Inspect { source, .. }
    264             | SqliteError::WriterLockOpen { source, .. }
    265             | SqliteError::WriterLockFailed { source, .. }
    266                 if matches!(
    267                     source.kind(),
    268                     std::io::ErrorKind::StorageFull | std::io::ErrorKind::QuotaExceeded
    269                 ) =>
    270             {
    271                 ErrorKind::StorageSpaceInsufficient
    272             }
    273             SqliteError::WriterAlreadyActive { .. } => ErrorKind::StorageBusy,
    274             SqliteError::SchemaTooNew { .. } => ErrorKind::StorageSchemaTooNew,
    275             SqliteError::SchemaTooOld { .. } | SqliteError::SchemaMigrationRequired { .. } => {
    276                 ErrorKind::StorageUnsupportedSchema
    277             }
    278             SqliteError::SchemaMetadataUnavailable { .. }
    279             | SqliteError::DatabaseCorrupt { .. }
    280             | SqliteError::SchemaIdentityMismatch { .. }
    281             | SqliteError::UnrecognizedSchema { .. }
    282             | SqliteError::SchemaCatalogMismatch { .. }
    283             | SqliteError::SchemaMigrationFailed { .. }
    284             | SqliteError::AuthoredMigrationBlocked { .. }
    285             | SqliteError::SourceGenerationMismatch
    286             | SqliteError::CorruptSourceGeneration
    287             | SqliteError::RestoreMarkerCorrupt(_)
    288             | SqliteError::RestoreRecoveryConflict(_) => ErrorKind::StorageInspectionFailed,
    289             _ => ErrorKind::StorageOpenFailed,
    290         };
    291         Self {
    292             kind,
    293             source: Some(Box::new(source)),
    294         }
    295     }
    296 
    297     pub(crate) fn storage_inspection_failed(source: radroots_storage::Error) -> Self {
    298         Self {
    299             kind: ErrorKind::StorageInspectionFailed,
    300             source: Some(Box::new(source)),
    301         }
    302     }
    303 
    304     #[cfg(any(feature = "blossom", feature = "sync", feature = "nostr"))]
    305     pub(crate) fn invalid_host_configuration(
    306         source: impl error::Error + Send + Sync + 'static,
    307     ) -> Self {
    308         Self {
    309             kind: ErrorKind::InvalidHostConfiguration,
    310             source: Some(Box::new(source)),
    311         }
    312     }
    313 
    314     #[cfg(feature = "nostr")]
    315     pub(crate) fn invalid_host_configuration_without_source() -> Self {
    316         Self::without_source(ErrorKind::InvalidHostConfiguration)
    317     }
    318 
    319     #[cfg(any(feature = "blossom", feature = "sync", feature = "nostr"))]
    320     pub(crate) fn shared_operation_unavailable() -> Self {
    321         Self::without_source(ErrorKind::SharedOperationUnavailable)
    322     }
    323 
    324     fn without_source(kind: ErrorKind) -> Self {
    325         Self { kind, source: None }
    326     }
    327 
    328     /// Returns the stable native category.
    329     #[must_use]
    330     pub const fn kind(&self) -> ErrorKind {
    331         self.kind
    332     }
    333 
    334     /// Returns metadata from the single SDK catalog.
    335     #[must_use]
    336     pub const fn descriptor(&self) -> ErrorDescriptor {
    337         self.kind.descriptor()
    338     }
    339 
    340     /// Converts to the V1 secret-safe protocol boundary.
    341     ///
    342     /// Native source messages are deliberately excluded. Catalog validation
    343     /// tests guarantee that static messages and capability IDs are valid; this
    344     /// conversion still fails closed to redacted text or no capability if a
    345     /// future catalog edit violates those invariants.
    346     #[must_use]
    347     pub fn to_report(&self) -> ErrorReport {
    348         let descriptor = self.descriptor();
    349         let capability = descriptor
    350             .capability()
    351             .and_then(|id| ProtocolCapabilityId::parse(id.as_str().to_owned()).ok());
    352         let message = SafeMessage::parse(descriptor.message().to_owned())
    353             .unwrap_or_else(|_| SafeMessage::redacted());
    354         ErrorReport::known(
    355             descriptor.code(),
    356             descriptor.operation(),
    357             capability,
    358             message,
    359             SafeDetails::default(),
    360         )
    361     }
    362 }
    363 
    364 impl fmt::Display for Error {
    365     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    366         formatter.write_str(self.descriptor().message())
    367     }
    368 }
    369 
    370 impl fmt::Debug for Error {
    371     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    372         formatter
    373             .debug_struct("Error")
    374             .field("kind", &self.kind)
    375             .field("code", &self.descriptor().code())
    376             .finish_non_exhaustive()
    377     }
    378 }
    379 
    380 impl error::Error for Error {
    381     fn source(&self) -> Option<&(dyn error::Error + 'static)> {
    382         self.source
    383             .as_deref()
    384             .map(|source| source as &(dyn error::Error + 'static))
    385     }
    386 }
    387 
    388 /// SDK result alias.
    389 pub type Result<T> = std::result::Result<T, Error>;
    390 
    391 #[cfg(test)]
    392 mod tests {
    393     use std::{collections::BTreeSet, error::Error as _};
    394 
    395     use super::*;
    396 
    397     #[cfg(feature = "sqlite")]
    398     #[test]
    399     fn startup_capacity_report_preserves_source_but_never_exposes_path() {
    400         use radroots_storage_sqlite::Error as Sqlite;
    401         let mut sources = vec![Sqlite::SpaceInsufficient];
    402         for kind in [
    403             std::io::ErrorKind::StorageFull,
    404             std::io::ErrorKind::QuotaExceeded,
    405         ] {
    406             sources.extend([
    407                 Sqlite::Inspect {
    408                     path: "/private/secret".into(),
    409                     source: std::io::Error::new(kind, "private detail"),
    410                 },
    411                 Sqlite::WriterLockOpen {
    412                     path: "/private/secret".into(),
    413                     source: std::io::Error::new(kind, "private detail"),
    414                 },
    415                 Sqlite::WriterLockFailed {
    416                     path: "/private/secret".into(),
    417                     source: std::io::Error::new(kind, "private detail"),
    418                 },
    419             ]);
    420         }
    421         for source in sources {
    422             let error = Error::storage_open_failed(source);
    423             assert!(error.source().is_some());
    424             assert_eq!(error.kind(), ErrorKind::StorageSpaceInsufficient);
    425             let report = error.to_report();
    426             report.validate().unwrap();
    427             assert_eq!(report.code().as_str(), "storage_space_insufficient");
    428             assert_eq!(
    429                 report.message().as_str(),
    430                 "SDK persistent storage space is insufficient"
    431             );
    432             assert!(!format!("{error:?}").contains("private"));
    433         }
    434         for kind in [
    435             std::io::ErrorKind::PermissionDenied,
    436             std::io::ErrorKind::Other,
    437         ] {
    438             assert_eq!(
    439                 Error::storage_open_failed(Sqlite::WriterLockOpen {
    440                     path: "/private/secret".into(),
    441                     source: kind.into(),
    442                 })
    443                 .kind(),
    444                 ErrorKind::StorageOpenFailed
    445             );
    446         }
    447     }
    448 
    449     #[test]
    450     fn catalog_is_exhaustive_unique_and_protocol_valid() {
    451         assert_eq!(CATALOG.len(), ErrorKind::ALL.len());
    452         let mut kinds = BTreeSet::new();
    453         let mut codes = BTreeSet::new();
    454         for (index, descriptor) in CATALOG.iter().copied().enumerate() {
    455             assert!(kinds.insert(descriptor.kind()));
    456             assert!(codes.insert(descriptor.code().as_str()));
    457             assert_eq!(descriptor.kind(), ErrorKind::ALL[index]);
    458             assert_eq!(descriptor, descriptor.kind().descriptor());
    459             assert!(!descriptor.recovery_actions().is_empty());
    460             assert!(SafeMessage::parse(descriptor.message()).is_ok());
    461             if let Some(capability) = descriptor.capability() {
    462                 assert!(ProtocolCapabilityId::parse(capability.as_str()).is_ok());
    463             }
    464             assert!(descriptor.safe_detail_keys().is_empty());
    465             Error::without_source(descriptor.kind())
    466                 .to_report()
    467                 .validate()
    468                 .expect("protocol report");
    469         }
    470     }
    471 
    472     #[test]
    473     fn native_source_is_preserved_but_protocol_report_is_redacted_from_it() {
    474         let error = Error::storage_close_failed(radroots_storage::Error::BackendUnavailable);
    475         assert!(error.source().is_some());
    476         assert_eq!(error.kind(), ErrorKind::StorageCloseFailed);
    477         assert_eq!(error.to_string(), "SDK storage close failed");
    478         let report = error.to_report();
    479         assert_eq!(report.code().as_str(), "storage_close_failed");
    480         assert_eq!(report.message().as_str(), "SDK storage close failed");
    481         assert!(!report.message().as_str().contains("backend"));
    482         assert!(format!("{error:?}").contains("StorageCloseFailed"));
    483         assert!(!format!("{error:?}").contains("BackendUnavailable"));
    484     }
    485 
    486     #[test]
    487     fn native_constructor_and_descriptor_surface_is_complete() {
    488         let source_free = [
    489             Error::missing_storage(),
    490             Error::close_in_progress(),
    491             Error::client_closing(),
    492             Error::client_closed(),
    493         ];
    494         for error in source_free {
    495             assert!(error.source().is_none());
    496             let descriptor = error.descriptor();
    497             assert_eq!(descriptor.kind(), error.kind());
    498             assert_eq!(descriptor.class(), descriptor.code().descriptor().class);
    499             assert_eq!(
    500                 descriptor.retryable(),
    501                 descriptor.code().descriptor().retryable
    502             );
    503             assert_eq!(descriptor.operation(), None);
    504             assert_eq!(descriptor.message(), error.to_string());
    505         }
    506 
    507         let inspection =
    508             Error::storage_inspection_failed(radroots_storage::Error::BackendUnavailable);
    509         assert_eq!(inspection.kind(), ErrorKind::StorageInspectionFailed);
    510         assert!(inspection.source().is_some());
    511 
    512         #[cfg(any(feature = "sync", feature = "nostr"))]
    513         {
    514             let host = Error::invalid_host_configuration(std::io::Error::other("private"));
    515             assert_eq!(host.kind(), ErrorKind::InvalidHostConfiguration);
    516             assert!(host.source().is_some());
    517             assert!(!host.to_string().contains("private"));
    518             assert_eq!(
    519                 Error::shared_operation_unavailable().kind(),
    520                 ErrorKind::SharedOperationUnavailable
    521             );
    522         }
    523 
    524         #[cfg(feature = "nostr")]
    525         assert!(
    526             Error::invalid_host_configuration_without_source()
    527                 .source()
    528                 .is_none()
    529         );
    530     }
    531 }