Skip to main content

ai_model_vault/
error.rs

1//! Error types for AI Model Vault
2//!
3//! The top-level [`VaultError`] enum covers all failure modes.  Domain-specific
4//! sub-error types ([`CryptoError`], [`StorageError`], [`ConversionError`])
5//! carry richer context and convert into `VaultError` via `From`.
6
7use std::io;
8use thiserror::Error;
9
10/// Result type alias for AI Model Vault operations
11pub type Result<T> = std::result::Result<T, VaultError>;
12
13// ── Domain-specific error types ─────────────────────────────────────────────
14
15/// Errors originating from cryptographic operations.
16#[derive(Error, Debug)]
17pub enum CryptoError {
18    /// Key derivation failure (Argon2id / PBKDF2)
19    #[error("Key derivation failed: {0}")]
20    KeyDerivation(String),
21
22    /// Encryption failure
23    #[error("Encryption failed: {0}")]
24    Encryption(String),
25
26    /// Decryption failure (wrong key, corrupted ciphertext, …)
27    #[error("Decryption failed: {0}")]
28    Decryption(String),
29
30    /// Data integrity check mismatch (HMAC / SHA-256)
31    #[error("Integrity check failed: {0}")]
32    Integrity(String),
33
34    /// Generic / uncategorised crypto error
35    #[error("Cryptographic error: {0}")]
36    Other(String),
37}
38
39/// Errors originating from storage and I/O operations.
40#[derive(Error, Debug)]
41pub enum StorageError {
42    /// Underlying I/O error
43    #[error(transparent)]
44    Io(#[from] io::Error),
45
46    /// Serialization / deserialization failure
47    #[error("Serialization error: {0}")]
48    Serialization(String),
49
50    /// Compression / decompression failure
51    #[error("Compression error: {0}")]
52    Compression(String),
53
54    /// Database backend error (SQLite, Sled, …)
55    #[error("Database error: {0}")]
56    Database(String),
57
58    /// Generic storage error
59    #[error("Storage error: {0}")]
60    Other(String),
61}
62
63/// Errors originating from model format conversion.
64#[derive(Error, Debug)]
65pub enum ConversionError {
66    /// Requested conversion path is not supported
67    #[error("Unsupported conversion: {0}")]
68    Unsupported(String),
69
70    /// Validation of the converted output failed
71    #[error("Validation failed: {0}")]
72    Validation(String),
73
74    /// Generic conversion error
75    #[error("Conversion error: {0}")]
76    Other(String),
77}
78
79// ── From impls: domain → VaultError ─────────────────────────────────────────
80
81impl From<CryptoError> for VaultError {
82    fn from(err: CryptoError) -> Self {
83        match err {
84            CryptoError::Integrity(msg) => VaultError::IntegrityError(msg),
85            other => VaultError::CryptoError(other.to_string()),
86        }
87    }
88}
89
90impl From<StorageError> for VaultError {
91    fn from(err: StorageError) -> Self {
92        match err {
93            StorageError::Io(e) => VaultError::IoError(e),
94            StorageError::Serialization(msg) => VaultError::SerializationError(msg),
95            StorageError::Compression(msg) => VaultError::CompressionError(msg),
96            StorageError::Database(msg) => VaultError::StorageError(msg),
97            StorageError::Other(msg) => VaultError::StorageError(msg),
98        }
99    }
100}
101
102impl From<ConversionError> for VaultError {
103    fn from(err: ConversionError) -> Self {
104        match err {
105            ConversionError::Unsupported(msg) => VaultError::UnsupportedFormat(msg),
106            other => VaultError::ConversionError(other.to_string()),
107        }
108    }
109}
110
111// ── Top-level error ─────────────────────────────────────────────────────────
112
113/// AI Model Vault error types
114///
115/// Marked `#[non_exhaustive]` so that adding a category later is not a breaking
116/// change for downstream matches. Inside this crate the enum is still
117/// exhaustive, which is what forces [`VaultError::exit_code`] to assign every
118/// new variant a code rather than letting it fall through a wildcard — the
119/// omission that let the published exit-code tables drift from reality.
120#[derive(Error, Debug)]
121#[non_exhaustive]
122pub enum VaultError {
123    /// Cryptographic operation failed
124    #[error("Cryptographic error: {0}")]
125    CryptoError(String),
126
127    /// Invalid passphrase or authentication failure
128    #[error("Authentication failed: invalid passphrase or corrupted data")]
129    AuthenticationFailed,
130
131    /// Data integrity check failed
132    #[error("Integrity check failed: {0}")]
133    IntegrityError(String),
134
135    /// Version control error
136    #[error("Version control error: {0}")]
137    VersionError(String),
138
139    /// Model not found
140    #[error("Model not found: {0}")]
141    ModelNotFound(String),
142
143    /// Version not found
144    #[error("Version {0} not found for model {1}")]
145    VersionNotFound(u32, String),
146
147    /// A named resource that is not a model or a version does not exist —
148    /// a profile, a registered vault, a backup schedule, a document.
149    ///
150    /// Carries a full message rather than a bare name so the caller can say
151    /// which kind of thing was missing.
152    #[error("Not found: {0}")]
153    NotFound(String),
154
155    /// Format conversion error
156    #[error("Format conversion error: {0}")]
157    ConversionError(String),
158
159    /// Unsupported model format
160    #[error("Unsupported model format: {0}")]
161    UnsupportedFormat(String),
162
163    /// I/O error
164    #[error("I/O error: {0}")]
165    IoError(#[from] io::Error),
166
167    /// Configuration error
168    #[error("Configuration error: {0}")]
169    ConfigError(String),
170
171    /// Serialization error
172    #[error("Serialization error: {0}")]
173    SerializationError(String),
174
175    /// Compression error
176    #[error("Compression error: {0}")]
177    CompressionError(String),
178
179    /// Security policy violation
180    #[error("Security policy violation: {0}")]
181    SecurityViolation(String),
182
183    /// Compliance violation
184    #[error("Compliance violation: {0}")]
185    ComplianceViolation(String),
186
187    /// Audit log error
188    #[error("Audit log error: {0}")]
189    AuditError(String),
190
191    /// Invalid input
192    #[error("Invalid input: {0}")]
193    InvalidInput(String),
194
195    /// Storage/database error
196    #[error("Storage error: {0}")]
197    StorageError(String),
198}
199
200/// Process exit code for a successful run.
201pub const EXIT_SUCCESS: u8 = 0;
202/// An error that does not fall into any more specific category below.
203pub const EXIT_GENERAL: u8 = 1;
204/// Wrong passphrase, or ciphertext that failed its authentication tag.
205pub const EXIT_AUTH: u8 = 2;
206/// A named model, version, or resource does not exist.
207pub const EXIT_NOT_FOUND: u8 = 3;
208/// The OS refused access, or a security policy did.
209pub const EXIT_PERMISSION: u8 = 4;
210/// A checksum or signature did not match — corruption or tampering.
211pub const EXIT_INTEGRITY: u8 = 5;
212/// The caller supplied something this command cannot act on.
213pub const EXIT_INVALID_INPUT: u8 = 6;
214/// The configuration file is missing, malformed, or invalid.
215pub const EXIT_CONFIG: u8 = 7;
216/// A compliance policy check failed.
217pub const EXIT_COMPLIANCE: u8 = 8;
218
219impl VaultError {
220    /// The process exit code this error maps to.
221    ///
222    /// This is a **stability contract**: agents and CI pipelines branch on
223    /// these numbers, so the mapping from category to code must not change
224    /// once published. It is mirrored in `README.md`, `AGENTS.md`,
225    /// `docs/CLI.md`, `.well-known/agents.json`, and
226    /// `.well-known/ontology.jsonld` — change all of them together, and only
227    /// ever by assigning a *new* code to a category that had none.
228    ///
229    /// Codes 0–5 match the table those manifests already published. 6–8 are
230    /// additions for categories that previously fell through to `1`.
231    ///
232    /// `IoError` is split: the kernel refusing access is a distinct,
233    /// actionable outcome (fix the permissions) from a disk or network
234    /// failure, so `PermissionDenied` earns [`EXIT_PERMISSION`] while every
235    /// other I/O failure stays [`EXIT_GENERAL`].
236    #[must_use]
237    pub fn exit_code(&self) -> u8 {
238        match self {
239            VaultError::AuthenticationFailed => EXIT_AUTH,
240
241            VaultError::ModelNotFound(_)
242            | VaultError::VersionNotFound(_, _)
243            | VaultError::NotFound(_) => EXIT_NOT_FOUND,
244
245            VaultError::SecurityViolation(_) => EXIT_PERMISSION,
246            VaultError::IoError(err) if err.kind() == io::ErrorKind::PermissionDenied => {
247                EXIT_PERMISSION
248            }
249
250            VaultError::IntegrityError(_) => EXIT_INTEGRITY,
251
252            VaultError::InvalidInput(_) | VaultError::UnsupportedFormat(_) => EXIT_INVALID_INPUT,
253
254            VaultError::ConfigError(_) => EXIT_CONFIG,
255
256            VaultError::ComplianceViolation(_) => EXIT_COMPLIANCE,
257
258            // Everything else is a genuine failure with no more specific
259            // handling an agent could apply. Listed exhaustively rather than
260            // with a wildcard so that adding a variant forces a decision here.
261            VaultError::CryptoError(_)
262            | VaultError::VersionError(_)
263            | VaultError::ConversionError(_)
264            | VaultError::IoError(_)
265            | VaultError::SerializationError(_)
266            | VaultError::CompressionError(_)
267            | VaultError::AuditError(_)
268            | VaultError::StorageError(_) => EXIT_GENERAL,
269        }
270    }
271
272    /// Stable, constant name for the error variant.
273    ///
274    /// This exists so telemetry can report *which kind* of failure occurred
275    /// without touching the message. Every variant that carries a `String`
276    /// carries an interpolated one — model names, filesystem paths, and in
277    /// `ConfigError`'s case the path of a config file under the user's home
278    /// directory. `Display` output is therefore unsafe to report, and this
279    /// returns a fixed literal per variant instead: the set of possible values
280    /// is exactly the list below, and nothing else can ever appear.
281    #[must_use]
282    pub fn kind(&self) -> &'static str {
283        match self {
284            VaultError::CryptoError(_) => "crypto",
285            VaultError::AuthenticationFailed => "authentication_failed",
286            VaultError::IntegrityError(_) => "integrity",
287            VaultError::VersionError(_) => "version",
288            VaultError::ModelNotFound(_) => "model_not_found",
289            VaultError::VersionNotFound(_, _) => "version_not_found",
290            VaultError::NotFound(_) => "not_found",
291            VaultError::ConversionError(_) => "conversion",
292            VaultError::UnsupportedFormat(_) => "unsupported_format",
293            VaultError::IoError(_) => "io",
294            VaultError::ConfigError(_) => "config",
295            VaultError::SerializationError(_) => "serialization",
296            VaultError::CompressionError(_) => "compression",
297            VaultError::SecurityViolation(_) => "security_violation",
298            VaultError::InvalidInput(_) => "invalid_input",
299            VaultError::ComplianceViolation(_) => "compliance_violation",
300            VaultError::AuditError(_) => "audit",
301            VaultError::StorageError(_) => "storage",
302        }
303    }
304}
305
306impl From<serde_json::Error> for VaultError {
307    fn from(err: serde_json::Error) -> Self {
308        VaultError::SerializationError(err.to_string())
309    }
310}
311
312impl From<serde_yaml_ng::Error> for VaultError {
313    fn from(err: serde_yaml_ng::Error) -> Self {
314        VaultError::SerializationError(err.to_string())
315    }
316}
317
318impl From<zip::result::ZipError> for VaultError {
319    fn from(err: zip::result::ZipError) -> Self {
320        VaultError::IoError(io::Error::other(err.to_string()))
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_zip_error_conversion() {
330        // Covers lines 94, 95 — From<ZipError>
331        let zip_err = zip::result::ZipError::FileNotFound;
332        let vault_err: VaultError = zip_err.into();
333        match vault_err {
334            VaultError::IoError(_) => {} // expected
335            _ => panic!("Expected IoError"),
336        }
337    }
338
339    #[test]
340    fn test_serde_yml_error_conversion() {
341        let yaml_err = serde_yaml_ng::from_str::<serde_yaml_ng::Value>("\t").unwrap_err();
342        let vault_err: VaultError = yaml_err.into();
343        match vault_err {
344            VaultError::SerializationError(_) => {}
345            _ => panic!("Expected SerializationError"),
346        }
347    }
348
349    #[test]
350    fn test_serde_json_error_conversion() {
351        // Covers L81-83 — From<serde_json::Error>
352        let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
353        let vault_err: VaultError = json_err.into();
354        match vault_err {
355            VaultError::SerializationError(msg) => {
356                assert!(!msg.is_empty());
357            }
358            _ => panic!("Expected SerializationError"),
359        }
360    }
361
362    #[test]
363    fn test_error_display_messages() {
364        // Covers L7 (type alias used implicitly) + various Display branches
365        let errors: Vec<VaultError> = vec![
366            VaultError::CryptoError("crypto fail".into()),
367            VaultError::AuthenticationFailed,
368            VaultError::IntegrityError("integrity fail".into()),
369            VaultError::VersionError("version fail".into()),
370            VaultError::ModelNotFound("model1".into()),
371            VaultError::VersionNotFound(3, "model1".into()),
372            VaultError::ConversionError("conv fail".into()),
373            VaultError::UnsupportedFormat("xyz".into()),
374            VaultError::ConfigError("config fail".into()),
375            VaultError::SerializationError("serde fail".into()),
376            VaultError::CompressionError("comp fail".into()),
377            VaultError::SecurityViolation("sec fail".into()),
378            VaultError::ComplianceViolation("cc fail".into()),
379            VaultError::AuditError("audit fail".into()),
380            VaultError::InvalidInput("bad input".into()),
381            VaultError::StorageError("store fail".into()),
382        ];
383
384        let expected_substrings = [
385            "crypto fail",
386            "invalid passphrase",
387            "integrity fail",
388            "version fail",
389            "model1",
390            "Version 3 not found for model model1",
391            "conv fail",
392            "xyz",
393            "config fail",
394            "serde fail",
395            "comp fail",
396            "sec fail",
397            "cc fail",
398            "audit fail",
399            "bad input",
400            "store fail",
401        ];
402
403        for (err, expected) in errors.iter().zip(expected_substrings.iter()) {
404            let msg = format!("{}", err);
405            assert!(
406                msg.contains(expected),
407                "Error '{}' should contain '{}'",
408                msg,
409                expected
410            );
411        }
412    }
413
414    #[test]
415    fn test_result_type_alias() {
416        // Covers L7 — the Result<T> type alias usage
417        let ok_result: super::Result<i32> = Ok(42);
418        assert_eq!(ok_result.ok(), Some(42));
419
420        let err_result: super::Result<i32> = Err(VaultError::CryptoError("test".into()));
421        assert!(err_result.is_err());
422    }
423
424    #[test]
425    fn test_io_error_conversion() {
426        // Covers L87 — From<io::Error>
427        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
428        let vault_err: VaultError = io_err.into();
429        match vault_err {
430            VaultError::IoError(e) => {
431                assert!(e.to_string().contains("file not found"));
432            }
433            _ => panic!("Expected IoError"),
434        }
435    }
436
437    #[test]
438    fn test_error_debug_format() {
439        // L93 — ensure Debug trait works (thiserror derives it)
440        let err = VaultError::StorageError("db fail".into());
441        let debug = format!("{:?}", err);
442        assert!(debug.contains("StorageError"));
443    }
444
445    #[test]
446    fn test_domain_error_display() {
447        // CryptoError Display
448        assert!(CryptoError::KeyDerivation("kdf".into())
449            .to_string()
450            .contains("kdf"));
451        assert!(CryptoError::Encryption("enc".into())
452            .to_string()
453            .contains("enc"));
454        assert!(CryptoError::Decryption("dec".into())
455            .to_string()
456            .contains("dec"));
457        assert!(CryptoError::Integrity("int".into())
458            .to_string()
459            .contains("int"));
460        assert!(CryptoError::Other("oth".into()).to_string().contains("oth"));
461
462        // StorageError Display
463        let io = StorageError::Io(std::io::Error::other("io"));
464        assert!(io.to_string().contains("io"));
465        assert!(StorageError::Serialization("ser".into())
466            .to_string()
467            .contains("ser"));
468        assert!(StorageError::Compression("comp".into())
469            .to_string()
470            .contains("comp"));
471        assert!(StorageError::Database("db".into())
472            .to_string()
473            .contains("db"));
474        assert!(StorageError::Other("oth".into())
475            .to_string()
476            .contains("oth"));
477
478        // ConversionError Display
479        assert!(ConversionError::Unsupported("uns".into())
480            .to_string()
481            .contains("uns"));
482        assert!(ConversionError::Validation("val".into())
483            .to_string()
484            .contains("val"));
485        assert!(ConversionError::Other("oth".into())
486            .to_string()
487            .contains("oth"));
488    }
489
490    // ── Domain-specific error conversion tests ──────────────────────────
491
492    #[test]
493    fn test_crypto_error_into_vault_error() {
494        let cases: Vec<(CryptoError, &str)> = vec![
495            (CryptoError::KeyDerivation("bad salt".into()), "bad salt"),
496            (CryptoError::Encryption("aes fail".into()), "aes fail"),
497            (CryptoError::Decryption("wrong key".into()), "wrong key"),
498            (CryptoError::Other("misc".into()), "misc"),
499        ];
500        for (crypto_err, expected) in cases {
501            let vault_err: VaultError = crypto_err.into();
502            match &vault_err {
503                VaultError::CryptoError(msg) => assert!(msg.contains(expected)),
504                _ => panic!("Expected CryptoError, got {:?}", vault_err),
505            }
506        }
507
508        // Integrity maps to IntegrityError
509        let integrity = CryptoError::Integrity("hash mismatch".into());
510        let vault_err: VaultError = integrity.into();
511        assert!(matches!(vault_err, VaultError::IntegrityError(_)));
512    }
513
514    #[test]
515    fn test_storage_error_into_vault_error() {
516        let io_err = StorageError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
517        assert!(matches!(VaultError::from(io_err), VaultError::IoError(_)));
518
519        let ser = StorageError::Serialization("bad json".into());
520        assert!(matches!(
521            VaultError::from(ser),
522            VaultError::SerializationError(_)
523        ));
524
525        let comp = StorageError::Compression("zlib".into());
526        assert!(matches!(
527            VaultError::from(comp),
528            VaultError::CompressionError(_)
529        ));
530
531        let db = StorageError::Database("sqlite locked".into());
532        assert!(matches!(VaultError::from(db), VaultError::StorageError(_)));
533
534        let other = StorageError::Other("unknown".into());
535        assert!(matches!(
536            VaultError::from(other),
537            VaultError::StorageError(_)
538        ));
539    }
540
541    #[test]
542    fn test_conversion_error_into_vault_error() {
543        let unsup = ConversionError::Unsupported("onnx→gguf".into());
544        assert!(matches!(
545            VaultError::from(unsup),
546            VaultError::UnsupportedFormat(_)
547        ));
548
549        let val = ConversionError::Validation("shape mismatch".into());
550        assert!(matches!(
551            VaultError::from(val),
552            VaultError::ConversionError(_)
553        ));
554
555        let other = ConversionError::Other("misc".into());
556        assert!(matches!(
557            VaultError::from(other),
558            VaultError::ConversionError(_)
559        ));
560    }
561
562    // ── Exit codes ──────────────────────────────────────────────────────────
563
564    /// Pins the published contract. If this test needs editing to pass, the
565    /// change is breaking for every agent and CI pipeline that branches on
566    /// these numbers — update the manifests in the same commit, or don't.
567    #[test]
568    fn test_exit_codes_match_the_published_contract() {
569        let cases: &[(VaultError, u8)] = &[
570            (VaultError::AuthenticationFailed, 2),
571            (VaultError::ModelNotFound("m".into()), 3),
572            (VaultError::VersionNotFound(2, "m".into()), 3),
573            (VaultError::NotFound("profile 'p'".into()), 3),
574            (VaultError::SecurityViolation("policy".into()), 4),
575            (VaultError::IntegrityError("checksum".into()), 5),
576            (VaultError::InvalidInput("bad".into()), 6),
577            (VaultError::UnsupportedFormat("xyz".into()), 6),
578            (VaultError::ConfigError("malformed".into()), 7),
579            (VaultError::ComplianceViolation("weak".into()), 8),
580            (VaultError::CryptoError("aead".into()), 1),
581            (VaultError::VersionError("index".into()), 1),
582            (VaultError::ConversionError("path".into()), 1),
583            (VaultError::SerializationError("json".into()), 1),
584            (VaultError::CompressionError("zstd".into()), 1),
585            (VaultError::AuditError("log".into()), 1),
586            (VaultError::StorageError("db".into()), 1),
587        ];
588
589        for (err, expected) in cases {
590            assert_eq!(
591                err.exit_code(),
592                *expected,
593                "{err:?} must exit {expected} — this mapping is a published contract"
594            );
595        }
596    }
597
598    #[test]
599    fn test_permission_denied_io_error_is_distinguished_from_other_io_failures() {
600        // The kernel refusing access is actionable (fix the permissions);
601        // a disk or network failure is not, so they must not share a code.
602        let denied = VaultError::IoError(io::Error::from(io::ErrorKind::PermissionDenied));
603        assert_eq!(denied.exit_code(), EXIT_PERMISSION);
604
605        let not_found = VaultError::IoError(io::Error::from(io::ErrorKind::NotFound));
606        assert_eq!(not_found.exit_code(), EXIT_GENERAL);
607
608        let broken = VaultError::IoError(io::Error::other("disk on fire"));
609        assert_eq!(broken.exit_code(), EXIT_GENERAL);
610    }
611
612    #[test]
613    fn test_success_is_the_only_zero_code() {
614        // A failure that exits 0 is the bug class this mapping exists to
615        // prevent, so assert no error can ever produce one.
616        let all = [
617            VaultError::AuthenticationFailed,
618            VaultError::ModelNotFound("m".into()),
619            VaultError::VersionNotFound(1, "m".into()),
620            VaultError::NotFound("thing".into()),
621            VaultError::SecurityViolation("p".into()),
622            VaultError::IntegrityError("c".into()),
623            VaultError::InvalidInput("i".into()),
624            VaultError::UnsupportedFormat("f".into()),
625            VaultError::ConfigError("c".into()),
626            VaultError::ComplianceViolation("v".into()),
627            VaultError::CryptoError("c".into()),
628            VaultError::VersionError("v".into()),
629            VaultError::ConversionError("c".into()),
630            VaultError::IoError(io::Error::other("io")),
631            VaultError::SerializationError("s".into()),
632            VaultError::CompressionError("c".into()),
633            VaultError::AuditError("a".into()),
634            VaultError::StorageError("s".into()),
635        ];
636        for err in &all {
637            assert_ne!(err.exit_code(), EXIT_SUCCESS, "{err:?} must not exit 0");
638        }
639        assert_eq!(EXIT_SUCCESS, 0);
640    }
641}