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}