Skip to main content

exo_core/
error.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Error types for the EXOCHAIN constitutional trust fabric.
18//!
19//! Every failure mode in the system has a dedicated variant ensuring
20//! exhaustive error handling at compile time.
21
22use thiserror::Error;
23
24/// Unified error type for all `exo-core` operations.
25#[derive(Debug, Clone, PartialEq, Eq, Error)]
26pub enum ExoError {
27    /// A BCTS state transition was requested that violates the state machine rules.
28    #[error("invalid transition from {from} to {to}")]
29    InvalidTransition { from: String, to: String },
30
31    /// A cryptographic signature failed verification.
32    #[error("invalid signature: {reason}")]
33    InvalidSignature { reason: String },
34
35    /// A DID string did not conform to the required format.
36    #[error("invalid DID: {value}")]
37    InvalidDid { value: String },
38
39    /// The HLC detected backward drift beyond acceptable tolerance.
40    #[error("clock drift detected: physical={physical_ms}ms, tolerance={tolerance_ms}ms")]
41    ClockDrift { physical_ms: u64, tolerance_ms: u64 },
42
43    /// The HLC cannot advance because the timestamp space is exhausted.
44    #[error("clock overflow: cannot advance past physical={physical_ms}ms logical={logical}")]
45    ClockOverflow { physical_ms: u64, logical: u32 },
46
47    /// The HLC wall-clock source could not produce a trustworthy timestamp.
48    #[error("clock unavailable: {reason}")]
49    ClockUnavailable { reason: String },
50
51    /// A hash did not match the expected value.
52    #[error("hash mismatch: expected {expected}, got {actual}")]
53    HashMismatch { expected: String, actual: String },
54
55    /// The actor does not have authority for the requested operation.
56    #[error("unauthorized: {reason}")]
57    Unauthorized { reason: String },
58
59    /// An operation requires consent that has not been granted.
60    #[error("consent required: {scope}")]
61    ConsentRequired { scope: String },
62
63    /// A system invariant was violated.
64    #[error("invariant violation: {description}")]
65    InvariantViolation { description: String },
66
67    /// Sybil-resistant identity verification failed.
68    #[error("sybil detected: {evidence}")]
69    SybilDetected { evidence: String },
70
71    /// Serialization / deserialization failure.
72    #[error("serialization error: {reason}")]
73    SerializationError { reason: String },
74
75    /// Cryptographic key generation or usage error.
76    #[error("crypto error: {reason}")]
77    CryptoError { reason: String },
78
79    /// Merkle proof verification failed.
80    #[error("invalid merkle proof")]
81    InvalidMerkleProof,
82
83    /// Receipt chain integrity check failed.
84    #[error("receipt chain integrity failure at index {index}")]
85    ReceiptChainBroken { index: usize },
86
87    /// Entity not found.
88    #[error("not found: {entity}")]
89    NotFound { entity: String },
90}
91
92/// Convenient Result alias used throughout `exo-core`.
93pub type Result<T> = std::result::Result<T, ExoError>;
94
95impl ExoError {
96    /// Returns `true` when this error indicates a security-relevant failure.
97    #[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}