1use thiserror::Error;
23
24#[derive(Debug, Clone, PartialEq, Eq, Error)]
26pub enum ExoError {
27 #[error("invalid transition from {from} to {to}")]
29 InvalidTransition { from: String, to: String },
30
31 #[error("invalid signature: {reason}")]
33 InvalidSignature { reason: String },
34
35 #[error("invalid DID: {value}")]
37 InvalidDid { value: String },
38
39 #[error("clock drift detected: physical={physical_ms}ms, tolerance={tolerance_ms}ms")]
41 ClockDrift { physical_ms: u64, tolerance_ms: u64 },
42
43 #[error("clock overflow: cannot advance past physical={physical_ms}ms logical={logical}")]
45 ClockOverflow { physical_ms: u64, logical: u32 },
46
47 #[error("clock unavailable: {reason}")]
49 ClockUnavailable { reason: String },
50
51 #[error("hash mismatch: expected {expected}, got {actual}")]
53 HashMismatch { expected: String, actual: String },
54
55 #[error("unauthorized: {reason}")]
57 Unauthorized { reason: String },
58
59 #[error("consent required: {scope}")]
61 ConsentRequired { scope: String },
62
63 #[error("invariant violation: {description}")]
65 InvariantViolation { description: String },
66
67 #[error("sybil detected: {evidence}")]
69 SybilDetected { evidence: String },
70
71 #[error("serialization error: {reason}")]
73 SerializationError { reason: String },
74
75 #[error("crypto error: {reason}")]
77 CryptoError { reason: String },
78
79 #[error("invalid merkle proof")]
81 InvalidMerkleProof,
82
83 #[error("receipt chain integrity failure at index {index}")]
85 ReceiptChainBroken { index: usize },
86
87 #[error("not found: {entity}")]
89 NotFound { entity: String },
90}
91
92pub type Result<T> = std::result::Result<T, ExoError>;
94
95impl ExoError {
96 #[must_use]
98 pub fn is_security_relevant(&self) -> bool {
99 matches!(
100 self,
101 ExoError::InvalidSignature { .. }
102 | ExoError::Unauthorized { .. }
103 | ExoError::SybilDetected { .. }
104 | ExoError::HashMismatch { .. }
105 | ExoError::ClockUnavailable { .. }
106 )
107 }
108}
109
110impl<T> From<ciborium::ser::Error<T>> for ExoError {
111 fn from(e: ciborium::ser::Error<T>) -> Self {
112 let reason = match e {
113 ciborium::ser::Error::Io(_) => "CBOR serialization I/O error",
114 ciborium::ser::Error::Value(_) => "CBOR serialization value error",
115 };
116 ExoError::SerializationError {
117 reason: reason.into(),
118 }
119 }
120}
121
122impl<T> From<ciborium::de::Error<T>> for ExoError {
123 fn from(e: ciborium::de::Error<T>) -> Self {
124 let reason = match e {
125 ciborium::de::Error::Io(_) => "CBOR deserialization I/O error",
126 ciborium::de::Error::Syntax(_) => "CBOR deserialization syntax error",
127 ciborium::de::Error::Semantic(_, _) => "CBOR deserialization semantic error",
128 ciborium::de::Error::RecursionLimitExceeded => {
129 "CBOR deserialization recursion limit exceeded"
130 }
131 };
132 ExoError::SerializationError {
133 reason: reason.into(),
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn display_all_variants() {
144 let cases: Vec<(ExoError, &str)> = vec![
145 (
146 ExoError::InvalidTransition {
147 from: "Draft".into(),
148 to: "Closed".into(),
149 },
150 "Draft",
151 ),
152 (
153 ExoError::InvalidSignature {
154 reason: "bad bytes".into(),
155 },
156 "bad bytes",
157 ),
158 (
159 ExoError::InvalidDid {
160 value: "garbage".into(),
161 },
162 "garbage",
163 ),
164 (
165 ExoError::ClockDrift {
166 physical_ms: 5000,
167 tolerance_ms: 1000,
168 },
169 "5000",
170 ),
171 (
172 ExoError::ClockUnavailable {
173 reason: "clock source failed".into(),
174 },
175 "clock source failed",
176 ),
177 (
178 ExoError::HashMismatch {
179 expected: "aaa".into(),
180 actual: "bbb".into(),
181 },
182 "aaa",
183 ),
184 (
185 ExoError::Unauthorized {
186 reason: "no role".into(),
187 },
188 "no role",
189 ),
190 (
191 ExoError::ConsentRequired {
192 scope: "data-share".into(),
193 },
194 "data-share",
195 ),
196 (
197 ExoError::InvariantViolation {
198 description: "bad state".into(),
199 },
200 "bad state",
201 ),
202 (
203 ExoError::SybilDetected {
204 evidence: "dup key".into(),
205 },
206 "dup key",
207 ),
208 (
209 ExoError::SerializationError {
210 reason: "cbor fail".into(),
211 },
212 "cbor fail",
213 ),
214 (
215 ExoError::CryptoError {
216 reason: "rng fail".into(),
217 },
218 "rng fail",
219 ),
220 (ExoError::InvalidMerkleProof, "invalid merkle proof"),
221 (ExoError::ReceiptChainBroken { index: 3 }, "3"),
222 (
223 ExoError::NotFound {
224 entity: "item".into(),
225 },
226 "item",
227 ),
228 ];
229 for (e, expected_substr) in cases {
230 assert!(e.to_string().contains(expected_substr), "failed for: {e:?}");
231 }
232 }
233
234 #[test]
235 fn is_security_relevant_positive() {
236 assert!(ExoError::InvalidSignature { reason: "x".into() }.is_security_relevant());
237 assert!(ExoError::Unauthorized { reason: "x".into() }.is_security_relevant());
238 assert!(
239 ExoError::SybilDetected {
240 evidence: "x".into()
241 }
242 .is_security_relevant()
243 );
244 assert!(
245 ExoError::HashMismatch {
246 expected: "a".into(),
247 actual: "b".into()
248 }
249 .is_security_relevant()
250 );
251 assert!(ExoError::ClockUnavailable { reason: "x".into() }.is_security_relevant());
252 }
253
254 #[test]
255 fn is_security_relevant_negative() {
256 assert!(!ExoError::InvalidDid { value: "x".into() }.is_security_relevant());
257 assert!(
258 !ExoError::ClockDrift {
259 physical_ms: 1,
260 tolerance_ms: 1
261 }
262 .is_security_relevant()
263 );
264 assert!(!ExoError::InvalidMerkleProof.is_security_relevant());
265 assert!(
266 !ExoError::InvariantViolation {
267 description: "x".into()
268 }
269 .is_security_relevant()
270 );
271 assert!(!ExoError::NotFound { entity: "x".into() }.is_security_relevant());
272 }
273
274 #[test]
275 fn clone_eq_debug() {
276 let e1 = ExoError::InvalidMerkleProof;
277 let e2 = e1.clone();
278 assert_eq!(e1, e2);
279 let dbg = format!("{e1:?}");
280 assert!(dbg.contains("InvalidMerkleProof"));
281 }
282
283 #[test]
284 fn cbor_error_conversion_redacts_underlying_debug_details() {
285 struct LeakyIoError;
286
287 impl core::fmt::Debug for LeakyIoError {
288 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
289 f.write_str("tenant-secret-token")
290 }
291 }
292
293 let serialized: ExoError = ciborium::ser::Error::Io(LeakyIoError).into();
294 let deserialized: ExoError = ciborium::de::Error::Io(LeakyIoError).into();
295
296 for error in [serialized, deserialized] {
297 let ExoError::SerializationError { reason } = error else {
298 panic!("expected serialization error");
299 };
300 assert!(
301 !reason.contains("tenant-secret-token"),
302 "underlying debug details must not be exposed in public error text: {reason}"
303 );
304 }
305 }
306
307 #[test]
308 fn result_alias() {
309 let ok: Result<u32> = Ok(42);
310 assert!(ok.is_ok());
311 if let Ok(val) = ok {
312 assert_eq!(val, 42);
313 }
314 }
315
316 #[test]
317 fn error_trait_source_is_none() {
318 use std::error::Error;
319 let e = ExoError::InvalidMerkleProof;
320 assert!(e.source().is_none());
321 }
322}