Skip to main content

kernel/
recover.rs

1//! Recovery primitives and source-preserving salvage.
2//!
3//! Use [`recover_to`] for the E4 recovery workflow. It keeps the source intact,
4//! follows verified published ancestry, streams named losses, and places raw
5//! rootless evidence in a separate archive with no current-membership claim.
6//!
7//! The older [`recover`] function below is a low-level, in-place forensic
8//! rebuild retained for kernel regression coverage. Its global leaf sweep can
9//! include obsolete CoW versions and resurrect deletes. Generation/page-number
10//! dedup does not establish current membership; sibling pointers can also be
11//! stale. A broken overflow aborts that strict rebuild. It is NOT the safe E4
12//! recovery API and must not be used to publish a repaired user database.
13//!
14//! See docs/RECOVERY_CONTRACT.md in the E4 root for the supported fault model,
15//! distinctions between current survivors and candidates, and open law gates.
16
17use crate::bulk::{pack_tree, ExternalSort};
18use crate::budget::MemoryBudget;
19use crate::io::{open_file, FileIo};
20use crate::meta::Meta;
21use crate::page::{PageKind, PageRef, PAGE_SIZE};
22use crate::pool::BufferPool;
23use crate::store::Config;
24use crate::Result;
25use std::collections::{HashMap, HashSet};
26use std::path::Path;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::Arc;
29
30mod safe;
31pub use safe::{recover_to, RecoveryClass, SafeRecoveryReport};
32mod reader;
33pub use reader::{CandidateReader, LeafCandidate, LeafEvent, LeafScanReport};
34
35/// A leaf that failed verification, and what can honestly be said about it.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct LostLeaf {
38    pub page_no: u32,
39    /// Historical sibling hint from the rootless sweep. CoW sibling pointers
40    /// can be stale: this is NOT an authenticated bound on current lost keys.
41    /// The safe API reports bounds only from verified parent separators.
42    pub after_key: Option<Vec<u8>>,
43}
44
45#[derive(Debug, Default)]
46pub struct RecoveryReport {
47    /// Total page reads attempted, across BOTH sweeps -- the first (which
48    /// collects entries and losses) and the second (which walks the sibling
49    /// chain to name each loss). Reflects actual I/O cost, not distinct pages
50    /// in the file; a store with any losses is read twice.
51    pub pages_scanned: u64,
52    pub leaves_kept: u64,
53    pub leaves_lost: u64,
54    /// Entries actually present in the rebuilt tree -- after deduplication, so
55    /// this is exactly what a caller's `scan` will count, never a raw sum of
56    /// pushes into leaves that may have overlapped.
57    pub entries_recovered: u64,
58    /// One entry per lost leaf. See `LostLeaf`.
59    pub lost_ranges: Vec<LostLeaf>,
60    /// Bytes at the end of the file that do not make up a complete page
61    /// (file length not a multiple of `PAGE_SIZE`) -- e.g. a crash mid-extend,
62    /// or a full disk. Named, not classified: the page's own kind is
63    /// unknowable, because its header may be exactly the part that is
64    /// missing. NOT included in `leaves_lost` -- a page that was never even a
65    /// candidate to be a leaf is not a leaf loss, it is a separate, honestly
66    /// unclassifiable fact about the file.
67    pub truncated_tail_bytes: u64,
68    /// Where the write-ahead log was copied to, if it held damage `Wal::open`
69    /// refuses to walk past (`wal::Stop::Damaged`). Every original byte is in
70    /// that file; the live `wal` is reconstructed from independently verified
71    /// committed regions. `None` means the log was fine and was not
72    /// touched at all -- the ordinary case, and the one this module's doc
73    /// comment promises.
74    pub wal_quarantined: Option<std::path::PathBuf>,
75    /// Bytes in the reconstructed live log. Ambiguous regions remain in the
76    /// quarantined original for a repair tool or person to inspect.
77    pub wal_bytes_kept: u64,
78    /// Size of the log that was set aside, in full.
79    pub wal_bytes_set_aside: u64,
80}
81
82/// The outcome of attempting to read and verify page `no`.
83enum ReadOutcome<'b> {
84    /// Read succeeded and the page verified.
85    Verified(PageRef<'b>),
86    /// The read succeeded, but the page failed to verify (bad CRC, identity,
87    /// etc). `buf` genuinely holds THIS page's own (corrupted) bytes, so a
88    /// raw kind byte read from it describes what the page actually claimed
89    /// to be, even though the page as a whole cannot be trusted.
90    ReadOk,
91    /// The read itself failed. `buf` was never overwritten for this `no`, so
92    /// it still holds whatever a PREVIOUS iteration (or nothing) left there
93    /// -- not this page's bytes at all. Nothing about it, including a raw
94    /// kind byte, may be trusted or used to classify this page.
95    ReadFailed,
96}
97
98/// Read and verify page `no`, distinguishing "the read itself failed" from
99/// "the read succeeded but the page did not verify" -- callers that fall back
100/// to a raw kind byte on failure need to know which, because only the second
101/// case leaves that byte meaningful.
102fn read_page<'b>(file: &dyn FileIo, buf: &'b mut [u8], no: u64) -> ReadOutcome<'b> {
103    if file.read_at(buf, no * PAGE_SIZE as u64).is_err() { return ReadOutcome::ReadFailed; }
104    match PageRef::open(buf, no as u32) {
105        Ok(p) => ReadOutcome::Verified(p),
106        Err(_) => ReadOutcome::ReadOk,
107    }
108}
109
110/// Read and verify page `no`. `None` covers both outcomes in which the page
111/// cannot be used as itself (a failed read or a failed verification) --
112/// collapsed to one outcome because callers here only ever need "is this
113/// page trustworthy", never why it was not. (The one caller that DOES need
114/// why -- the first sweep's lost-leaf classification, which falls back to a
115/// raw kind byte -- uses `read_page` directly instead.)
116fn read_verified<'b>(file: &dyn FileIo, buf: &'b mut [u8], no: u64) -> Option<PageRef<'b>> {
117    match read_page(file, buf, no) {
118        ReadOutcome::Verified(p) => Some(p),
119        ReadOutcome::ReadOk | ReadOutcome::ReadFailed => None,
120    }
121}
122
123/// Recovery deliberately shares the normal/verifier record decoder. A
124/// recovery-only parser is most likely to be exercised by malformed bytes and
125/// therefore must not have weaker bounds checks than the ordinary reader.
126fn decode_leaf_record(rec: &[u8], page_no: u32) -> Result<(&[u8], &[u8], bool)> {
127    match crate::verify::decode_record(rec, page_no, PageKind::Leaf)? {
128        crate::verify::DecodedRecord::Leaf { key, value, overflow } => {
129            Ok((key, value, overflow))
130        }
131        crate::verify::DecodedRecord::Interior { .. } => unreachable!(),
132    }
133}
134
135/// Overflow markers name pages in the SOURCE file. Repacking leaves alone
136/// cannot preserve those addresses. Stream verified chunks into the new pool
137/// and rewrite the marker, using one scratch page and at most one write guard.
138/// Any failure leaves the original file untouched by the caller's publish gate.
139fn copy_overflow(file: &dyn FileIo, pool: &BufferPool, marker: &[u8]) -> Result<Vec<u8>> {
140    use crate::btree::{OV_CAP, OV_DATA, OV_NEXT, OV_USED};
141    use crate::page::PageMut;
142    let bad = |page_no, why| crate::Error::Corrupt { page_no, why };
143    if marker.len() != 12 { return Err(bad(0, "recovery overflow marker wrong size")); }
144    let total = u32::from_le_bytes(marker[0..4].try_into().unwrap()) as usize;
145    let mut source = u32::from_le_bytes(marker[4..8].try_into().unwrap());
146    let want_crc = u32::from_le_bytes(marker[8..12].try_into().unwrap());
147    let expected_pages = total.div_ceil(OV_CAP).max(1);
148    let file_pages = file.len()? / PAGE_SIZE as u64;
149    let (mut seen, mut bytes, mut crc) = (0usize, 0usize, 0u32);
150    let (mut head, mut previous) = (0u32, 0u32);
151    let mut buf = [0u8; PAGE_SIZE];
152    while source != 0 {
153        seen += 1;
154        if seen > expected_pages || source as u64 >= file_pages {
155            return Err(bad(source, "recovery overflow chain cycles or exceeds file"));
156        }
157        file.read_at(&mut buf, source as u64 * PAGE_SIZE as u64)?;
158        let page = PageRef::open(&buf, source)?;
159        if page.kind() != PageKind::Overflow || page.tree_id() != 0 {
160            return Err(bad(source, "recovery overflow chain reaches wrong page kind"));
161        }
162        let used = u16::from_le_bytes(buf[OV_USED..OV_USED + 2].try_into().unwrap()) as usize;
163        let next = u32::from_le_bytes(buf[OV_NEXT..OV_NEXT + 4].try_into().unwrap());
164        if used > OV_CAP || bytes.checked_add(used).is_none_or(|n| n > total) {
165            return Err(bad(source, "recovery overflow length out of bounds"));
166        }
167        let chunk = &buf[OV_DATA..OV_DATA + used];
168        crc = crc32c::crc32c_append(crc, chunk);
169        bytes += used;
170        let mut w = pool.allocate()?;
171        let dest = w.page_no();
172        let b = w.bytes_mut();
173        PageMut::init(b, PageKind::Overflow, 0, dest).finalise(0);
174        b[OV_USED..OV_USED + 2].copy_from_slice(&(used as u16).to_le_bytes());
175        b[OV_DATA..OV_DATA + used].copy_from_slice(chunk);
176        drop(w);
177        if previous == 0 { head = dest; } else {
178            let mut w = pool.get_mut(previous)?;
179            w.bytes_mut()[OV_NEXT..OV_NEXT + 4].copy_from_slice(&dest.to_le_bytes());
180        }
181        previous = dest;
182        source = next;
183    }
184    if seen != expected_pages || bytes != total || crc != want_crc {
185        return Err(bad(0, "recovery overflow value fails manifest checksum"));
186    }
187    let mut relocated = marker.to_vec();
188    relocated[4..8].copy_from_slice(&head.to_le_bytes());
189    Ok(relocated)
190}
191
192/// Tag a value with (generation, origin page number) so a later
193/// duplicate-key collapse can choose a winner. Since 2n step A, `seal`
194/// stamps every page that reaches disk with the generation of the epoch
195/// that wrote it, so the GENERATION is the primary recency signal -- it
196/// stays correct when page numbers are recycled (the freelist), where the
197/// old rule "higher page number = written later" silently inverts. Page
198/// number remains the tie-break WITHIN a generation: fresh allocations in
199/// one epoch are still monotonic, and one key has at most one current-
200/// epoch leaf (a shadowed page is edited in place thereafter). Both fields
201/// big-endian so the 12-byte prefix compares lexicographically.
202fn tag(gen: u64, page_no: u32, val: &[u8]) -> Vec<u8> {
203    let mut t = Vec::with_capacity(12 + val.len());
204    t.extend_from_slice(&gen.to_be_bytes());
205    t.extend_from_slice(&page_no.to_be_bytes());
206    t.extend_from_slice(val);
207    t
208}
209
210fn untag(t: &[u8]) -> (&[u8], &[u8]) {
211    (&t[0..12], &t[12..])
212}
213
214static SEQ: AtomicU64 = AtomicU64::new(0);
215
216/// Low-level in-place forensic rebuild; prefer [`recover_to`].
217///
218/// WARNING: this rootless path can resurrect deleted/obsolete rows and its
219/// historical loss hints are not authoritative. Retained for kernel regression
220/// coverage, not for publishing current user data.
221///
222/// Sweeps the file once, keeping every leaf that still verifies (correct CRC,
223/// correct identity), and separately counting every page that CLAIMS to be a
224/// leaf but does not verify -- only that page kind costs a loss, because an
225/// interior page is fully re-derivable from the leaves and a damaged one is
226/// simply skipped for free.
227///
228/// Two leaves can genuinely agree on the same key: page reclamation is
229/// deferred in this engine (`Store::bulk_load`'s doc comment), so a whole-tree
230/// replacement leaves the previous tree's leaves allocated, intact, and CRC
231/// valid, just unreachable from the current root. A sweep that does not consult
232/// the (possibly damaged) root cannot tell "unreachable" from "current" by
233/// structure alone, so duplicates are resolved by (generation, page number) (see `tag`)
234/// before the fresh tree is packed, since `pack_tree` itself now refuses to
235/// pack a duplicate key outright.
236///
237/// Law 3: the old file is never touched until a complete, verified replacement
238/// exists. The rebuild is written to a fresh temp file and only then swapped
239/// in with a rename.
240///
241/// SACRIFICE (Law 4): after the existing build and full barrier, recovery pays
242/// one additional sequential tree traversal through a fresh 16-page pool.
243/// This is recovery-only work; query and write paths are unchanged.
244pub fn recover(dir: &Path, cfg: Config) -> Result<RecoveryReport> {
245    if matches!(crate::limits::read(dir), Ok(Some(_))) {
246        return Err(crate::Error::ResourceLimit("in-place repair disabled for constrained stores; recover_to a separately provisioned destination"));
247    }
248    let data = dir.join("data");
249    let (file, _) = open_file(&data, cfg.io)?;
250    recover_impl(&*file, dir, cfg)
251}
252
253/// The actual sweep and rebuild, taking the file to read as a `&dyn FileIo`
254/// rather than opening it itself. Exists so a test can inject a read failure
255/// on a specific page without needing a real faulty disk -- `recover`'s own
256/// `open_file` call is the only thing this split moves out of the fallible
257/// path a test can reach; everything else (the two sweeps, the rebuild, the
258/// rename) is unchanged.
259fn recover_impl(file: &dyn FileIo, dir: &Path, cfg: Config) -> Result<RecoveryReport> {
260    recover_impl_with_before_publish(file, dir, cfg, |_| Ok(()))
261}
262
263/// Test seam at the exact Law-3 boundary: the replacement is complete and
264/// flushed, but the original has not been renamed over yet.
265fn recover_impl_with_before_publish<F>(
266    file: &dyn FileIo,
267    dir: &Path,
268    cfg: Config,
269    before_publish: F,
270) -> Result<RecoveryReport>
271where
272    F: FnOnce(&Path) -> Result<()>,
273{
274    let data = dir.join("data");
275    let len = file.len()?;
276    let total = len / PAGE_SIZE as u64;
277
278    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
279    let scratch = std::env::temp_dir().join(format!("kernel-recover-{}-{}", std::process::id(), seq));
280    // A third of the configured budget -- the fraction the pool below does
281    // NOT take (it takes two thirds, matching `Store::build`'s convention).
282    // A fixed arena disconnected from `cfg.budget_bytes` is not a bound at
283    // all: every caller in this crate's own tests configures 16-32 MiB, which
284    // a hard-coded 64 MiB arena would have exceeded outright, on the one path
285    // that runs when the store is largest and most damaged.
286    let arena = (cfg.budget_bytes / 3).max(4 << 20);
287    let mut sorter = ExternalSort::new(&scratch, arena)?;
288
289    let mut rep = RecoveryReport::default();
290    rep.truncated_tail_bytes = len % PAGE_SIZE as u64;
291    let mut max_lsn: u64 = 0;
292    // Page numbers of every leaf that failed to verify, in the order the
293    // sweep found them -- small (bounded by how much is actually lost, not by
294    // the store), so this is fine to hold in full.
295    let mut lost_pages: Vec<u32> = Vec::new();
296
297    let mut buf = vec![0u8; PAGE_SIZE];
298    for no in 1..total {
299        rep.pages_scanned += 1;
300        let page = match read_page(file, &mut buf, no) {
301            ReadOutcome::Verified(p) => p,
302            ReadOutcome::ReadOk => {
303                // The read succeeded, so `buf` genuinely holds this page's
304                // own (corrupted) bytes -- a raw kind byte read from it
305                // describes what the page actually claimed to be. Only count
306                // it as a lost LEAF if it claims to be one; a damaged
307                // interior page costs nothing, because interiors are wholly
308                // derivable from the leaves that survive.
309                let kind = u16::from_le_bytes([buf[6], buf[7]]);
310                if kind == PageKind::Leaf as u16 {
311                    rep.leaves_lost += 1;
312                    lost_pages.push(no as u32);
313                }
314                continue;
315            }
316            ReadOutcome::ReadFailed => {
317                // The read itself failed: `buf` was never touched for this
318                // `no` and still holds whatever a PREVIOUS iteration left in
319                // it -- not this page's bytes at all. Reading a kind byte
320                // from stale data and skipping the page because it happens
321                // to say "Interior" is exactly the silent undercount Law 5
322                // forbids: a page that may genuinely have been a leaf would
323                // be dropped with no counter touched and no entry in
324                // `lost_ranges`. Over-reporting is tolerable; this is not, so
325                // an unreadable page is ALWAYS counted as a lost leaf,
326                // regardless of what the stale buffer says.
327                rep.leaves_lost += 1;
328                lost_pages.push(no as u32);
329                continue;
330            }
331        };
332        // Trustworthy only because this page just verified: a corrupted page's
333        // `lsn` field could be garbage, so only verified pages may contribute
334        // to the LSN floor the rebuilt superblock will publish. (Today every
335        // production page carries `lsn == 0` regardless -- see page.rs -- so
336        // this is a no-op until that field is wired up, and correct either way.)
337        max_lsn = max_lsn.max(page.lsn());
338        if page.kind() != PageKind::Leaf { continue; }
339        rep.leaves_kept += 1;
340
341        let n = page.nentries();
342        for i in 0..n {
343            let rec = page.slot(i);
344            let (k, v, is_marker) = decode_leaf_record(rec, page.page_no())?;
345            // v is the slot's stored value: for an overflow record that is
346            // the 12-byte marker, and the flag must survive recovery or the
347            // rebuilt tree would hold the marker bytes as a LITERAL value.
348            sorter.push_flagged(k.to_vec(), tag(page.lsn(), page.page_no(), v), is_marker)?;
349        }
350    }
351
352    // Second sweep: name each loss from the sibling chain, not from page
353    // order. `lost_set` is only as large as `lost_pages` -- O(losses), never
354    // a map of every leaf's `next_leaf`, which would be RAM proportional to
355    // the store in the one path that runs when the store is largest and most
356    // damaged.
357    let lost_set: HashSet<u32> = lost_pages.iter().copied().collect();
358    let mut after_by_page: HashMap<u32, Vec<u8>> = HashMap::with_capacity(lost_pages.len());
359    if !lost_set.is_empty() {
360        for no in 1..total {
361            rep.pages_scanned += 1;
362            let Some(page) = read_verified(file, &mut buf, no) else { continue };
363            if page.kind() != PageKind::Leaf { continue; }
364            if !lost_set.contains(&page.next_leaf()) { continue; }
365            let n = page.nentries();
366            // An empty leaf has no key to honestly offer as a bound, even
367            // though it structurally is the predecessor -- Law 5 asks what
368            // was lost, not what probably was, so this leaf simply does not
369            // supply a bound rather than inventing one from nothing.
370            if n == 0 { continue; }
371            let (last_k, _, _) = decode_leaf_record(page.slot(n - 1), page.page_no())?;
372            // If more than one surviving leaf's `next_leaf` names the same
373            // lost page (not possible from an intact sibling chain, but this
374            // sweep does not assume the chain is intact), the later one found
375            // wins; either is an equally honest single predecessor.
376            after_by_page.insert(page.next_leaf(), last_k.to_vec());
377        }
378    }
379    for page_no in lost_pages {
380        rep.lost_ranges.push(LostLeaf { page_no, after_key: after_by_page.get(&page_no).cloned() });
381    }
382
383    // Rebuild into a fresh file, then swap. Law 3: nothing about the damaged
384    // file is touched until the replacement is complete and verified.
385    let tmp = dir.join("data.rebuild");
386    let _ = std::fs::remove_file(&tmp);
387    let mut entries_recovered = 0u64;
388    let next_lsn = max_lsn.checked_add(1).ok_or(crate::Error::Corrupt {
389        page_no: 0,
390        why: "recovered LSN high-water mark is exhausted",
391    })?;
392    let mut rebuilt_roots = [0u32; crate::meta::MAX_TREES];
393    // Wrapped in an immediately-invoked closure so any `?` failure inside can
394    // be caught and `tmp` cleaned up before the error propagates -- without
395    // this, a failure partway through (e.g. `pack_tree` hitting a page too
396    // large, or the pool failing to allocate) leaves a half-built
397    // `data.rebuild` sitting on disk: litter that looks like real state to
398    // anyone inspecting the directory later.
399    let build: Result<()> = (|| {
400        let (nf, _) = open_file(&tmp, cfg.io)?;
401        let budget = Arc::new(MemoryBudget::new(cfg.budget_bytes));
402        let frames = ((cfg.budget_bytes * 2 / 3) / PAGE_SIZE).max(16);
403        let pool = BufferPool::new(nf.into(), budget, frames)?;
404        let _meta_page = pool.allocate()?; // page 0 is the superblock; BTree::create
405        drop(_meta_page); // asserts a root is never page 0 -- reserve it first.
406        let _slot_b = pool.allocate()?;    // page 1 is meta slot B (2f)
407        drop(_slot_b);
408        Meta::init_slot_b(&pool)?;
409
410        let mut runs = sorter.finish()?;
411        let merged = runs.iter()?;
412
413        // Collapse adjacent equal keys, keeping the highest (generation,
414        // page number) origin (see `tag`'s doc comment).
415        // `merged` is sorted by key, so duplicates of the same key are always
416        // adjacent -- this needs only the current winner in memory, never the
417        // whole store (Law 1).
418        struct Dedup<I> { inner: I, winner: Option<(Vec<u8>, Vec<u8>, bool)>, done: bool }
419        impl<I: Iterator<Item = Result<(Vec<u8>, Vec<u8>, bool)>>> Iterator for Dedup<I> {
420            type Item = Result<(Vec<u8>, Vec<u8>, bool)>;
421            fn next(&mut self) -> Option<Self::Item> {
422                if self.done { return None; }
423                loop {
424                    match self.inner.next() {
425                        Some(Ok((k, tagged_v, m))) => {
426                            match &mut self.winner {
427                                None => self.winner = Some((k, tagged_v, m)),
428                                Some((wk, wv, wm)) if *wk == k => {
429                                    let (worder, _) = untag(wv);
430                                    let (order, _) = untag(&tagged_v);
431                                    if order > worder { *wv = tagged_v; *wm = m; }
432                                }
433                                Some(_) => {
434                                    let (out_k, out_v, out_m) = self.winner.take().unwrap();
435                                    self.winner = Some((k, tagged_v, m));
436                                    let (_, real_v) = untag(&out_v);
437                                    return Some(Ok((out_k, real_v.to_vec(), out_m)));
438                                }
439                            }
440                        }
441                        Some(Err(e)) => { self.done = true; return Some(Err(e)); }
442                        None => {
443                            self.done = true;
444                            return self.winner.take().map(|(k, v, m)| {
445                                let (_, real_v) = untag(&v);
446                                Ok((k, real_v.to_vec(), m))
447                            });
448                        }
449                    }
450                }
451            }
452        }
453        // A salvaged source may have lost rows while its aggregate remains
454        // checksum-valid. Derived SQL counts are never evidence of salvage
455        // completeness. The marker also tells ordinary WAL replay not to
456        // resurrect old summaries on this generation-zero salvaged source.
457        let recovered = Dedup { inner: merged, winner: None, done: false }
458            .filter(|item| !matches!(item, Ok((key, _, _))
459                if crate::keys::is_field_aggregate_key(key)));
460        let relocated = recovered.map(|item| {
461            let (key, value, overflow) = item?;
462            let value = if overflow { copy_overflow(file, &pool, &value)? } else { value };
463            Ok((key, value, overflow))
464        });
465        let counting = CountingIter { inner: relocated, count: &mut entries_recovered };
466        // Same scratch the sort used: `pack_tree` spills one file per tree
467        // level there and unlinks each as it is consumed, so nothing of its
468        // outlives this call even on the error path below.
469        let root = pack_tree(&pool, 1, counting, 0.9, &scratch)?;
470
471        // The LSN floor for the rebuilt store. It must be strictly above every
472        // LSN any surviving (verified) page carried, or a future record could
473        // repeat one already embedded in a page -- exactly the hazard
474        // `Meta::next_lsn`'s own doc comment names. The old log itself is not
475        // a source of truth here: it describes writes to a file that, after a
476        // sweep, may not even have the same page-to-content mapping any more.
477        rebuilt_roots[0] = root;
478        Meta { format_version: crate::meta::FORMAT_VERSION, roots: rebuilt_roots, next_lsn, generation: 0 }.write(&pool)?;
479        Meta::mark_salvaged(&pool)?;
480        // Always the strongest barrier, regardless of `cfg.sync`: Law 3 says
481        // the replacement must be verified durable before the rename that
482        // exposes it, and a repair run happens rarely enough that it should
483        // not inherit a throughput-motivated durability trade-off made for
484        // ordinary per-write traffic.
485        pool.flush_all(crate::io::Barrier::Full)?;
486        Ok(())
487    })();
488    if let Err(e) = build {
489        let _ = std::fs::remove_file(&tmp);
490        return Err(e);
491    }
492    if let Err(e) = before_publish(&tmp).and_then(|_| {
493        let expected = Meta {
494            format_version: crate::meta::FORMAT_VERSION,
495            roots: rebuilt_roots,
496            next_lsn,
497            generation: 0,
498        };
499        let verified = crate::verify::verify_rebuild(&tmp, cfg.io, &expected, entries_recovered)?;
500        debug_assert_eq!(verified.rows, entries_recovered);
501        debug_assert!(verified.pages > 0);
502        Ok(())
503    }) {
504        let _ = std::fs::remove_file(&tmp);
505        return Err(e);
506    }
507
508    // Recovery has changed every page number. Publish a verified EMPTY list
509    // before publishing those new numbers. If power fails here, the standing
510    // data file either sees its own old list or rejects generation 0 and leaks;
511    // if it fails after the data rename, the rebuilt file sees the matching
512    // empty list. No instant exposes rebuilt data beside old reusable numbers.
513    let rebuilt_pages = std::fs::metadata(&tmp)?.len() / PAGE_SIZE as u64;
514    let rebuilt_pages = u32::try_from(rebuilt_pages).map_err(|_| crate::Error::Corrupt {
515        page_no: 0,
516        why: "rebuilt file has too many pages for its freelist bound",
517    })?;
518    let empty_free = BufferPool::empty_free(0);
519    crate::verify::publish_freelist(dir, empty_free, 0, rebuilt_pages, file, true, true)?;
520    std::fs::rename(&tmp, &data)?;
521    let (f2, _) = open_file(&data, cfg.io)?;
522    f2.sync_dir()?;
523
524    // The log, if it is the thing that is damaged. `Wal::open` refuses a
525    // log whose walk stopped for a reason that is not an ending (see
526    // `wal::Stop`), and that refusal is only half a design: a store that
527    // cannot be opened and cannot be repaired is exactly as unrecoverable
528    // as one that was deleted, which is what Law 5 forbids. This is the
529    // other half.
530    quarantine_damaged_wal(dir, cfg, &mut rep)?;
531
532    // Otherwise the write-ahead log is deliberately NOT deleted here. Its records are
533    // logical (`insert`/`delete` by key, per `Store::apply`) and name no
534    // pages, so they replay onto the rebuilt tree exactly as well as onto
535    // the old one -- and the log holds precisely the writes that were
536    // committed but never checkpointed, which is to say the writes that are
537    // NOT in the pages this sweep could find. Deleting it would be this
538    // repair path discarding committed data it could have restored, having
539    // decided what to keep by reading and then removing what it did not see
540    // -- Law 3's exact subject. `Store::open`'s ordinary log replay applies
541    // it to the rebuilt tree the next time this directory is opened; replay
542    // is idempotent for both record kinds and `Wal::open` already truncates
543    // a damaged tail, so nothing here needs to special-case it.
544
545    rep.entries_recovered = entries_recovered;
546    Ok(rep)
547}
548
549/// Copy the whole log aside, byte for byte, verify the copy by read-back hash,
550/// and reconstruct safely resynchronised committed regions as the live log.
551///
552/// Law 3 first: nothing is deleted and nothing is overwritten in place. The
553/// full copy is written and fsynced BEFORE the live log is touched, so at
554/// every instant from here on there is at least one complete copy of every
555/// original byte on disk. A crash midway leaves either the original alone or
556/// the original plus its copy; neither loses anything, and re-running
557/// `recover()` simply makes another copy.
558///
559/// Law 5 second: what makes the refusal survivable is that this runs on
560/// exactly the images `Wal::open` refuses, and afterwards those images open.
561/// Ambiguous frames are not replayed and not thrown away: they remain in
562/// `wal.corrupt.N`, where a repair tool has the whole file and the offset
563/// that stopped the walk.
564///
565/// A missing log is a proved absence and succeeds.  A log that exists but
566/// cannot be inspected is uncertainty and propagates: it may contain a
567/// committed tail that is absent from the rebuilt pages, so reporting success
568/// would strand precisely the data recovery is responsible for finding.
569/// Nothing has touched that log when inspection fails.
570fn quarantine_damaged_wal(dir: &Path, cfg: Config, rep: &mut RecoveryReport) -> Result<()> {
571    let wal = dir.join("wal");
572    if !wal.exists() { return Ok(()); }
573    let scan = crate::wal::Wal::inspect(&wal, cfg.io)?;
574    match scan.stop {
575        crate::wal::Stop::End(_) => return Ok(()),
576        crate::wal::Stop::Damaged { .. } => {}
577    }
578
579    // First free name, so a second repair never overwrites the evidence the
580    // first one preserved.
581    let mut n = 0u32;
582    let aside = loop {
583        let c = dir.join(format!("wal.corrupt.{n}"));
584        if !c.exists() { break c; }
585        n = n.checked_add(1).ok_or(crate::Error::TooLarge)?;
586    };
587    let total = std::fs::metadata(&wal)?.len();
588    let source_hash = crate::wal::hash_prefix(&wal, total)?;
589    std::fs::copy(&wal, &aside)?;
590    std::fs::File::open(&aside)?.sync_all()?;
591    if std::fs::metadata(&aside)?.len() != total
592        || crate::wal::hash_prefix(&aside, total)? != source_hash
593    {
594        let _ = std::fs::remove_file(&aside);
595        return Err(crate::Error::CorruptWal {
596            offset: scan.end,
597            why: "quarantined WAL copy failed independent read-back hashing",
598        });
599    }
600
601    // Reconstruct beside the original and rename only after an independent
602    // parser and hash pass agree with the salvage writer.
603    let tmp = dir.join("wal.rebuild");
604    let _ = std::fs::remove_file(&tmp);
605    let salvaged = crate::wal::Wal::salvage_committed(&wal, &tmp)?;
606    let verified = crate::wal::Wal::inspect(&tmp, cfg.io)?;
607    if verified.end != salvaged.bytes
608        || !matches!(verified.stop, crate::wal::Stop::End(_))
609        || std::fs::metadata(&tmp)?.len() != salvaged.bytes
610        || crate::wal::hash_prefix(&tmp, salvaged.bytes)? != salvaged.hash
611    {
612        let _ = std::fs::remove_file(&tmp);
613        return Err(crate::Error::CorruptWal {
614            offset: scan.end,
615            why: "reconstructed WAL failed independent parse and hash verification",
616        });
617    }
618    std::fs::rename(&tmp, &wal)?;
619    let (f, _) = open_file(&wal, crate::io::IoMode::Buffered)?;
620    f.sync_dir()?;
621
622    rep.wal_quarantined = Some(aside);
623    rep.wal_bytes_kept = salvaged.bytes;
624    rep.wal_bytes_set_aside = total;
625    Ok(())
626}
627
628/// Counts every item that passes through, so `entries_recovered` reflects
629/// exactly what was packed -- after deduplication -- rather than a raw push
630/// count that could include items later collapsed away.
631struct CountingIter<'a, I> { inner: I, count: &'a mut u64 }
632impl<'a, I: Iterator<Item = Result<(Vec<u8>, Vec<u8>, bool)>>> Iterator for CountingIter<'a, I> {
633    type Item = Result<(Vec<u8>, Vec<u8>, bool)>;
634    fn next(&mut self) -> Option<Self::Item> {
635        let item = self.inner.next();
636        if let Some(Ok(_)) = &item { *self.count += 1; }
637        item
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use crate::io::IoMode;
645    use crate::store::{Config, Store, SyncMode};
646
647    fn cfg() -> Config { Config { budget_bytes: 16 << 20, io: IoMode::Buffered, sync: SyncMode::Off } }
648
649    /// The falsification: force `recover` to treat a damaged INTERIOR page as
650    /// a loss, the same as a leaf. If interiors truly cost nothing (they are
651    /// wholly derivable from the leaves), then wrongly counting one as a loss
652    /// must be something a test could catch -- proving the real implementation
653    /// relies on the distinction rather than merely asserting it in a comment.
654    /// This test does not call the wrong-counting code path (that would defeat
655    /// its own purpose); it exists so the counterfactual is checked by hand in
656    /// the report, alongside a positive assertion that a leaf loss above is
657    /// real. See the task report for the actual before/after run.
658    #[test]
659    fn a_damaged_interior_page_costs_nothing() {
660        let d = tempfile::tempdir().unwrap();
661        let n = 5_000u64;
662        { let mut s = Store::create(d.path(), cfg()).unwrap();
663          s.bulk_load((0..n).map(|i| (i.to_be_bytes().to_vec(), b"v".to_vec()))).unwrap();
664          s.commit().unwrap(); s.checkpoint().unwrap(); }
665
666        let path = d.path().join("data");
667        let mut bytes = std::fs::read(&path).unwrap();
668        let ps = PAGE_SIZE;
669        let mut wrecked_interior = false;
670        for p in 1..bytes.len() / ps {
671            let kind = u16::from_le_bytes([bytes[p * ps + 6], bytes[p * ps + 7]]);
672            if kind == PageKind::Interior as u16 && !wrecked_interior {
673                bytes[p * ps + 100] ^= 0xff;
674                wrecked_interior = true;
675            }
676        }
677        assert!(wrecked_interior, "the fixture needs at least one interior page");
678        std::fs::write(&path, &bytes).unwrap();
679
680        let report = recover(d.path(), cfg()).unwrap();
681        assert_eq!(report.leaves_lost, 0, "a damaged interior page must cost no leaf loss");
682        assert_eq!(report.entries_recovered, n, "and no data may be missing either");
683    }
684
685    /// Pins the duplicate-key resolution directly. `Store::bulk_load` never
686    /// reclaims the previous tree's pages, so bulk-loading twice over
687    /// overlapping keys leaves BOTH generations' leaves intact and CRC-valid
688    /// in the file -- a stale one at lower page numbers, a current one at
689    /// higher page numbers, both surviving the sweep. Recovery must keep the
690    /// current value, not the first one it happens to see.
691    #[test]
692    fn a_duplicate_key_across_two_surviving_generations_keeps_the_newer_one() {
693        let d = tempfile::tempdir().unwrap();
694        let n = 500u64;
695        let mut s = Store::create(d.path(), cfg()).unwrap();
696        s.bulk_load((0..n).map(|i| (i.to_be_bytes().to_vec(), b"stale".to_vec()))).unwrap();
697        // The stale tree's leaves are now allocated at low page numbers and
698        // are never reclaimed -- confirmed below.
699        s.bulk_load((0..n).map(|i| (i.to_be_bytes().to_vec(), b"current".to_vec()))).unwrap();
700        s.commit().unwrap(); s.checkpoint().unwrap();
701        drop(s);
702
703        // Sanity: the file really does hold leaves from both generations,
704        // i.e. this test exercises what it claims to.
705        let bytes = std::fs::read(d.path().join("data")).unwrap();
706        let mut stale_leaves = 0;
707        let mut current_leaves = 0;
708        for p in 1..bytes.len() / PAGE_SIZE {
709            let b = &bytes[p * PAGE_SIZE..(p + 1) * PAGE_SIZE];
710            if let Ok(pr) = PageRef::open(b, p as u32) {
711                if pr.kind() == PageKind::Leaf && pr.nentries() > 0 {
712                    let (_, v, _) = decode_leaf_record(pr.slot(0), pr.page_no()).unwrap();
713                    if v == b"stale" { stale_leaves += 1; }
714                    if v == b"current" { current_leaves += 1; }
715                }
716            }
717        }
718        assert!(stale_leaves > 0 && current_leaves > 0,
719                "fixture must retain both generations' leaves unreclaimed");
720
721        let report = recover(d.path(), cfg()).unwrap();
722        assert_eq!(report.entries_recovered, n,
723                   "duplicates must collapse to one entry per key, not {}",
724                   stale_leaves + current_leaves);
725
726        let reopened = Store::open(d.path(), cfg()).unwrap();
727        for i in (0..n).step_by(37) {
728            assert_eq!(
729                reopened.get(&i.to_be_bytes()).unwrap().as_deref(),
730                Some(&b"current"[..]),
731                "key {i} must resolve to the newer generation, not the stale one"
732            );
733        }
734    }
735
736    /// Locate every non-empty leaf's page number and last key in a raw file
737    /// buffer, in page-number order. Test helper only.
738    fn leaves_with_last_keys(bytes: &[u8]) -> Vec<(u32, Vec<u8>)> {
739        let mut out = Vec::new();
740        for p in 1..bytes.len() / PAGE_SIZE {
741            let b = &bytes[p * PAGE_SIZE..(p + 1) * PAGE_SIZE];
742            if let Ok(pr) = PageRef::open(b, p as u32) {
743                if pr.kind() == PageKind::Leaf && pr.nentries() > 0 {
744                    let (last_k, _, _) =
745                        decode_leaf_record(pr.slot(pr.nentries() - 1), pr.page_no()).unwrap();
746                    out.push((p as u32, last_k.to_vec()));
747                }
748            }
749        }
750        out
751    }
752
753    /// Break a page's CRC by flipping a payload byte, leaving its header
754    /// (including `kind`) intact -- the same technique the integration test
755    /// uses, so a broken leaf still reads as a Leaf by its raw kind byte.
756    fn wreck(bytes: &mut [u8], page_no: u32) {
757        let base = page_no as usize * PAGE_SIZE;
758        bytes[base + 50] ^= 0xff;
759    }
760
761    /// Overwrite a page's `next_leaf` field and re-SEAL it (checksum is
762    /// seal's job, not finalise's -- finalise only writes the lsn field),
763    /// preserving the page's generation stamp so the forged pointer is
764    /// indistinguishable from a real one. The old form finalise(0) only
765    /// passed because the forge happened to be a byte-level no-op (the
766    /// pointer already named its packed neighbour and lsn was already 0);
767    /// generation stamping (2n step A) made it a real change and exposed
768    /// the stale CRC.
769    fn forge_next_leaf(bytes: &mut [u8], page_no: u32, target: u32) {
770        let base = page_no as usize * PAGE_SIZE;
771        let page = &mut bytes[base..base + PAGE_SIZE];
772        let gen = u64::from_le_bytes(page[24..32].try_into().unwrap());
773        let mut p = crate::page::PageMut::reopen(page);
774        p.set_next_leaf(target);
775        crate::page::seal(page, gen);
776    }
777
778    /// The falsification for the sibling-chain lost-range naming: point a
779    /// surviving leaf's `next_leaf` at a page about to be destroyed, and
780    /// confirm `after_key` reports that leaf's last key -- proving the bound
781    /// really is read from the chain. Then destroy the pointing leaf too, and
782    /// confirm `after_key` falls back to `None` rather than a page-order
783    /// guess, since nothing verifiable points at the lost page any more.
784    #[test]
785    fn a_lost_leafs_after_key_comes_from_the_sibling_chain_not_page_order() {
786        let d = tempfile::tempdir().unwrap();
787        let n = 2_000u64;
788        { let mut s = Store::create(d.path(), cfg()).unwrap();
789          s.bulk_load((0..n).map(|i| (i.to_be_bytes().to_vec(), b"v".to_vec()))).unwrap();
790          s.commit().unwrap(); s.checkpoint().unwrap(); }
791
792        let path = d.path().join("data");
793        let mut bytes = std::fs::read(&path).unwrap();
794
795        let leaves = leaves_with_last_keys(&bytes);
796        assert!(leaves.len() >= 2, "fixture needs at least two non-empty leaves");
797        let (pointer_no, pointer_last_key) = leaves[0].clone();
798        let (victim_no, _) = leaves[1].clone();
799        assert_ne!(pointer_no, victim_no);
800
801        // Forge pointer -> victim, destroy victim. `recover` rebuilds `data`
802        // in place, so this exact byte state is captured (cloned) BEFORE
803        // either half runs `recover` -- each half gets its own fresh
804        // directory over the same starting bytes, rather than the second
805        // half accidentally reading back the first half's already-rebuilt
806        // file.
807        forge_next_leaf(&mut bytes, pointer_no, victim_no);
808        wreck(&mut bytes, victim_no);
809        let bytes_for_half_b = bytes.clone();
810
811        // Half A: one surviving leaf's next_leaf names the lost page ->
812        // after_key must be that leaf's real last key.
813        std::fs::write(&path, &bytes).unwrap();
814        let report = recover(d.path(), cfg()).unwrap();
815        let victim_entry = report.lost_ranges.iter().find(|l| l.page_no == victim_no)
816            .expect("the victim page must be reported as a loss");
817        assert_eq!(
818            victim_entry.after_key,
819            Some(pointer_last_key.clone()),
820            "after_key must come from the leaf whose next_leaf names the lost page"
821        );
822
823        // Half B: also destroy the pointing leaf, in a fresh directory over
824        // the SAME starting bytes. Its forged next_leaf is still physically
825        // present, but it can no longer be trusted (it fails its own CRC),
826        // so the second sweep must not see it -- after_key must fall back to
827        // None, not silently keep reporting the stale bound.
828        let d2 = tempfile::tempdir().unwrap();
829        let mut bytes2 = bytes_for_half_b;
830        wreck(&mut bytes2, pointer_no);
831        std::fs::write(d2.path().join("data"), &bytes2).unwrap();
832
833        let report2 = recover(d2.path(), cfg()).unwrap();
834        let victim_entry2 = report2.lost_ranges.iter().find(|l| l.page_no == victim_no)
835            .expect("the victim page must still be reported as a loss");
836        assert_eq!(
837            victim_entry2.after_key, None,
838            "after_key must be None once the only pointer to the lost page is itself unreadable"
839        );
840    }
841
842    /// A file truncated mid-page (crash mid-extend, a full disk) must be
843    /// named, not silently dropped from the floor-divided page count.
844    #[test]
845    fn a_truncated_tail_is_named_not_silently_dropped() {
846        let d = tempfile::tempdir().unwrap();
847        let n = 2_000u64;
848        { let mut s = Store::create(d.path(), cfg()).unwrap();
849          s.bulk_load((0..n).map(|i| (i.to_be_bytes().to_vec(), b"v".to_vec()))).unwrap();
850          s.commit().unwrap(); s.checkpoint().unwrap(); }
851
852        let path = d.path().join("data");
853        let full_len = std::fs::metadata(&path).unwrap().len();
854        assert_eq!(full_len % PAGE_SIZE as u64, 0, "fixture must start page-aligned");
855
856        // Truncate 137 bytes into the last page -- neither a whole page nor
857        // nothing.
858        let short_by = 137u64;
859        let truncated_len = full_len - short_by;
860        let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
861        f.set_len(truncated_len).unwrap();
862        drop(f);
863
864        let report = recover(d.path(), cfg()).unwrap();
865        assert_eq!(
866            report.truncated_tail_bytes,
867            PAGE_SIZE as u64 - short_by,
868            "the dangling bytes at the end of the file must be named exactly"
869        );
870        // The tail is unclassifiable (its own header may be the missing
871        // part), so it must never inflate leaves_lost -- no other page was
872        // damaged, so this must stay exactly zero.
873        assert_eq!(report.leaves_lost, 0, "a truncated tail must not be counted as a lost leaf");
874        assert_eq!(report.entries_recovered, n, "every complete page's data must still be recovered");
875    }
876
877    /// Delegates every `FileIo` method to a real, opened file, except that
878    /// `read_at` fails outright at one specific offset -- a deterministic,
879    /// portable way to force "the read itself failed" without needing a
880    /// faulty disk.
881    struct FailAt { inner: Box<dyn FileIo>, fail_offset: u64 }
882    impl FileIo for FailAt {
883        fn requires_alignment(&self) -> bool { self.inner.requires_alignment() }
884        fn read_at(&self, buf: &mut [u8], off: u64) -> Result<()> {
885            if off == self.fail_offset {
886                return Err(std::io::Error::new(std::io::ErrorKind::Other, "injected read failure").into());
887            }
888            self.inner.read_at(buf, off)
889        }
890        fn write_at(&self, buf: &[u8], off: u64) -> Result<()> { self.inner.write_at(buf, off) }
891        fn sync_data(&self) -> Result<()> { self.inner.sync_data() }
892        fn sync_full(&self) -> Result<()> { self.inner.sync_full() }
893        fn sync_full_primitive(&self) -> &'static str { self.inner.sync_full_primitive() }
894        fn sync_dir(&self) -> Result<()> { self.inner.sync_dir() }
895        fn len(&self) -> Result<u64> { self.inner.len() }
896        fn set_len(&self, n: u64) -> Result<()> { self.inner.set_len(n) }
897    }
898
899    /// The falsification for item 4: force a read failure on a page whose
900    /// PREDECESSOR (in scan order) is a genuine, undamaged interior page, and
901    /// confirm the failed page is counted as a lost leaf regardless -- not
902    /// silently skipped because the stale buffer (still holding the
903    /// predecessor's bytes) says "Interior".
904    ///
905    /// Two bulk loads in a row lay out pages as [gen-1 leaves][gen-1
906    /// interiors][gen-2 leaves][gen-2 interiors] (`pack_tree` allocates all
907    /// of a generation's leaves before any of its interiors), so the
908    /// boundary between gen-1's LAST interior page and gen-2's FIRST leaf
909    /// page is exactly "predecessor is interior, this page is a real leaf" --
910    /// no forged bytes needed, just picking the right natural page.
911    #[test]
912    fn an_unreadable_page_is_counted_regardless_of_the_stale_buffer_it_follows() {
913        let d = tempfile::tempdir().unwrap();
914        let n = 3_000u64;
915        let mut s = Store::create(d.path(), cfg()).unwrap();
916        s.bulk_load((0..n).map(|i| (i.to_be_bytes().to_vec(), b"gen1".to_vec()))).unwrap();
917        s.bulk_load((n..2 * n).map(|i| (i.to_be_bytes().to_vec(), b"gen2".to_vec()))).unwrap();
918        s.commit().unwrap(); s.checkpoint().unwrap();
919        drop(s);
920
921        let path = d.path().join("data");
922        let bytes = std::fs::read(&path).unwrap();
923        let total = bytes.len() / PAGE_SIZE;
924        let kind_of = |p: usize| -> Option<PageKind> {
925            PageRef::open(&bytes[p * PAGE_SIZE..(p + 1) * PAGE_SIZE], p as u32).ok().map(|pr| pr.kind())
926        };
927        let victim = (2..total as u32)
928            .find(|&p| kind_of(p as usize - 1) == Some(PageKind::Interior)
929                    && kind_of(p as usize) == Some(PageKind::Leaf))
930            .expect("two generations must produce an interior-then-leaf boundary");
931
932        let (real_file, _) = crate::io::open_file(&path, IoMode::Buffered).unwrap();
933        let failing = FailAt { inner: real_file, fail_offset: victim as u64 * PAGE_SIZE as u64 };
934        let report = recover_impl(&failing, d.path(), cfg()).unwrap();
935
936        assert!(
937            report.lost_ranges.iter().any(|l| l.page_no == victim),
938            "page {victim} (predecessor is Interior) failed to read and must be counted as lost, \
939             not silently skipped because the stale buffer said Interior"
940        );
941
942        // Falsification: the OLD behaviour -- classify a read failure from
943        // whatever `buf` still holds (the predecessor's real Interior bytes,
944        // since a failed read never overwrites `buf`) -- would have skipped
945        // this exact page. Reproduce that classification by hand against the
946        // SAME stale buffer this sweep would have seen, to show the
947        // counterfactual is real rather than assumed.
948        let buf = &bytes[(victim as usize - 1) * PAGE_SIZE..victim as usize * PAGE_SIZE];
949        let stale_kind = u16::from_le_bytes([buf[6], buf[7]]);
950        assert_eq!(
951            stale_kind,
952            PageKind::Interior as u16,
953            "sanity: the stale buffer a failed read would have left behind really does say Interior"
954        );
955        let old_behaviour_would_count_it = stale_kind == PageKind::Leaf as u16;
956        assert!(
957            !old_behaviour_would_count_it,
958            "the old kind-byte-fallback logic would NOT have counted page {victim} -- \
959             confirming this is a genuine fix, not a no-op"
960        );
961    }
962
963    /// A malformed superblock is the damage `recover()` exists to service,
964    /// so `recover()` must degrade on it, never abort. This overwrites page
965    /// 0 with a finalised-but-EMPTY Meta page -- CRC-valid, zero slots,
966    /// exactly the shape `Meta::write`'s error path leaves behind -- and
967    /// confirms the repair still returns `Ok` and finds every leaf. (Task 17
968    /// re-review, R2: an earlier version of `Meta::from_page` panicked on
969    /// this page rather than returning `Err`, which took `recover()` down
970    /// with it. `from_page`'s own unit test pins the decoder; this pins the
971    /// repair path that has to survive whatever the decoder says.)
972    #[test]
973    fn recover_does_not_panic_on_an_empty_but_crc_valid_meta_page() {
974        let d = tempfile::tempdir().unwrap();
975        let n = 500u64;
976        { let mut s = Store::create(d.path(), cfg()).unwrap();
977          s.bulk_load((0..n).map(|i| (i.to_be_bytes().to_vec(), b"v".to_vec()))).unwrap();
978          s.commit().unwrap(); s.checkpoint().unwrap(); }
979
980        let path = d.path().join("data");
981        let mut bytes = std::fs::read(&path).unwrap();
982        {
983            let page0 = &mut bytes[0..PAGE_SIZE];
984            let mut p = crate::page::PageMut::init(page0, PageKind::Meta, 0, 0);
985            // Deliberately no insert_slot -- zero entries, but still
986            // finalised (CRC-valid).
987            p.finalise(0);
988        }
989        std::fs::write(&path, &bytes).unwrap();
990
991        let report = recover(d.path(), cfg())
992            .expect("recover() must degrade on a malformed superblock, not panic");
993        assert!(report.leaves_kept > 0, "the surviving leaves must still be found and repacked");
994        assert_eq!(report.entries_recovered, n, "every leaf's entries survive an empty page 0");
995    }
996
997    #[test]
998    fn a_corrupted_rebuild_never_replaces_the_original_data_file() {
999        let d = tempfile::tempdir().unwrap();
1000        {
1001            let mut s = Store::create(d.path(), cfg()).unwrap();
1002            s.bulk_load((0..2_000u64).map(|i| {
1003                (i.to_be_bytes().to_vec(), i.to_le_bytes().to_vec())
1004            })).unwrap();
1005        }
1006        let data = d.path().join("data");
1007        let original = std::fs::read(&data).unwrap();
1008        let (file, _) = open_file(&data, cfg().io).unwrap();
1009
1010        let result = recover_impl_with_before_publish(&*file, d.path(), cfg(), |fresh| {
1011            let mut bytes = std::fs::read(fresh)?;
1012            bytes[PAGE_SIZE * 2 + 100] ^= 0x80;
1013            std::fs::write(fresh, bytes)?;
1014            Ok(())
1015        });
1016
1017        assert!(result.is_err(), "a rebuild corrupted before publication must be refused");
1018        assert_eq!(std::fs::read(&data).unwrap(), original,
1019                   "the original data file must remain byte-for-byte authoritative");
1020        let s = Store::open(d.path(), cfg()).unwrap();
1021        assert_eq!(s.get(&1999u64.to_be_bytes()).unwrap().as_deref(),
1022                   Some(&1999u64.to_le_bytes()[..]));
1023    }
1024
1025    #[test]
1026    fn an_unreadable_wal_is_an_error_while_no_wal_is_success() {
1027        let damaged = tempfile::tempdir().unwrap();
1028        {
1029            let mut s = Store::create(damaged.path(), cfg()).unwrap();
1030            s.put(b"published", b"yes").unwrap();
1031            s.commit().unwrap();
1032            s.checkpoint().unwrap();
1033            s.put(b"committed-tail", b"must not be stranded").unwrap();
1034            s.commit().unwrap();
1035        }
1036        let wal = damaged.path().join("wal");
1037        std::fs::rename(&wal, damaged.path().join("wal.committed-tail")).unwrap();
1038        std::fs::create_dir(&wal).unwrap();
1039        let result = recover(damaged.path(), cfg());
1040        assert!(result.is_err(), "an unreadable committed log must not be reported as recovered");
1041
1042        let healthy = tempfile::tempdir().unwrap();
1043        {
1044            let mut s = Store::create(healthy.path(), cfg()).unwrap();
1045            s.put(b"published", b"yes").unwrap();
1046            s.commit().unwrap();
1047            s.checkpoint().unwrap();
1048        }
1049        std::fs::remove_file(healthy.path().join("wal")).unwrap();
1050        assert!(recover(healthy.path(), cfg()).is_ok(), "there is nothing to inspect when no log exists");
1051    }
1052}