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 }