Skip to main content

cdx_core/
error.rs

1//! Error types for cdx-core.
2
3use std::path::PathBuf;
4use thiserror::Error;
5
6/// Result type alias using [`enum@Error`].
7pub type Result<T> = std::result::Result<T, Error>;
8
9/// Errors that can occur when working with Codex documents.
10#[derive(Debug, Error)]
11pub enum Error {
12    /// The file is not a valid ZIP archive.
13    #[error("invalid ZIP archive: {0}")]
14    InvalidArchive(#[from] zip::result::ZipError),
15
16    /// JSON parsing or serialization failed.
17    #[error("JSON error: {0}")]
18    Json(#[from] serde_json::Error),
19
20    /// I/O operation failed.
21    #[error("I/O error: {0}")]
22    Io(#[from] std::io::Error),
23
24    /// A required file is missing from the archive.
25    #[error("missing required file: {path}")]
26    MissingFile {
27        /// Path of the missing file.
28        path: String,
29    },
30
31    /// The manifest is invalid.
32    #[error("invalid manifest: {reason}")]
33    InvalidManifest {
34        /// Description of the validation failure.
35        reason: String,
36    },
37
38    /// The document's Codex version is not supported.
39    #[error("unsupported Codex version: {version}")]
40    UnsupportedVersion {
41        /// The unsupported version string.
42        version: String,
43    },
44
45    /// Hash verification failed.
46    #[error("hash mismatch for {path}: expected {expected}, got {actual}")]
47    HashMismatch {
48        /// Path of the file with mismatched hash.
49        path: String,
50        /// Expected hash value.
51        expected: String,
52        /// Actual computed hash value.
53        actual: String,
54    },
55
56    /// Document ID verification failed.
57    #[error("document ID mismatch: expected {expected}, got {actual}")]
58    DocumentIdMismatch {
59        /// Expected document ID.
60        expected: String,
61        /// Actual computed document ID.
62        actual: String,
63    },
64
65    /// Invalid document state transition.
66    #[error("invalid state transition from {from:?} to {to:?}")]
67    InvalidStateTransition {
68        /// Current state.
69        from: crate::DocumentState,
70        /// Attempted target state.
71        to: crate::DocumentState,
72    },
73
74    /// State requirements not met.
75    #[error("state {state:?} requires {requirement}")]
76    StateRequirementNotMet {
77        /// The document state with unmet requirements.
78        state: crate::DocumentState,
79        /// Description of the unmet requirement.
80        requirement: String,
81    },
82
83    /// Path traversal attempt detected (security).
84    #[error("path traversal detected: {path}")]
85    PathTraversal {
86        /// The suspicious path.
87        path: String,
88    },
89
90    /// Hash algorithm is not supported.
91    #[error("unsupported hash algorithm: {algorithm}")]
92    UnsupportedHashAlgorithm {
93        /// The unsupported algorithm identifier.
94        algorithm: String,
95    },
96
97    /// Invalid hash format.
98    #[error("invalid hash format: {value}")]
99    InvalidHashFormat {
100        /// The invalid hash string.
101        value: String,
102    },
103
104    /// File not found.
105    #[error("file not found: {}", path.display())]
106    FileNotFound {
107        /// Path to the missing file.
108        path: PathBuf,
109    },
110
111    /// Invalid certificate.
112    #[error("invalid certificate: {reason}")]
113    InvalidCertificate {
114        /// Description of the certificate issue.
115        reason: String,
116    },
117
118    /// Network operation failed.
119    #[error("network error: {message}")]
120    Network {
121        /// Description of the network error.
122        message: String,
123    },
124
125    /// Feature not implemented.
126    #[error("not implemented: {feature}")]
127    NotImplemented {
128        /// Description of the unimplemented feature.
129        feature: String,
130    },
131
132    /// Cannot modify document in immutable state.
133    #[error("cannot {action} in {state:?} state")]
134    ImmutableDocument {
135        /// The action that was attempted.
136        action: String,
137        /// Current document state.
138        state: crate::DocumentState,
139    },
140
141    /// Extension not found or not loaded.
142    #[error("extension not available: {extension}")]
143    ExtensionNotAvailable {
144        /// Name of the missing extension.
145        extension: String,
146    },
147
148    /// Content validation failed.
149    #[error("content validation failed: {reason}")]
150    ValidationFailed {
151        /// Description of the validation failure.
152        reason: String,
153    },
154
155    /// Signature operation failed.
156    #[error("signature error: {reason}")]
157    SignatureError {
158        /// Description of the signature issue.
159        reason: String,
160    },
161
162    /// Encryption operation failed.
163    #[error("encryption error: {reason}")]
164    EncryptionError {
165        /// Description of the encryption issue.
166        reason: String,
167    },
168
169    /// File exceeds the maximum allowed size (decompression bomb protection).
170    #[error("file too large: {path} is {size} bytes (limit: {limit} bytes)")]
171    FileTooLarge {
172        /// Path of the oversized file.
173        path: String,
174        /// Actual or declared size in bytes.
175        size: u64,
176        /// Maximum allowed size in bytes.
177        limit: u64,
178    },
179
180    /// Archive structure is invalid.
181    #[error("invalid archive structure: {reason}")]
182    InvalidArchiveStructure {
183        /// Description of the structural issue.
184        reason: String,
185    },
186}
187
188/// Create an [`Error::InvalidManifest`] with a formatted reason.
189pub(crate) fn invalid_manifest(reason: impl Into<String>) -> Error {
190    Error::InvalidManifest {
191        reason: reason.into(),
192    }
193}
194
195/// Create an [`Error::EncryptionError`] with a formatted reason.
196pub(crate) fn encryption_error(reason: impl Into<String>) -> Error {
197    Error::EncryptionError {
198        reason: reason.into(),
199    }
200}
201
202/// Create an [`Error::Network`] error with a formatted message.
203pub(crate) fn network_error(message: impl Into<String>) -> Error {
204    Error::Network {
205        message: message.into(),
206    }
207}
208
209/// Create an [`Error::InvalidCertificate`] with a formatted reason.
210pub(crate) fn invalid_certificate(reason: impl Into<String>) -> Error {
211    Error::InvalidCertificate {
212        reason: reason.into(),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::path::PathBuf;
220
221    #[test]
222    fn display_invalid_archive() {
223        let err = Error::InvalidArchive(zip::result::ZipError::FileNotFound);
224        assert!(err.to_string().contains("invalid ZIP archive"));
225    }
226
227    #[test]
228    fn display_json() {
229        let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
230        let err = Error::Json(json_err);
231        assert!(err.to_string().starts_with("JSON error:"));
232    }
233
234    #[test]
235    fn display_io() {
236        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
237        let err = Error::Io(io_err);
238        assert!(err.to_string().starts_with("I/O error:"));
239    }
240
241    #[test]
242    fn display_missing_file() {
243        let err = Error::MissingFile {
244            path: "manifest.json".to_string(),
245        };
246        assert_eq!(err.to_string(), "missing required file: manifest.json");
247    }
248
249    #[test]
250    fn display_invalid_manifest() {
251        let err = Error::InvalidManifest {
252            reason: "bad version".to_string(),
253        };
254        assert_eq!(err.to_string(), "invalid manifest: bad version");
255    }
256
257    #[test]
258    fn display_unsupported_version() {
259        let err = Error::UnsupportedVersion {
260            version: "99.0".to_string(),
261        };
262        assert_eq!(err.to_string(), "unsupported Codex version: 99.0");
263    }
264
265    #[test]
266    fn display_hash_mismatch() {
267        let err = Error::HashMismatch {
268            path: "content.json".to_string(),
269            expected: "abc".to_string(),
270            actual: "def".to_string(),
271        };
272        assert_eq!(
273            err.to_string(),
274            "hash mismatch for content.json: expected abc, got def"
275        );
276    }
277
278    #[test]
279    fn display_document_id_mismatch() {
280        let err = Error::DocumentIdMismatch {
281            expected: "id1".to_string(),
282            actual: "id2".to_string(),
283        };
284        assert_eq!(
285            err.to_string(),
286            "document ID mismatch: expected id1, got id2"
287        );
288    }
289
290    #[test]
291    fn display_invalid_state_transition() {
292        let err = Error::InvalidStateTransition {
293            from: crate::DocumentState::Draft,
294            to: crate::DocumentState::Frozen,
295        };
296        assert!(err.to_string().contains("invalid state transition"));
297        assert!(err.to_string().contains("Draft"));
298        assert!(err.to_string().contains("Frozen"));
299    }
300
301    #[test]
302    fn display_state_requirement_not_met() {
303        let err = Error::StateRequirementNotMet {
304            state: crate::DocumentState::Frozen,
305            requirement: "at least one signature".to_string(),
306        };
307        assert!(err.to_string().contains("Frozen"));
308        assert!(err.to_string().contains("at least one signature"));
309    }
310
311    #[test]
312    fn display_path_traversal() {
313        let err = Error::PathTraversal {
314            path: "../etc/passwd".to_string(),
315        };
316        assert_eq!(err.to_string(), "path traversal detected: ../etc/passwd");
317    }
318
319    #[test]
320    fn display_unsupported_hash_algorithm() {
321        let err = Error::UnsupportedHashAlgorithm {
322            algorithm: "md5".to_string(),
323        };
324        assert_eq!(err.to_string(), "unsupported hash algorithm: md5");
325    }
326
327    #[test]
328    fn display_invalid_hash_format() {
329        let err = Error::InvalidHashFormat {
330            value: "not-a-hash".to_string(),
331        };
332        assert_eq!(err.to_string(), "invalid hash format: not-a-hash");
333    }
334
335    #[test]
336    fn display_file_not_found() {
337        let err = Error::FileNotFound {
338            path: PathBuf::from("/tmp/missing.cdx"),
339        };
340        assert!(err.to_string().contains("file not found"));
341        assert!(err.to_string().contains("missing.cdx"));
342    }
343
344    #[test]
345    fn display_invalid_certificate() {
346        let err = Error::InvalidCertificate {
347            reason: "expired".to_string(),
348        };
349        assert_eq!(err.to_string(), "invalid certificate: expired");
350    }
351
352    #[test]
353    fn display_network() {
354        let err = Error::Network {
355            message: "timeout".to_string(),
356        };
357        assert_eq!(err.to_string(), "network error: timeout");
358    }
359
360    #[test]
361    fn display_not_implemented() {
362        let err = Error::NotImplemented {
363            feature: "blockchain anchoring".to_string(),
364        };
365        assert_eq!(err.to_string(), "not implemented: blockchain anchoring");
366    }
367
368    #[test]
369    fn display_immutable_document() {
370        let err = Error::ImmutableDocument {
371            action: "modify content".to_string(),
372            state: crate::DocumentState::Frozen,
373        };
374        assert!(err.to_string().contains("cannot modify content"));
375        assert!(err.to_string().contains("Frozen"));
376    }
377
378    #[test]
379    fn display_extension_not_available() {
380        let err = Error::ExtensionNotAvailable {
381            extension: "forms".to_string(),
382        };
383        assert_eq!(err.to_string(), "extension not available: forms");
384    }
385
386    #[test]
387    fn display_validation_failed() {
388        let err = Error::ValidationFailed {
389            reason: "empty content".to_string(),
390        };
391        assert_eq!(err.to_string(), "content validation failed: empty content");
392    }
393
394    #[test]
395    fn display_signature_error() {
396        let err = Error::SignatureError {
397            reason: "invalid key".to_string(),
398        };
399        assert_eq!(err.to_string(), "signature error: invalid key");
400    }
401
402    #[test]
403    fn display_encryption_error() {
404        let err = Error::EncryptionError {
405            reason: "wrong password".to_string(),
406        };
407        assert_eq!(err.to_string(), "encryption error: wrong password");
408    }
409
410    #[test]
411    fn display_file_too_large() {
412        let err = Error::FileTooLarge {
413            path: "assets/huge.bin".to_string(),
414            size: 512 * 1024 * 1024,
415            limit: 256 * 1024 * 1024,
416        };
417        let msg = err.to_string();
418        assert!(msg.contains("file too large"));
419        assert!(msg.contains("assets/huge.bin"));
420    }
421
422    #[test]
423    fn display_invalid_archive_structure() {
424        let err = Error::InvalidArchiveStructure {
425            reason: "manifest not first".to_string(),
426        };
427        assert_eq!(
428            err.to_string(),
429            "invalid archive structure: manifest not first"
430        );
431    }
432}