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 }