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::SignatureError`] with a formatted reason.
196pub(crate) fn signature_error(reason: impl Into<String>) -> Error {
197    Error::SignatureError {
198        reason: reason.into(),
199    }
200}
201
202/// Create an [`Error::EncryptionError`] with a formatted reason.
203pub(crate) fn encryption_error(reason: impl Into<String>) -> Error {
204    Error::EncryptionError {
205        reason: reason.into(),
206    }
207}
208
209/// Create an [`Error::Network`] error with a formatted message.
210pub(crate) fn network_error(message: impl Into<String>) -> Error {
211    Error::Network {
212        message: message.into(),
213    }
214}
215
216/// Create an [`Error::InvalidCertificate`] with a formatted reason.
217pub(crate) fn invalid_certificate(reason: impl Into<String>) -> Error {
218    Error::InvalidCertificate {
219        reason: reason.into(),
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::path::PathBuf;
227
228    #[test]
229    fn display_invalid_archive() {
230        let err = Error::InvalidArchive(zip::result::ZipError::FileNotFound);
231        assert!(err.to_string().contains("invalid ZIP archive"));
232    }
233
234    #[test]
235    fn display_json() {
236        let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
237        let err = Error::Json(json_err);
238        assert!(err.to_string().starts_with("JSON error:"));
239    }
240
241    #[test]
242    fn display_io() {
243        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
244        let err = Error::Io(io_err);
245        assert!(err.to_string().starts_with("I/O error:"));
246    }
247
248    #[test]
249    fn display_missing_file() {
250        let err = Error::MissingFile {
251            path: "manifest.json".to_string(),
252        };
253        assert_eq!(err.to_string(), "missing required file: manifest.json");
254    }
255
256    #[test]
257    fn display_invalid_manifest() {
258        let err = Error::InvalidManifest {
259            reason: "bad version".to_string(),
260        };
261        assert_eq!(err.to_string(), "invalid manifest: bad version");
262    }
263
264    #[test]
265    fn display_unsupported_version() {
266        let err = Error::UnsupportedVersion {
267            version: "99.0".to_string(),
268        };
269        assert_eq!(err.to_string(), "unsupported Codex version: 99.0");
270    }
271
272    #[test]
273    fn display_hash_mismatch() {
274        let err = Error::HashMismatch {
275            path: "content.json".to_string(),
276            expected: "abc".to_string(),
277            actual: "def".to_string(),
278        };
279        assert_eq!(
280            err.to_string(),
281            "hash mismatch for content.json: expected abc, got def"
282        );
283    }
284
285    #[test]
286    fn display_document_id_mismatch() {
287        let err = Error::DocumentIdMismatch {
288            expected: "id1".to_string(),
289            actual: "id2".to_string(),
290        };
291        assert_eq!(
292            err.to_string(),
293            "document ID mismatch: expected id1, got id2"
294        );
295    }
296
297    #[test]
298    fn display_invalid_state_transition() {
299        let err = Error::InvalidStateTransition {
300            from: crate::DocumentState::Draft,
301            to: crate::DocumentState::Frozen,
302        };
303        assert!(err.to_string().contains("invalid state transition"));
304        assert!(err.to_string().contains("Draft"));
305        assert!(err.to_string().contains("Frozen"));
306    }
307
308    #[test]
309    fn display_state_requirement_not_met() {
310        let err = Error::StateRequirementNotMet {
311            state: crate::DocumentState::Frozen,
312            requirement: "at least one signature".to_string(),
313        };
314        assert!(err.to_string().contains("Frozen"));
315        assert!(err.to_string().contains("at least one signature"));
316    }
317
318    #[test]
319    fn display_path_traversal() {
320        let err = Error::PathTraversal {
321            path: "../etc/passwd".to_string(),
322        };
323        assert_eq!(err.to_string(), "path traversal detected: ../etc/passwd");
324    }
325
326    #[test]
327    fn display_unsupported_hash_algorithm() {
328        let err = Error::UnsupportedHashAlgorithm {
329            algorithm: "md5".to_string(),
330        };
331        assert_eq!(err.to_string(), "unsupported hash algorithm: md5");
332    }
333
334    #[test]
335    fn display_invalid_hash_format() {
336        let err = Error::InvalidHashFormat {
337            value: "not-a-hash".to_string(),
338        };
339        assert_eq!(err.to_string(), "invalid hash format: not-a-hash");
340    }
341
342    #[test]
343    fn display_file_not_found() {
344        let err = Error::FileNotFound {
345            path: PathBuf::from("/tmp/missing.cdx"),
346        };
347        assert!(err.to_string().contains("file not found"));
348        assert!(err.to_string().contains("missing.cdx"));
349    }
350
351    #[test]
352    fn display_invalid_certificate() {
353        let err = Error::InvalidCertificate {
354            reason: "expired".to_string(),
355        };
356        assert_eq!(err.to_string(), "invalid certificate: expired");
357    }
358
359    #[test]
360    fn display_network() {
361        let err = Error::Network {
362            message: "timeout".to_string(),
363        };
364        assert_eq!(err.to_string(), "network error: timeout");
365    }
366
367    #[test]
368    fn display_not_implemented() {
369        let err = Error::NotImplemented {
370            feature: "blockchain anchoring".to_string(),
371        };
372        assert_eq!(err.to_string(), "not implemented: blockchain anchoring");
373    }
374
375    #[test]
376    fn display_immutable_document() {
377        let err = Error::ImmutableDocument {
378            action: "modify content".to_string(),
379            state: crate::DocumentState::Frozen,
380        };
381        assert!(err.to_string().contains("cannot modify content"));
382        assert!(err.to_string().contains("Frozen"));
383    }
384
385    #[test]
386    fn display_extension_not_available() {
387        let err = Error::ExtensionNotAvailable {
388            extension: "forms".to_string(),
389        };
390        assert_eq!(err.to_string(), "extension not available: forms");
391    }
392
393    #[test]
394    fn display_validation_failed() {
395        let err = Error::ValidationFailed {
396            reason: "empty content".to_string(),
397        };
398        assert_eq!(err.to_string(), "content validation failed: empty content");
399    }
400
401    #[test]
402    fn display_signature_error() {
403        let err = Error::SignatureError {
404            reason: "invalid key".to_string(),
405        };
406        assert_eq!(err.to_string(), "signature error: invalid key");
407    }
408
409    #[test]
410    fn display_encryption_error() {
411        let err = Error::EncryptionError {
412            reason: "wrong password".to_string(),
413        };
414        assert_eq!(err.to_string(), "encryption error: wrong password");
415    }
416
417    #[test]
418    fn display_file_too_large() {
419        let err = Error::FileTooLarge {
420            path: "assets/huge.bin".to_string(),
421            size: 512 * 1024 * 1024,
422            limit: 256 * 1024 * 1024,
423        };
424        let msg = err.to_string();
425        assert!(msg.contains("file too large"));
426        assert!(msg.contains("assets/huge.bin"));
427    }
428
429    #[test]
430    fn display_invalid_archive_structure() {
431        let err = Error::InvalidArchiveStructure {
432            reason: "manifest not first".to_string(),
433        };
434        assert_eq!(
435            err.to_string(),
436            "invalid archive structure: manifest not first"
437        );
438    }
439}