Skip to content

Commit 3bdc2be

Browse files
committed
fix(recovery): make page reachability walks authoritative against reserved pages
Introduce a canonical page_space module defining the reserved page-id range (headers + apply-journal) and use it everywhere a raw `< 4` comparison stood in for it. collect_all_page_ids and its overflow-chain walk now fail closed on any reference into reserved space or a chain cycle instead of silently truncating, and deep-walk's diagnostic pass gains the same structural coverage (leaf overflow chains, decode failures) it was previously missing. Commit now panics rather than proceeding if the post-commit reachability walk itself errors, since a short reachable set would otherwise pass the freed-page check vacuously.
1 parent 29c549c commit 3bdc2be

7 files changed

Lines changed: 570 additions & 182 deletions

File tree

src/btree/leaf.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use crate::Result;
44
use crate::errors::PagedbError;
5+
use crate::pager::page_space::is_reserved;
56

67
use super::node::{
78
HEADER_LEN, NodeHeader, NodeKind, OVERFLOW_SENTINEL, body_capacity, read_u16_le, read_u64_le,
@@ -157,7 +158,7 @@ impl Leaf {
157158
root_page_id,
158159
} => {
159160
assert!(
160-
*root_page_id >= 4,
161+
!is_reserved(*root_page_id),
161162
"encoding leaf record with wild overflow root_page_id={root_page_id} \
162163
(reserved page — use-after-free / stale value)"
163164
);

src/btree/tree/maintenance.rs

Lines changed: 130 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use crate::Result;
44
use crate::errors::PagedbError;
55
use crate::pager::format::page_kind::PageKind;
6+
use crate::pager::page_space::is_reserved;
67
use crate::vfs::Vfs;
78

89
use crate::btree::leaf::{Leaf, LeafValue};
@@ -106,21 +107,39 @@ impl<V: Vfs> BTree<V> {
106107
/// Collect all page IDs reachable from this tree's root (internal nodes,
107108
/// leaves, overflow chains) into `out`. Used by the deep-walk integrity
108109
/// checker to identify orphan pages.
109-
#[allow(clippy::too_many_lines)]
110+
///
111+
/// Authoritative, not best-effort: `Ok(())` means every reachable node and
112+
/// overflow page authenticated as its own kind, decoded structurally, and
113+
/// pointed only at allocatable pages. Anything else is a corruption error,
114+
/// because a partial set that its caller cannot distinguish from a complete
115+
/// one turns every live page into a false orphan report — and, worse, reads
116+
/// as a clean bill of health.
110117
pub async fn collect_all_page_ids(
111118
&self,
112119
out: &mut std::collections::BTreeSet<u64>,
113120
) -> Result<()> {
114121
if self.root_page_id == 0 {
115122
return Ok(());
116123
}
117-
let mut stack: Vec<u64> = vec![self.root_page_id];
124+
// Track traversal separately from `out`. `out` is the caller's
125+
// accumulator across every tree in the database and arrives pre-seeded
126+
// with the reserved pages, so doubling it as the visited set would let
127+
// whatever the caller happened to put in it silently truncate this
128+
// walk — the walk's own progress must not depend on that.
129+
let mut visited = std::collections::BTreeSet::new();
130+
let mut stack: Vec<(u64, u64)> = vec![(0, self.root_page_id)];
118131

119-
while let Some(page_id) = stack.pop() {
120-
if !out.insert(page_id) {
121-
// Already visited.
132+
while let Some((parent_page_id, page_id)) = stack.pop() {
133+
if is_reserved(page_id) {
134+
return Err(PagedbError::reserved_page_referenced(
135+
parent_page_id,
136+
page_id,
137+
));
138+
}
139+
if !visited.insert(page_id) {
122140
continue;
123141
}
142+
out.insert(page_id);
124143

125144
let (guard, kind) = self.read_node_guard(page_id).await?;
126145
match kind {
@@ -137,19 +156,21 @@ impl<V: Vfs> BTree<V> {
137156
drop(guard);
138157

139158
for overflow_root in overflow_roots {
140-
self.collect_overflow_chain(overflow_root, out).await?;
159+
self.collect_overflow_chain(page_id, overflow_root, &mut visited, out)
160+
.await?;
141161
}
142162
}
143163
NodeKind::Internal => {
144164
let internal = internal::Internal::decode(guard.body_ref())?;
145165
drop(guard);
146166

167+
// A zero child id is an absent slot, not a pointer.
147168
if internal.leftmost_child != 0 {
148-
stack.push(internal.leftmost_child);
169+
stack.push((page_id, internal.leftmost_child));
149170
}
150171
for entry in &internal.entries {
151172
if entry.right_child != 0 {
152-
stack.push(entry.right_child);
173+
stack.push((page_id, entry.right_child));
153174
}
154175
}
155176
}
@@ -158,37 +179,55 @@ impl<V: Vfs> BTree<V> {
158179
Ok(())
159180
}
160181

161-
/// Walk an overflow chain starting at `root` and insert all page IDs into
162-
/// `out`. This is authoritative for reachability collection, so malformed
163-
/// pages or missing links propagate as corruption instead of being omitted.
182+
/// Walk the overflow chain rooted at `root` — referenced by leaf
183+
/// `leaf_page_id` — and insert every page ID into `out`.
184+
///
185+
/// Authoritative on the same terms as [`Self::collect_all_page_ids`]. A
186+
/// repeated page is reported as a cycle rather than treated as the end of
187+
/// the chain: the two are indistinguishable to a caller that just stops
188+
/// walking, and only one of them is a healthy tree.
189+
///
190+
/// `visited` is the traversal's tree-wide page set. Overflow pages and node
191+
/// pages draw from the same id space, so one set both deduplicates a chain
192+
/// shared by several refcounting leaves and catches a chain that loops back
193+
/// into the node graph.
164194
async fn collect_overflow_chain(
165195
&self,
196+
leaf_page_id: u64,
166197
root: u64,
198+
visited: &mut std::collections::BTreeSet<u64>,
167199
out: &mut std::collections::BTreeSet<u64>,
168200
) -> Result<()> {
169-
if root == 0 || !out.insert(root) {
201+
// Unlike an internal child slot or a chain terminator, zero is not a
202+
// valid overflow root: an `Overflow` leaf value always owns at least
203+
// its root page.
204+
if is_reserved(root) {
205+
return Err(PagedbError::reserved_page_referenced(leaf_page_id, root));
206+
}
207+
if !visited.insert(root) {
208+
// Already walked — a value whose chain is shared by refcount.
170209
return Ok(());
171210
}
211+
out.insert(root);
172212

173-
let mut seen = std::collections::BTreeSet::new();
174-
seen.insert(root);
175213
let info = overflow::read_root_page(&self.pager, self.realm_id, root).await?;
176214
let mut chain_id = info.next;
177215

178216
while chain_id != 0 {
179-
if !seen.insert(chain_id) {
180-
return Err(PagedbError::corruption(
181-
crate::errors::CorruptionDetail::HeaderUnverifiable,
182-
));
217+
if is_reserved(chain_id) {
218+
return Err(PagedbError::reserved_page_referenced(root, chain_id));
219+
}
220+
if !visited.insert(chain_id) {
221+
return Err(PagedbError::overflow_chain_cycle(root, chain_id));
183222
}
223+
out.insert(chain_id);
184224

185225
let guard = self
186226
.pager
187227
.read_main_page(chain_id, self.realm_id, PageKind::Overflow)
188228
.await?;
189229
let body = guard.body();
190230
let (next, _) = overflow::decode_overflow(&body)?;
191-
out.insert(chain_id);
192231
chain_id = next;
193232
}
194233

@@ -208,7 +247,7 @@ impl<V: Vfs> BTree<V> {
208247
let mut stack: Vec<(u64, u64)> = vec![(0, self.root_page_id)];
209248
let mut seen = std::collections::BTreeSet::new();
210249
while let Some((parent, page_id)) = stack.pop() {
211-
if page_id < 4 {
250+
if is_reserved(page_id) {
212251
return Some(format!(
213252
"internal {parent} -> RESERVED child page {page_id}"
214253
));
@@ -234,56 +273,12 @@ impl<V: Vfs> BTree<V> {
234273
return Some(format!("leaf {page_id} (parent {parent}) decode failed"));
235274
};
236275
for (k, v) in &leaf.records {
237-
if let LeafValue::Overflow { root_page_id, .. } = v {
238-
// Walk the FULL overflow chain: every page and every
239-
// next-pointer must be a real page (>= 4). A reserved or
240-
// unreadable link means a chain page was freed/reused
241-
// while still linked (use-after-free).
242-
let mut chain = *root_page_id;
243-
let mut first = true;
244-
let mut chain_seen = std::collections::BTreeSet::new();
245-
while chain != 0 {
246-
if chain < 4 {
247-
return Some(format!(
248-
"leaf {page_id} (parent {parent}) key {:02x?} overflow chain \
249-
-> RESERVED page {chain} (use-after-free)",
250-
&k[..k.len().min(8)]
251-
));
252-
}
253-
if !chain_seen.insert(chain) {
254-
return Some(format!(
255-
"leaf {page_id} (parent {parent}) overflow chain CYCLE at {chain}"
256-
));
257-
}
258-
// root (`OverflowRoot`): next after refcount[4];
259-
// chain page (`Overflow`): next at byte 0.
260-
let (kind, next_off, what) = if first {
261-
(PageKind::OverflowRoot, 4usize, "root")
262-
} else {
263-
(PageKind::Overflow, 0usize, "chain page")
264-
};
265-
first = false;
266-
let cg = match self
267-
.pager
268-
.read_main_page(chain, self.realm_id, kind)
269-
.await
270-
{
271-
Ok(cg) => cg,
272-
Err(e) => {
273-
return Some(format!(
274-
"leaf {page_id} (parent {parent}) overflow {what} {chain} \
275-
UNREADABLE ({e:?}) — freed/reused"
276-
));
277-
}
278-
};
279-
let cbody = cg.body();
280-
if cbody.len() < next_off + 8 {
281-
break;
282-
}
283-
let mut b = [0u8; 8];
284-
b.copy_from_slice(&cbody[next_off..next_off + 8]);
285-
chain = u64::from_le_bytes(b);
286-
}
276+
if let LeafValue::Overflow { root_page_id, .. } = v
277+
&& let Some(desc) = self
278+
.find_dangling_in_overflow(page_id, parent, k, *root_page_id)
279+
.await
280+
{
281+
return Some(desc);
287282
}
288283
}
289284
} else {
@@ -305,4 +300,69 @@ impl<V: Vfs> BTree<V> {
305300
}
306301
None
307302
}
303+
304+
/// Walk the full overflow chain rooted at `root` — held by key `key` in
305+
/// leaf `page_id` — and describe the first anomaly, if any.
306+
///
307+
/// Every page and every next-pointer must be allocatable. A reserved or
308+
/// unreadable link means a chain page was freed and reused while still
309+
/// linked. Zero terminates a chain but can never *be* one: an `Overflow`
310+
/// value owns at least its root page.
311+
async fn find_dangling_in_overflow(
312+
&self,
313+
page_id: u64,
314+
parent: u64,
315+
key: &[u8],
316+
root: u64,
317+
) -> Option<String> {
318+
let key_prefix = &key[..key.len().min(8)];
319+
if root == 0 {
320+
return Some(format!(
321+
"leaf {page_id} (parent {parent}) key {key_prefix:02x?} overflow value has no \
322+
root page"
323+
));
324+
}
325+
326+
let mut chain = root;
327+
let mut first = true;
328+
let mut chain_seen = std::collections::BTreeSet::new();
329+
while chain != 0 {
330+
if is_reserved(chain) {
331+
return Some(format!(
332+
"leaf {page_id} (parent {parent}) key {key_prefix:02x?} overflow chain -> \
333+
RESERVED page {chain} (use-after-free)"
334+
));
335+
}
336+
if !chain_seen.insert(chain) {
337+
return Some(format!(
338+
"leaf {page_id} (parent {parent}) overflow chain CYCLE at {chain}"
339+
));
340+
}
341+
// root (`OverflowRoot`): next after refcount[4];
342+
// chain page (`Overflow`): next at byte 0.
343+
let (kind, next_off, what) = if first {
344+
(PageKind::OverflowRoot, 4usize, "root")
345+
} else {
346+
(PageKind::Overflow, 0usize, "chain page")
347+
};
348+
first = false;
349+
let guard = match self.pager.read_main_page(chain, self.realm_id, kind).await {
350+
Ok(guard) => guard,
351+
Err(e) => {
352+
return Some(format!(
353+
"leaf {page_id} (parent {parent}) overflow {what} {chain} UNREADABLE \
354+
({e:?}) — freed/reused"
355+
));
356+
}
357+
};
358+
let body = guard.body();
359+
if body.len() < next_off + 8 {
360+
break;
361+
}
362+
let mut next = [0u8; 8];
363+
next.copy_from_slice(&body[next_off..next_off + 8]);
364+
chain = u64::from_le_bytes(next);
365+
}
366+
None
367+
}
308368
}

src/errors.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,18 @@ pub enum CorruptionDetail {
221221
},
222222
/// main.db A/B header HK-MAC failed on both copies.
223223
HeaderUnverifiable,
224+
/// A live B+ tree or overflow pointer targets a reserved page (0..=3).
225+
/// Pages 0 and 1 are the A/B structural headers and 2..=3 the apply-journal;
226+
/// no live tree pointer may reach them, so this is a wild pointer or a
227+
/// use-after-free that handed a reserved page back to an allocation.
228+
ReservedPageReferenced {
229+
parent_page_id: u64,
230+
child_page_id: u64,
231+
},
232+
/// An overflow chain revisited a page it had already walked, so the chain
233+
/// has no terminator. Distinct from a truncated chain: the links
234+
/// authenticate, they just form a loop.
235+
OverflowChainCycle { root_page_id: u64, page_id: u64 },
224236
}
225237

226238
/// Quota failure reason, distinguishing which resource was exhausted.
@@ -267,6 +279,24 @@ impl PagedbError {
267279
Self::Corruption(CorruptionDetail::CatalogRowInvalid { field })
268280
}
269281

282+
/// Canonical constructor for a live tree pointer into a reserved page.
283+
#[must_use]
284+
pub const fn reserved_page_referenced(parent_page_id: u64, child_page_id: u64) -> Self {
285+
Self::Corruption(CorruptionDetail::ReservedPageReferenced {
286+
parent_page_id,
287+
child_page_id,
288+
})
289+
}
290+
291+
/// Canonical constructor for a cyclic overflow chain.
292+
#[must_use]
293+
pub const fn overflow_chain_cycle(root_page_id: u64, page_id: u64) -> Self {
294+
Self::Corruption(CorruptionDetail::OverflowChainCycle {
295+
root_page_id,
296+
page_id,
297+
})
298+
}
299+
270300
/// Canonical constructor for an incremental snapshot that cannot be
271301
/// applied to this handle's current identity or reader-visible state.
272302
#[must_use]

src/pager/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ pub mod core;
55
pub mod format;
66
pub mod freelist;
77
pub mod header;
8+
pub mod page_space;
89

910
pub use cache::PageCache;
1011
pub use core::{FileKey, PageGuard, Pager, PagerConfig};
1112
pub use format::{data_page, page_kind::PageKind, segment_footer, structural_header};
13+
pub use page_space::{FIRST_ALLOCATABLE_PAGE_ID, is_reserved};

src/pager/page_space.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//! Layout of main.db's page-id space.
2+
//!
3+
//! Page ids 0..=3 are reserved and never handed out by the allocator:
4+
//!
5+
//! | id | owner |
6+
//! | -- | ----- |
7+
//! | 0 | structural header, copy A |
8+
//! | 1 | structural header, copy B |
9+
//! | 2 | apply-journal root |
10+
//! | 3 | apply-journal spare |
11+
//!
12+
//! Everything from [`FIRST_ALLOCATABLE_PAGE_ID`] up is tree, overflow, or
13+
//! free-list territory. The distinction matters beyond bookkeeping: the
14+
//! structural headers use a different envelope (HK-MAC, cleartext) than data
15+
//! pages, so a live tree pointer that reaches one is a wild pointer or a
16+
//! use-after-free that recycled a reserved id — never a benign condition.
17+
18+
/// First page id the allocator may hand out. Ids below this are reserved.
19+
pub const FIRST_ALLOCATABLE_PAGE_ID: u64 = 4;
20+
21+
/// Whether `page_id` names a reserved page that no live tree pointer,
22+
/// overflow link, or free-list entry may reference.
23+
#[must_use]
24+
pub const fn is_reserved(page_id: u64) -> bool {
25+
page_id < FIRST_ALLOCATABLE_PAGE_ID
26+
}

0 commit comments

Comments
 (0)