error.rs (7904B)
1 //! Safe public errors with separately preserved internal sources. 2 3 use core::fmt; 4 use std::error::Error; 5 6 /// Maximum byte length of every public host-error message. 7 pub const MAX_SAFE_ERROR_MESSAGE_BYTES: usize = 96; 8 9 /// Stable public error codes for host-mechanism failures. 10 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 11 pub enum HostErrorCode { 12 ConfigDocument, 13 Lifecycle, 14 AdminTransport, 15 OperationsBind, 16 OperationsServe, 17 PathContext, 18 TaskFailure, 19 InvalidHostContract, 20 } 21 22 impl HostErrorCode { 23 /// Returns the stable lowercase wire representation of this code. 24 #[must_use] 25 pub const fn as_str(self) -> &'static str { 26 match self { 27 Self::ConfigDocument => "host_config_document", 28 Self::Lifecycle => "host_lifecycle", 29 Self::AdminTransport => "host_admin_transport", 30 Self::OperationsBind => "host_operations_bind", 31 Self::OperationsServe => "host_operations_serve", 32 Self::PathContext => "host_path_context", 33 Self::TaskFailure => "host_task_failure", 34 Self::InvalidHostContract => "invalid_host_contract", 35 } 36 } 37 } 38 39 impl fmt::Display for HostErrorCode { 40 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 41 formatter.write_str(self.as_str()) 42 } 43 } 44 45 /// Service-neutral failure categories accepted by the host boundary. 46 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 47 pub enum HostErrorKind { 48 ConfigDocument, 49 Lifecycle, 50 AdminTransport, 51 OperationsBind, 52 OperationsServe, 53 PathContext, 54 TaskFailure, 55 InvalidHostContract, 56 } 57 58 impl HostErrorKind { 59 /// Returns the bounded representation safe for public boundaries. 60 #[must_use] 61 pub const fn safe_error(self) -> SafeHostError { 62 match self { 63 Self::ConfigDocument => SafeHostError::new( 64 HostErrorCode::ConfigDocument, 65 "service configuration document is invalid", 66 ), 67 Self::Lifecycle => SafeHostError::new( 68 HostErrorCode::Lifecycle, 69 "service lifecycle operation failed", 70 ), 71 Self::AdminTransport => SafeHostError::new( 72 HostErrorCode::AdminTransport, 73 "local administration transport failed", 74 ), 75 Self::OperationsBind => SafeHostError::new( 76 HostErrorCode::OperationsBind, 77 "operations listener could not be bound", 78 ), 79 Self::OperationsServe => SafeHostError::new( 80 HostErrorCode::OperationsServe, 81 "operations listener stopped unexpectedly", 82 ), 83 Self::PathContext => SafeHostError::new( 84 HostErrorCode::PathContext, 85 "service path context is invalid", 86 ), 87 Self::TaskFailure => SafeHostError::new( 88 HostErrorCode::TaskFailure, 89 "authoritative service task failed", 90 ), 91 Self::InvalidHostContract => SafeHostError::new( 92 HostErrorCode::InvalidHostContract, 93 "service host contract is invalid", 94 ), 95 } 96 } 97 } 98 99 /// Error information safe for logs, status, metrics, and wire envelopes. 100 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 101 pub struct SafeHostError { 102 code: HostErrorCode, 103 message: &'static str, 104 } 105 106 impl SafeHostError { 107 const fn new(code: HostErrorCode, message: &'static str) -> Self { 108 assert!(message.len() <= MAX_SAFE_ERROR_MESSAGE_BYTES); 109 Self { code, message } 110 } 111 112 /// Returns the typed public code. 113 #[must_use] 114 pub const fn code(self) -> HostErrorCode { 115 self.code 116 } 117 118 /// Returns the stable string used to serialize the public code. 119 #[must_use] 120 pub const fn code_str(self) -> &'static str { 121 self.code.as_str() 122 } 123 124 /// Returns the bounded public message. 125 #[must_use] 126 pub const fn message(self) -> &'static str { 127 self.message 128 } 129 } 130 131 impl fmt::Display for SafeHostError { 132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 133 write!(formatter, "{}: {}", self.code, self.message) 134 } 135 } 136 137 /// A typed host failure whose public display never includes its internal cause. 138 #[derive(Debug)] 139 pub struct HostError { 140 kind: HostErrorKind, 141 source: Option<Box<dyn Error + Send + Sync + 'static>>, 142 } 143 144 impl HostError { 145 /// Creates a host failure without an upstream cause. 146 #[must_use] 147 pub const fn new(kind: HostErrorKind) -> Self { 148 Self { kind, source: None } 149 } 150 151 /// Creates a host failure while retaining its cause for trusted inspection. 152 pub fn with_source(kind: HostErrorKind, source: impl Error + Send + Sync + 'static) -> Self { 153 Self { 154 kind, 155 source: Some(Box::new(source)), 156 } 157 } 158 159 /// Returns the service-neutral failure category. 160 #[must_use] 161 pub const fn kind(&self) -> HostErrorKind { 162 self.kind 163 } 164 165 /// Returns the bounded representation safe for public boundaries. 166 #[must_use] 167 pub const fn safe_error(&self) -> SafeHostError { 168 self.kind.safe_error() 169 } 170 } 171 172 impl fmt::Display for HostError { 173 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 174 self.safe_error().fmt(formatter) 175 } 176 } 177 178 impl Error for HostError { 179 fn source(&self) -> Option<&(dyn Error + 'static)> { 180 self.source 181 .as_deref() 182 .map(|source| source as &(dyn Error + 'static)) 183 } 184 } 185 186 #[cfg(test)] 187 mod tests { 188 use super::*; 189 190 #[derive(Debug)] 191 struct SensitiveCause; 192 193 impl fmt::Display for SensitiveCause { 194 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 195 formatter.write_str("secret upstream detail") 196 } 197 } 198 199 impl Error for SensitiveCause {} 200 201 #[test] 202 fn display_is_safe_while_source_is_preserved() { 203 let error = HostError::with_source(HostErrorKind::ConfigDocument, SensitiveCause); 204 205 assert_eq!( 206 error.to_string(), 207 "host_config_document: service configuration document is invalid" 208 ); 209 assert!(!error.to_string().contains("secret")); 210 assert_eq!( 211 error.source().map(ToString::to_string).as_deref(), 212 Some("secret upstream detail") 213 ); 214 } 215 216 #[test] 217 fn safe_codes_have_stable_serialization_helpers() { 218 let expected = [ 219 (HostErrorKind::ConfigDocument, "host_config_document"), 220 (HostErrorKind::Lifecycle, "host_lifecycle"), 221 (HostErrorKind::AdminTransport, "host_admin_transport"), 222 (HostErrorKind::OperationsBind, "host_operations_bind"), 223 (HostErrorKind::OperationsServe, "host_operations_serve"), 224 (HostErrorKind::PathContext, "host_path_context"), 225 (HostErrorKind::TaskFailure, "host_task_failure"), 226 (HostErrorKind::InvalidHostContract, "invalid_host_contract"), 227 ]; 228 229 for (kind, code) in expected { 230 let safe = kind.safe_error(); 231 assert_eq!(safe.code_str(), code); 232 assert_eq!(safe.code().to_string(), code); 233 } 234 } 235 236 #[test] 237 fn all_public_messages_are_nonempty_bounded_and_fixed() { 238 for kind in [ 239 HostErrorKind::ConfigDocument, 240 HostErrorKind::Lifecycle, 241 HostErrorKind::AdminTransport, 242 HostErrorKind::OperationsBind, 243 HostErrorKind::OperationsServe, 244 HostErrorKind::PathContext, 245 HostErrorKind::TaskFailure, 246 HostErrorKind::InvalidHostContract, 247 ] { 248 let safe = kind.safe_error(); 249 assert!(!safe.message().is_empty()); 250 assert!(safe.message().len() <= MAX_SAFE_ERROR_MESSAGE_BYTES); 251 assert!(safe.message().is_ascii()); 252 } 253 } 254 }