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}