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 }