Skip to main content

chisel/
error.rs

1// error.rs — Error types for Chisel. Part of the foundation layer (layer 1:
2// pure types, no I/O) alongside page.rs and superblock.rs.
3//
4// The split between Operational and Fatal is a deliberate contract for callers:
5// Operational variants mean the database on disk is still consistent and the
6// handle remains usable — the caller made a mistake (bad handle, nested txn,
7// etc.) and can recover by issuing a different request. Fatal variants mean
8// integrity invariants have been violated; the caller should stop using the
9// Chisel handle because further operations may read torn or corrupt state.
10// Anything promoted from Operational to Fatal (or vice versa) is a breaking
11// change for users doing error-class matching.
12
13use crate::superblock::SlotDefect;
14use std::fmt;
15use std::io;
16
17/// I36 (ISSUES.md, 2026-05-22): `#[non_exhaustive]` so adding a new
18/// error variant (a future operational signal, a new fatal-corruption
19/// flavor) is not a breaking change. External `match` arms over
20/// `ChiselError` need a `_ => …` catchall; the conventional path is
21/// `if e.is_fatal() { … } else { … }` which never enumerates
22/// variants and is therefore unaffected. The `Display` impl below is
23/// exhaustive — adding a variant still requires updating its prose.
24#[non_exhaustive]
25#[derive(Debug)]
26pub enum ChiselError {
27    // Operational — recoverable; database file is intact.
28    InvalidHandle(u64),
29    NoActiveTransaction,
30    TransactionAlreadyActive,
31    SavepointNotFound(String),
32    DuplicateSavepoint(String),
33    ReadOnlyMode,
34    FileNotFound,
35    // Caller passed a named-root name that is empty, longer than the
36    // fixed 24-byte slot, contains a null byte, or is not valid UTF-8.
37    // See ISSUES.md F2.
38    InvalidRootName,
39    // All slots in the superblock's fixed-size named-root table are in
40    // use and the caller tried to set a new (previously unused) name.
41    // See ISSUES.md F2.
42    RootNameTableFull,
43    // Options.superblock_count is out of the supported range (ISSUES.md
44    // R4). Only raised at open time when the caller passed a value
45    // < MIN_SUPERBLOCKS (2) or > MAX_SUPERBLOCKS (16). Operational —
46    // the caller fixes their Options and tries again.
47    InvalidSuperblockCount {
48        value: u32,
49    },
50    // The page cache has reached its strict cap (`max_pages`) with
51    // every cached entry dirty and the spillway disabled
52    // (`spillway_max_bytes == 0`), so there is no clean page available
53    // for eviction and no spillway to absorb the overflow. Operational:
54    // the DB on disk is still fine. Recovery is to commit (which flushes
55    // dirty pages and clears the backlog) or roll back (which discards
56    // the in-flight work entirely). The pre-spillway 8x
57    // HARD_CEILING_MULTIPLIER design is gone — see spec
58    // 2026-05-03-chisel-spillway-design.md.
59    CacheFull {
60        limit: usize,
61    },
62    // The spillway file has reached its `spillway_max_bytes` cap with
63    // every cached entry dirty, so there is neither room in the cache
64    // nor room in the spillway. Operational: the DB on disk is still
65    // intact. Recovery is to commit (which drains the spillway and
66    // resets it) or roll back. Spec 2026-05-03-chisel-spillway-design.md.
67    SpillwayFull {
68        limit_bytes: u64,
69    },
70    // Raised when a configuration mutator (e.g. set_cache_max_bytes,
71    // set_spillway_max_bytes, set_drain_insertion) is called while a
72    // transaction is in flight. Operational: caller commits or rolls
73    // back, then retries. The mutators only operate on between-
74    // transactions state; mid-transaction shrink would either reject
75    // or silently spill, neither of which is a clean story.
76    TransactionInProgress,
77    // delete_tagged was given a tag that does not match the chunk's actual tag.
78    // Operational: the caller passed the wrong tag; the chunk and the membership
79    // index are untouched, so the transaction may continue.
80    TagMismatch {
81        handle: u64,
82        expected: u32,
83        actual: u32,
84    },
85
86    // Fatal — database integrity is in question. Close and re-open
87    // before attempting further work. The reopen will re-run superblock
88    // selection, which CAN recover from `CorruptSuperblock` on the
89    // currently-active slot (the previous slot is still valid) but
90    // cannot recover from `ChecksumMismatch` on a data/handle-table
91    // page — those indicate the last-committed snapshot itself is
92    // damaged. Under the I1 poison model, any fatal error poisons the
93    // TransactionManager, so `close-and-reopen` is the only legitimate
94    // response regardless of which fatal variant fired.
95    IoError(io::Error),
96    ChecksumMismatch {
97        page_id: u64,
98    },
99    // Raised when `Superblock::select` finds no usable slot. "Usable"
100    // means `deserialize` returned Some, which in turn requires a valid
101    // XXH3 checksum, correct magic, AND a `superblock_count` inside
102    // `MIN_SUPERBLOCKS..=MAX_SUPERBLOCKS`. A slot that passes checksum
103    // but has an out-of-range `superblock_count` counts as corrupt for
104    // this purpose. `defects` contains one `SlotDefect` per candidate slot
105    // that failed validation, so operators can pinpoint the exact cause
106    // without reaching for the raw slot bytes (I106).
107    CorruptSuperblock {
108        defects: Vec<SlotDefect>,
109    },
110    FileSizeMismatch {
111        expected: u64,
112        actual: u64,
113    },
114    LockFailed,
115    // Raised when a superblock's checksum is valid but its format_version
116    // field does not match the binary's supported version. Distinct from
117    // CorruptSuperblock (which means no readable superblock at all) so
118    // users can tell "unopenable because damaged" from "unopenable because
119    // written by a newer/incompatible Chisel build".
120    UnsupportedFormatVersion {
121        found: u32,
122        expected: u32,
123    },
124    // Raised by every operation on a TransactionManager that has previously
125    // seen a fatal error (commit I/O failure, checksum mismatch on read,
126    // etc.). See ISSUES.md I1: modeled after std::sync::Mutex poisoning.
127    // The only legal recovery is to drop the Chisel handle and reopen; the
128    // shadow-paging crash-recovery path then returns the database to the
129    // last durable state. Named Poisoned (not "Closed"/"Dead") to match
130    // Rust conventions and make the recovery idiom obvious.
131    Poisoned,
132    // Raised when a page's structural contents violate the format's
133    // invariants (e.g., an overflow chain with a cycle, a next_page
134    // pointer that loops, a chain longer than its advertised
135    // total_length). Distinct from `ChecksumMismatch` because the
136    // checksum may be valid — the bytes are "structurally wrong"
137    // rather than "bit-flipped". See ISSUES.md I14.
138    CorruptPage {
139        page_id: u64,
140    },
141    // Raised when a caller asks `PageIo::read_page` for a page id that
142    // is beyond the current physical file length. Pre-I16, this path
143    // surfaced as a generic IoError(UnexpectedEof) which obscured the
144    // cause during debugging. The typed variant makes it obvious that
145    // the request is an upstream bug (stale handle-table entry,
146    // arithmetic error in a cache consumer) rather than a real I/O
147    // failure. See ISSUES.md I16.
148    InvalidPageId {
149        page_id: u64,
150    },
151    // Raised at open time when the superblock's `page_size` field does not
152    // match the `PAGE_SIZE` compiled into this binary. A mismatch means
153    // the file was written with a different page geometry and every page
154    // boundary calculation would be wrong; the open is refused before any
155    // data is read. Distinct from `UnsupportedFormatVersion` (which guards
156    // the format-version field) so operators can distinguish a compile-
157    // configuration mismatch from an actual version incompatibility.
158    UnsupportedPageSize {
159        stored: u32,
160        compiled: u32,
161    },
162
163    // Operational — the caller supplied the wrong key material or none, or
164    // asked an unencrypted-only build to open an encrypted DB. The on-disk
165    // file is untouched; the caller fixes their `Options` and retries.
166    //
167    // Raised at open time when the superblock declares encryption but
168    // `Options::encryption_key` was None.
169    NoEncryptionKey,
170    // Raised at open time when a key was supplied but no key-slot's wrapped
171    // DEK could be unwrapped under the derived KEK (wrong passphrase / raw
172    // key). Operational: the DB is intact; supply the right key and reopen.
173    InvalidEncryptionKey,
174    // Raised when a key was supplied to open a *plaintext* DB, or an
175    // encrypted DB is opened by a build that the on-disk crypto-header
176    // algorithm id is unknown to. Operational: the request is a mismatch,
177    // not corruption.
178    EncryptionNotSupported,
179    // Operational — key-management (add/rotate/remove) ran out of room: all
180    // KEY_SLOT_COUNT (8) wrapped-DEK slots are occupied, so there is nowhere
181    // to stage a new credential. The DB is intact; remove an unused key first.
182    NoFreeKeySlot,
183    // Operational — refusing to remove the last active key slot, which would
184    // leave the database with zero usable credentials (permanently unopenable).
185    LastKeySlot,
186    // Fatal — an AEAD authentication failure while decrypting a page that
187    // was already located and read off disk. The ciphertext, tag, nonce, or
188    // session DEK disagree, so the last-committed snapshot cannot be trusted;
189    // poisons the manager (I1) exactly like ChecksumMismatch. Distinct from
190    // InvalidEncryptionKey (a *key-slot* unwrap failure at open, before any
191    // page is served) — this fires mid-session on a real data/handle page.
192    DecryptionFailed {
193        page_id: u64,
194    },
195}
196
197impl ChiselError {
198    /// True for error variants that indicate a violation of storage
199    /// integrity or an unrecoverable I/O condition. Operational errors
200    /// (caller mistakes like `InvalidHandle`) return false. Used by
201    /// `TransactionManager` to decide whether an error should poison the
202    /// manager — see ISSUES.md I1.
203    ///
204    /// `Poisoned` itself is NOT fatal by this definition: it just means
205    /// the manager is already dead, so seeing it again should not trigger
206    /// a redundant state change.
207    ///
208    /// INVARIANT: this match must list exactly the variants in the "Fatal"
209    /// block of the enum above. Adding a fatal variant without adding it here
210    /// silently leaves it classified operational, so the manager would keep
211    /// serving requests over questionable on-disk state — a durability hole,
212    /// not a compile error (the `#[non_exhaustive]` enum has no exhaustiveness
213    /// check to catch the omission).
214    pub fn is_fatal(&self) -> bool {
215        matches!(
216            self,
217            ChiselError::IoError(_)
218                | ChiselError::ChecksumMismatch { .. }
219                | ChiselError::CorruptSuperblock { .. }
220                | ChiselError::FileSizeMismatch { .. }
221                | ChiselError::LockFailed
222                | ChiselError::UnsupportedFormatVersion { .. }
223                | ChiselError::CorruptPage { .. }
224                | ChiselError::InvalidPageId { .. }
225                | ChiselError::UnsupportedPageSize { .. }
226                | ChiselError::DecryptionFailed { .. }
227        )
228    }
229}
230
231impl fmt::Display for ChiselError {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        match self {
234            ChiselError::InvalidHandle(h) => write!(f, "invalid handle: {h}"),
235            ChiselError::TagMismatch { handle, expected, actual } => {
236                write!(f, "handle {handle} has tag {actual}, not the expected {expected}")
237            }
238            ChiselError::NoActiveTransaction => write!(f, "no active transaction"),
239            ChiselError::TransactionAlreadyActive => write!(f, "transaction already active"),
240            ChiselError::SavepointNotFound(name) => write!(f, "savepoint not found: {name}"),
241            ChiselError::DuplicateSavepoint(name) => write!(f, "duplicate savepoint: {name}"),
242            ChiselError::ReadOnlyMode => write!(f, "database is read-only"),
243            ChiselError::FileNotFound => write!(f, "database file not found"),
244            ChiselError::InvalidRootName => write!(
245                f,
246                "invalid named-root name (empty, too long, non-UTF-8, or contains NUL)"
247            ),
248            ChiselError::RootNameTableFull => {
249                write!(f, "named-root table is full (all slots are in use)")
250            }
251            ChiselError::InvalidSuperblockCount { value } => {
252                write!(f, "invalid superblock_count {value}; must be in 2..=16")
253            }
254            ChiselError::CacheFull { limit } => write!(
255                f,
256                "page cache full: {limit} dirty pages held; commit or roll back to free cache"
257            ),
258            ChiselError::SpillwayFull { limit_bytes } => {
259                // limit_bytes == 0 is the "spillway disabled" sentinel, not a
260                // zero-capacity overflow: the cache filled and there is no
261                // spillway configured to absorb it. Reworded so operators do
262                // not chase a phantom spill area that was never enabled.
263                if *limit_bytes == 0 {
264                    write!(
265                        f,
266                        "spillway is disabled (spillway_max_bytes=0); commit or roll back to free cache"
267                    )
268                } else {
269                    write!(
270                        f,
271                        "spillway full: {limit_bytes}-byte limit reached; commit or roll back to free cache and spillway"
272                    )
273                }
274            },
275            ChiselError::TransactionInProgress => write!(
276                f,
277                "configuration changes are only allowed between transactions; commit or roll back first"
278            ),
279            ChiselError::IoError(e) => write!(f, "I/O error: {e}"),
280            ChiselError::ChecksumMismatch { page_id } => {
281                write!(f, "checksum mismatch on page {page_id}")
282            }
283            ChiselError::CorruptSuperblock { defects } => {
284                write!(f, "no valid superblock found")?;
285                for (i, sd) in defects.iter().enumerate() {
286                    let sep = if i == 0 { ":" } else { ";" };
287                    write!(f, "{sep} slot {}: {}", sd.slot, sd.defect)?;
288                }
289                Ok(())
290            }
291            ChiselError::FileSizeMismatch { expected, actual } => {
292                write!(
293                    f,
294                    "file size mismatch: expected {expected} bytes, got {actual}"
295                )
296            }
297            ChiselError::LockFailed => write!(f, "failed to acquire exclusive file lock"),
298            ChiselError::UnsupportedFormatVersion { found, expected } => write!(
299                f,
300                "unsupported on-disk format version: found {found}, this build supports {expected}"
301            ),
302            ChiselError::Poisoned => write!(
303                f,
304                "database handle is poisoned after a previous fatal error; drop and reopen"
305            ),
306            ChiselError::CorruptPage { page_id } => {
307                write!(f, "corrupt page structure at page {page_id}")
308            }
309            ChiselError::InvalidPageId { page_id } => {
310                write!(f, "invalid page id {page_id} (out of range for file)")
311            }
312            ChiselError::UnsupportedPageSize { stored, compiled } => write!(
313                f,
314                "page size mismatch: file was written with {stored}-byte pages, this build uses {compiled}-byte pages"
315            ),
316            ChiselError::NoEncryptionKey => write!(
317                f,
318                "database is encrypted but no encryption_key was supplied"
319            ),
320            ChiselError::InvalidEncryptionKey => write!(
321                f,
322                "encryption key does not match any key slot (wrong passphrase or raw key)"
323            ),
324            ChiselError::EncryptionNotSupported => write!(
325                f,
326                "encryption not supported for this open (key supplied for a plaintext database, or unknown crypto algorithm)"
327            ),
328            ChiselError::NoFreeKeySlot => write!(
329                f,
330                "no free key slot: all 8 key slots are occupied (remove an unused key first)"
331            ),
332            ChiselError::LastKeySlot => write!(
333                f,
334                "refusing to remove the last active key slot (the database would become permanently unopenable)"
335            ),
336            ChiselError::DecryptionFailed { page_id } => {
337                write!(f, "decryption/authentication failed for page {page_id}")
338            }
339        }
340    }
341}
342
343impl std::error::Error for ChiselError {
344    /// I41 (ISSUES.md, 2026-05-22): expose the wrapped `io::Error` from the
345    /// `IoError` variant so error-chain walkers (`anyhow::Error::root_cause`,
346    /// structured-logging adapters, `eyre` reports, `std::error::Error::chain`)
347    /// can see the underlying cause. Returns `None` for every other variant
348    /// because no other variant carries an inner error today. If a future
349    /// variant grows an inner cause, extend this match.
350    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
351        match self {
352            ChiselError::IoError(e) => Some(e),
353            _ => None,
354        }
355    }
356}
357
358// Conversion so `?` works on std::io calls in page_io.rs and friends.
359//
360// I105 (ISSUES.md, 2026-06-21): inspect ErrorKind instead of blanket-classifying
361// every io::Error as a *fatal* IoError. `NotFound` is demoted to the operational
362// `FileNotFound`: it can only arise from resolving a missing PATH (open() of a
363// non-existent file), never from a read/write on the already-open, flock'd fd, so
364// it signals a caller mistake (bad path), not on-disk corruption — the DB is
365// untouched and the caller can retry. (Chisel::open already pre-checks the
366// missing-file case in lib.rs and returns FileNotFound directly; this just makes
367// any stray NotFound that reaches a `?` classify consistently rather than
368// poisoning.) Every other kind stays fatal: under the poison model over-poisoning
369// is the safe direction (reopen recovers), and demoting a kind that might signal
370// real corruption would be the same fail-open hazard as I104's is_fatal default.
371//
372// One other site CAN surface a NotFound after the DB is open: the spillway is a
373// second file opened lazily mid-transaction (Spillway::open_file). A NotFound
374// there is a degraded in-flight cache state, NOT a recoverable bad-path mistake,
375// so that call site maps its open error to the fatal IoError directly and never
376// routes through this demotion (review 2026-06-22). The invariant this blanket
377// NotFound→operational rule relies on — "NotFound only means the initial open
378// path" — therefore still holds for every `?` that reaches here.
379impl From<io::Error> for ChiselError {
380    fn from(e: io::Error) -> Self {
381        match e.kind() {
382            io::ErrorKind::NotFound => ChiselError::FileNotFound,
383            _ => ChiselError::IoError(e),
384        }
385    }
386}
387
388impl From<crate::crypto::CryptoError> for ChiselError {
389    // A CryptoError reaching the engine through `?` in the create/open/key-management
390    // paths is always a key-or-KDF problem on intact on-disk data, so it maps to the
391    // operational InvalidEncryptionKey. The page-read path (Phase 3) does NOT use this
392    // blanket conversion — it maps decrypt failures explicitly to the fatal
393    // ChiselError::DecryptionFailed { page_id } via `.map_err(...)`. The operational
394    // cases that are NOT CryptoError-derived (no key supplied for an encrypted DB; a
395    // key supplied for a plaintext DB) are returned explicitly as NoEncryptionKey /
396    // EncryptionNotSupported at their decision sites.
397    fn from(_: crate::crypto::CryptoError) -> Self {
398        ChiselError::InvalidEncryptionKey
399    }
400}
401
402/// Crate-wide Result alias. All fallible Chisel APIs return this.
403pub type Result<T> = std::result::Result<T, ChiselError>;
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use std::error::Error;
409
410    // I41 regression: ChiselError::IoError(inner) must expose `inner` via
411    // source() so error-chain walkers can recover the io::Error and inspect
412    // its kind/raw_os_error. Pre-I41 the trait impl was empty (`{}`) so
413    // source() returned None and the inner cause was unreachable from
414    // callers that didn't pattern-match on the variant.
415    #[test]
416    fn ioerror_source_returns_inner_io_error() {
417        let inner = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
418        let e: ChiselError = inner.into();
419        let src = e
420            .source()
421            .expect("IoError must expose its inner error via source()");
422        let downcast: &io::Error = src
423            .downcast_ref()
424            .expect("source() should downcast back to io::Error");
425        assert_eq!(downcast.kind(), io::ErrorKind::PermissionDenied);
426    }
427
428    // I41 corollary: every variant that does NOT wrap an inner error must
429    // return None from source(). Sampling a few representative ones is
430    // enough; a future variant that grows an inner cause will need the
431    // source() match arm extended.
432    #[test]
433    fn non_io_variants_have_no_source() {
434        let cases: [ChiselError; 5] = [
435            ChiselError::InvalidHandle(0),
436            ChiselError::NoActiveTransaction,
437            ChiselError::ChecksumMismatch { page_id: 0 },
438            ChiselError::CorruptSuperblock { defects: vec![] },
439            ChiselError::Poisoned,
440        ];
441        for e in &cases {
442            assert!(
443                e.source().is_none(),
444                "expected source() == None for {e:?}, got Some(...)"
445            );
446        }
447    }
448
449    // I70 (ISSUES.md, 2026-05-22): unit test for the typed
450    // CorruptPage variant. Group F's I45 + I49 convert two
451    // previously-panicking sites to return CorruptPage errors; the
452    // fault-injection paths needed to trigger those sites from a
453    // black-box test would be invasive (would need test-only
454    // force_lru_desync / force_handle_table_desync helpers). This
455    // test covers the variant's value-level contract instead:
456    // page_id round-trips through construction, the variant is
457    // classified as fatal (so it poisons the manager per I1), and
458    // Display formats the page id. Integration coverage of the
459    // CorruptPage path against on-disk corruption already exists in
460    // src/recovery_tests.rs (the I14 test).
461    #[test]
462    fn corrupt_page_variant_contract() {
463        let e = ChiselError::CorruptPage { page_id: 42 };
464        // is_fatal() returns true → TransactionManager will poison.
465        assert!(
466            e.is_fatal(),
467            "CorruptPage must be fatal so the manager poisons"
468        );
469        // source() returns None — CorruptPage does not wrap an inner cause.
470        assert!(e.source().is_none(), "CorruptPage has no inner source");
471        // Display includes the page id so log inspectors can identify
472        // the bad page without round-tripping through Debug.
473        let msg = format!("{e}");
474        assert!(
475            msg.contains("42"),
476            "Display message {msg:?} should mention page id 42"
477        );
478    }
479
480    // I106: the CorruptSuperblock Display is the operator-facing diagnostic, so
481    // pin its exact format — the empty/single/multiple separator logic (":" on
482    // the first slot, ";" between, no trailing punctuation) is easy to break.
483    #[test]
484    fn corrupt_superblock_display_format() {
485        use crate::superblock::{SlotDefect, SuperblockDefect};
486        // No defects (the test-constructor case): bare message, no punctuation.
487        let empty = ChiselError::CorruptSuperblock { defects: vec![] };
488        assert_eq!(format!("{empty}"), "no valid superblock found");
489        // One defect: colon-introduced slot detail.
490        let one = ChiselError::CorruptSuperblock {
491            defects: vec![SlotDefect {
492                slot: 0,
493                defect: SuperblockDefect::BadChecksum,
494            }],
495        };
496        assert_eq!(
497            format!("{one}"),
498            "no valid superblock found: slot 0: bad checksum"
499        );
500        // Multiple: colon first, semicolons between, no trailing separator; also
501        // exercises BadCount's value rendering.
502        let many = ChiselError::CorruptSuperblock {
503            defects: vec![
504                SlotDefect {
505                    slot: 0,
506                    defect: SuperblockDefect::BadChecksum,
507                },
508                SlotDefect {
509                    slot: 1,
510                    defect: SuperblockDefect::BadMagic,
511                },
512                SlotDefect {
513                    slot: 2,
514                    defect: SuperblockDefect::BadCount(99),
515                },
516            ],
517        };
518        assert_eq!(
519            format!("{many}"),
520            "no valid superblock found: slot 0: bad checksum; slot 1: bad magic; slot 2: bad superblock_count 99"
521        );
522    }
523
524    // I104 (ISSUES.md, 2026-06-21): exhaustiveness guard for is_fatal(). The
525    // `documented_is_fatal` helper classifies every variant via a match with NO
526    // `_` arm; because this is the DEFINING crate, #[non_exhaustive] does not
527    // suppress the exhaustiveness check here, so adding a ChiselError variant
528    // fails to COMPILE until it is placed in the Fatal or Operational block —
529    // the compile-time signal is_fatal()'s `matches!` cannot provide. The test
530    // then asserts is_fatal() agrees with the documented classification for a
531    // constructed instance of every variant, catching a misclassification in
532    // EITHER direction (a fatal variant left out of is_fatal(), or an operational
533    // one wrongly listed). The two encodings are independent on purpose:
534    // documented_is_fatal does NOT call is_fatal().
535    #[test]
536    fn is_fatal_matches_documented_classification_for_every_variant() {
537        fn documented_is_fatal(e: &ChiselError) -> bool {
538            match e {
539                // Operational — database file is intact; caller can recover.
540                ChiselError::InvalidHandle(_)
541                | ChiselError::NoActiveTransaction
542                | ChiselError::TransactionAlreadyActive
543                | ChiselError::SavepointNotFound(_)
544                | ChiselError::DuplicateSavepoint(_)
545                | ChiselError::ReadOnlyMode
546                | ChiselError::FileNotFound
547                | ChiselError::InvalidRootName
548                | ChiselError::RootNameTableFull
549                | ChiselError::InvalidSuperblockCount { .. }
550                | ChiselError::CacheFull { .. }
551                | ChiselError::SpillwayFull { .. }
552                | ChiselError::TransactionInProgress
553                | ChiselError::TagMismatch { .. }
554                // Poisoned is operational by is_fatal()'s definition: the manager
555                // is already dead, so re-seeing it must not re-poison.
556                | ChiselError::Poisoned
557                // Encryption credential errors: the DB is intact; supply the
558                // right key and retry, or manage key slots before retrying.
559                | ChiselError::NoEncryptionKey
560                | ChiselError::InvalidEncryptionKey
561                | ChiselError::EncryptionNotSupported
562                | ChiselError::NoFreeKeySlot
563                | ChiselError::LastKeySlot => false,
564                // Fatal — integrity in question; close and reopen.
565                ChiselError::IoError(_)
566                | ChiselError::ChecksumMismatch { .. }
567                | ChiselError::CorruptSuperblock { .. }
568                | ChiselError::FileSizeMismatch { .. }
569                | ChiselError::LockFailed
570                | ChiselError::UnsupportedFormatVersion { .. }
571                | ChiselError::CorruptPage { .. }
572                | ChiselError::InvalidPageId { .. }
573                | ChiselError::UnsupportedPageSize { .. }
574                | ChiselError::DecryptionFailed { .. } => true,
575            }
576        }
577
578        // One instance of every variant. If a variant is added to the enum,
579        // documented_is_fatal stops compiling first; this list then needs the
580        // new variant too so is_fatal() is actually exercised against it.
581        let all = [
582            ChiselError::InvalidHandle(0),
583            ChiselError::NoActiveTransaction,
584            ChiselError::TransactionAlreadyActive,
585            ChiselError::SavepointNotFound("s".to_string()),
586            ChiselError::DuplicateSavepoint("s".to_string()),
587            ChiselError::ReadOnlyMode,
588            ChiselError::FileNotFound,
589            ChiselError::InvalidRootName,
590            ChiselError::RootNameTableFull,
591            ChiselError::InvalidSuperblockCount { value: 0 },
592            ChiselError::CacheFull { limit: 0 },
593            ChiselError::SpillwayFull { limit_bytes: 0 },
594            ChiselError::TransactionInProgress,
595            ChiselError::TagMismatch {
596                handle: 0,
597                expected: 0,
598                actual: 0,
599            },
600            ChiselError::Poisoned,
601            ChiselError::IoError(io::Error::other("io")),
602            ChiselError::ChecksumMismatch { page_id: 0 },
603            ChiselError::CorruptSuperblock { defects: vec![] },
604            ChiselError::FileSizeMismatch {
605                expected: 0,
606                actual: 0,
607            },
608            ChiselError::LockFailed,
609            ChiselError::UnsupportedFormatVersion {
610                found: 0,
611                expected: 0,
612            },
613            ChiselError::CorruptPage { page_id: 0 },
614            ChiselError::InvalidPageId { page_id: 0 },
615            ChiselError::UnsupportedPageSize {
616                stored: 0,
617                compiled: 0,
618            },
619            ChiselError::NoEncryptionKey,
620            ChiselError::InvalidEncryptionKey,
621            ChiselError::EncryptionNotSupported,
622            ChiselError::NoFreeKeySlot,
623            ChiselError::LastKeySlot,
624            ChiselError::DecryptionFailed { page_id: 0 },
625        ];
626        for e in &all {
627            assert_eq!(
628                e.is_fatal(),
629                documented_is_fatal(e),
630                "is_fatal() disagrees with the documented Fatal/Operational block for {e:?}"
631            );
632        }
633        // Tripwire: exactly 10 variants are fatal today. If this count moves, the
634        // Fatal/Operational split changed — confirm that was intentional (it is a
635        // breaking change for callers doing error-class matching, per the header).
636        assert_eq!(all.iter().filter(|e| e.is_fatal()).count(), 10);
637    }
638
639    // Phase 4: the three operational encryption errors are recoverable (the
640    // on-disk DB is intact — the caller supplied the wrong/no key, or asked an
641    // old binary to read a v2 file), so is_fatal() is false. DecryptionFailed
642    // is fatal: an AEAD tag failure on a page read means the ciphertext or DEK
643    // is wrong and the snapshot can't be trusted, so it must poison (I1).
644    #[test]
645    fn encryption_error_classification() {
646        assert!(!ChiselError::NoEncryptionKey.is_fatal());
647        assert!(!ChiselError::InvalidEncryptionKey.is_fatal());
648        assert!(!ChiselError::EncryptionNotSupported.is_fatal());
649        assert!(!ChiselError::NoFreeKeySlot.is_fatal());
650        assert!(!ChiselError::LastKeySlot.is_fatal());
651        assert!(ChiselError::DecryptionFailed { page_id: 7 }.is_fatal());
652
653        // Display carries the page id for the fatal variant.
654        let msg = format!("{}", ChiselError::DecryptionFailed { page_id: 7 });
655        assert!(
656            msg.contains('7'),
657            "Display {msg:?} should mention page id 7"
658        );
659
660        // source() is None for all six — none wrap an inner cause.
661        use std::error::Error;
662        for e in [
663            ChiselError::NoEncryptionKey,
664            ChiselError::InvalidEncryptionKey,
665            ChiselError::EncryptionNotSupported,
666            ChiselError::NoFreeKeySlot,
667            ChiselError::LastKeySlot,
668            ChiselError::DecryptionFailed { page_id: 0 },
669        ] {
670            assert!(e.source().is_none());
671        }
672    }
673}