Skip to content

Commit dc00f15

Browse files
fix(storage): harden B-tree and chain integrity
1 parent 9f87a65 commit dc00f15

10 files changed

Lines changed: 892 additions & 122 deletions

File tree

src/btree/internal.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ pub struct Internal {
2323
pub entries: Vec<InternalEntry>,
2424
}
2525

26+
/// Encoded byte cost of one internal separator plus its slot-directory entry.
27+
pub(crate) fn separator_entry_size(key_len: usize) -> Result<usize> {
28+
if key_len > u16::MAX as usize {
29+
return Err(PagedbError::PayloadTooLarge);
30+
}
31+
2usize
32+
.checked_add(key_len)
33+
.and_then(|size| size.checked_add(8))
34+
.and_then(|size| size.checked_add(2))
35+
.ok_or(PagedbError::PayloadTooLarge)
36+
}
37+
38+
#[must_use]
39+
pub(crate) fn separator_fits(key_len: usize, page_size: usize) -> bool {
40+
separator_entry_size(key_len)
41+
.ok()
42+
.and_then(|entry_size| HEADER_LEN.checked_add(entry_size))
43+
.is_some_and(|needed| needed <= body_capacity(page_size))
44+
}
45+
2646
impl Internal {
2747
pub fn decode(body: &[u8]) -> Result<Self> {
2848
let h: NodeHeader = validate_node_body(body)?;

src/btree/leaf.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,24 @@ impl Leaf {
237237
let slot_dir_bytes = records.len() * 2;
238238
HEADER_LEN + prefix_len + slot_dir_bytes + record_bytes <= cap
239239
}
240+
241+
/// Whether one key/value record fits in an otherwise-empty leaf.
242+
#[must_use]
243+
pub(crate) fn single_record_fits_encoded(
244+
key_len: usize,
245+
value_encoded_size: usize,
246+
page_size: usize,
247+
) -> bool {
248+
if key_len > u16::MAX as usize {
249+
return false;
250+
}
251+
HEADER_LEN
252+
.checked_add(key_len)
253+
.and_then(|size| size.checked_add(2))
254+
.and_then(|size| size.checked_add(2))
255+
.and_then(|size| size.checked_add(value_encoded_size))
256+
.is_some_and(|needed| needed <= body_capacity(page_size))
257+
}
240258
}
241259

242260
impl Default for Leaf {

src/btree/overflow.rs

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
//! `next` pointer sits at byte 4 in the root and byte 0 in a chain page — any
1414
//! chain walk must account for that offset.
1515
16+
use std::collections::BTreeSet;
17+
1618
use crate::errors::PagedbError;
1719
use crate::pager::Pager;
1820
use crate::pager::format::data_page::ENVELOPE_OVERHEAD;
@@ -272,8 +274,12 @@ pub async fn release<V: Vfs>(
272274
if info.refcount <= 1 {
273275
// Collect entire chain.
274276
let mut freed = vec![root_page_id];
277+
let mut seen = BTreeSet::from([root_page_id]);
275278
let mut cur = info.next;
276279
while cur != 0 {
280+
if !seen.insert(cur) {
281+
return Err(PagedbError::overflow_chain_cycle(root_page_id, cur));
282+
}
277283
let guard = pager
278284
.read_main_page(cur, realm_id, PageKind::Overflow)
279285
.await?;
@@ -303,22 +309,46 @@ pub async fn read_chain<V: Vfs>(
303309
root_page_id: u64,
304310
total_len: u64,
305311
) -> Result<Vec<u8>> {
306-
let mut out: Vec<u8> = Vec::with_capacity(usize::try_from(total_len).unwrap_or(0));
312+
let total_len = usize::try_from(total_len)
313+
.ok()
314+
.filter(|len| isize::try_from(*len).is_ok())
315+
.ok_or_else(|| PagedbError::overflow_body_malformed("chain.total_length"))?;
316+
// Durable metadata must not choose an arbitrarily large allocation before
317+
// any chain page has been authenticated. Grow only as bytes are verified.
318+
let mut out: Vec<u8> = Vec::with_capacity(total_len.min(pager.page_size()));
307319

308320
let info = read_root_page(pager, realm_id, root_page_id).await?;
321+
if info.root_data.len() > total_len {
322+
return Err(PagedbError::overflow_body_malformed(
323+
"chain.assembled_length",
324+
));
325+
}
309326
out.extend_from_slice(&info.root_data);
310327

328+
let mut seen = BTreeSet::from([root_page_id]);
311329
let mut next = info.next;
312330
while next != 0 {
331+
if !seen.insert(next) {
332+
return Err(PagedbError::overflow_chain_cycle(root_page_id, next));
333+
}
313334
let guard = pager
314335
.read_main_page(next, realm_id, PageKind::Overflow)
315336
.await?;
316337
let body = guard.body();
317338
let (n, data) = decode_overflow(&body)?;
339+
if out
340+
.len()
341+
.checked_add(data.len())
342+
.is_none_or(|assembled| assembled > total_len)
343+
{
344+
return Err(PagedbError::overflow_body_malformed(
345+
"chain.assembled_length",
346+
));
347+
}
318348
out.extend_from_slice(data);
319349
next = n;
320350
}
321-
if out.len() as u64 != total_len {
351+
if out.len() != total_len {
322352
return Err(PagedbError::overflow_body_malformed(
323353
"chain.assembled_length",
324354
));
@@ -334,9 +364,13 @@ pub async fn collect_chain<V: Vfs>(
334364
root_page_id: u64,
335365
) -> Result<Vec<u64>> {
336366
let mut out = vec![root_page_id];
367+
let mut seen = BTreeSet::from([root_page_id]);
337368
let info = read_root_page(pager, realm_id, root_page_id).await?;
338369
let mut next = info.next;
339370
while next != 0 {
371+
if !seen.insert(next) {
372+
return Err(PagedbError::overflow_chain_cycle(root_page_id, next));
373+
}
340374
let guard = pager
341375
.read_main_page(next, realm_id, PageKind::Overflow)
342376
.await?;
@@ -350,8 +384,38 @@ pub async fn collect_chain<V: Vfs>(
350384

351385
#[cfg(test)]
352386
mod tests {
387+
use std::sync::Arc;
388+
use std::time::Duration;
389+
390+
use crate::crypto::CipherId;
391+
use crate::crypto::kdf::derive_mk;
392+
use crate::errors::CorruptionDetail;
393+
use crate::pager::PagerConfig;
394+
use crate::vfs::memory::MemVfs;
395+
353396
use super::*;
354397

398+
const TEST_PAGE_SIZE: usize = 4096;
399+
const TEST_REALM: RealmId = RealmId::new([0xA4; 16]);
400+
401+
async fn test_pager() -> Arc<Pager<MemVfs>> {
402+
let mk = derive_mk(&[0xA5; 32], &[0u8; 16], 0).unwrap();
403+
let cfg = PagerConfig {
404+
page_size: TEST_PAGE_SIZE,
405+
buffer_pool_pages: 16,
406+
segment_cache_pages: 16,
407+
cipher_id: CipherId::Aes256Gcm,
408+
mk_epoch: 0,
409+
main_db_file_id: [0xB4; 16],
410+
main_db_path: "/main.db".into(),
411+
anchor_budget: 1_000_000,
412+
dek_lru_capacity: 16,
413+
observer_retry_count: 0,
414+
metrics_enabled: true,
415+
};
416+
Arc::new(Pager::open(MemVfs::new(), mk, cfg).await.unwrap())
417+
}
418+
355419
#[test]
356420
fn round_trip_chain_page() {
357421
let mut body = vec![0u8; 4096 - ENVELOPE_OVERHEAD];
@@ -378,4 +442,111 @@ mod tests {
378442
// root: 4096 - 40 - 16 = 4040
379443
assert_eq!(overflow_root_capacity(4096), 4040);
380444
}
445+
446+
async fn cyclic_chain(pager: &Pager<MemVfs>, root_page_id: u64, chain_page_id: u64) {
447+
let mut root_body = vec![0u8; TEST_PAGE_SIZE - ENVELOPE_OVERHEAD];
448+
encode_overflow_root(&mut root_body, 1, chain_page_id, b"").unwrap();
449+
pager
450+
.write_main_page(root_page_id, TEST_REALM, PageKind::OverflowRoot, &root_body)
451+
.await
452+
.unwrap();
453+
454+
let mut chain_body = vec![0u8; TEST_PAGE_SIZE - ENVELOPE_OVERHEAD];
455+
encode_overflow(&mut chain_body, chain_page_id, b"").unwrap();
456+
pager
457+
.write_main_page(chain_page_id, TEST_REALM, PageKind::Overflow, &chain_body)
458+
.await
459+
.unwrap();
460+
}
461+
462+
#[tokio::test(flavor = "current_thread")]
463+
async fn read_chain_rejects_absurd_total_len_without_allocation_panic() {
464+
let pager = test_pager().await;
465+
let root_page_id = 42;
466+
let mut body = vec![0u8; TEST_PAGE_SIZE - ENVELOPE_OVERHEAD];
467+
encode_overflow_root(&mut body, 1, 0, b"small").unwrap();
468+
pager
469+
.write_main_page(root_page_id, TEST_REALM, PageKind::OverflowRoot, &body)
470+
.await
471+
.unwrap();
472+
473+
let error = read_chain(&pager, TEST_REALM, root_page_id, u64::MAX)
474+
.await
475+
.unwrap_err();
476+
assert!(matches!(
477+
error,
478+
PagedbError::Corruption(CorruptionDetail::OverflowBodyMalformed { .. })
479+
));
480+
}
481+
482+
#[tokio::test(flavor = "current_thread")]
483+
async fn read_chain_rejects_more_data_than_declared() {
484+
let pager = test_pager().await;
485+
let root_page_id = 43;
486+
let mut body = vec![0u8; TEST_PAGE_SIZE - ENVELOPE_OVERHEAD];
487+
encode_overflow_root(&mut body, 1, 0, b"too-long").unwrap();
488+
pager
489+
.write_main_page(root_page_id, TEST_REALM, PageKind::OverflowRoot, &body)
490+
.await
491+
.unwrap();
492+
493+
let error = read_chain(&pager, TEST_REALM, root_page_id, 1)
494+
.await
495+
.unwrap_err();
496+
assert!(matches!(
497+
error,
498+
PagedbError::Corruption(CorruptionDetail::OverflowBodyMalformed { .. })
499+
));
500+
}
501+
502+
#[tokio::test(flavor = "current_thread")]
503+
async fn read_chain_rejects_cycle_without_hanging() {
504+
let pager = test_pager().await;
505+
cyclic_chain(&pager, 44, 45).await;
506+
let error = tokio::time::timeout(
507+
Duration::from_secs(1),
508+
read_chain(&pager, TEST_REALM, 44, 0),
509+
)
510+
.await
511+
.expect("cycle detection should return before the timeout")
512+
.unwrap_err();
513+
assert!(matches!(
514+
error,
515+
PagedbError::Corruption(CorruptionDetail::OverflowChainCycle { .. })
516+
));
517+
}
518+
519+
#[tokio::test(flavor = "current_thread")]
520+
async fn release_rejects_cycle_without_hanging() {
521+
let pager = test_pager().await;
522+
cyclic_chain(&pager, 46, 47).await;
523+
let result =
524+
tokio::time::timeout(Duration::from_secs(1), release(&pager, TEST_REALM, 46, 48))
525+
.await
526+
.expect("cycle detection should return before the timeout");
527+
let Err(error) = result else {
528+
panic!("overflow release cycles must not be accepted");
529+
};
530+
assert!(matches!(
531+
error,
532+
PagedbError::Corruption(CorruptionDetail::OverflowChainCycle { .. })
533+
));
534+
}
535+
536+
#[tokio::test(flavor = "current_thread")]
537+
async fn collect_chain_rejects_cycle_without_hanging() {
538+
let pager = test_pager().await;
539+
cyclic_chain(&pager, 49, 50).await;
540+
let error = tokio::time::timeout(
541+
Duration::from_secs(1),
542+
collect_chain(&pager, TEST_REALM, 49),
543+
)
544+
.await
545+
.expect("cycle detection should return before the timeout")
546+
.expect_err("overflow collect cycles must not be accepted");
547+
assert!(matches!(
548+
error,
549+
PagedbError::Corruption(CorruptionDetail::OverflowChainCycle { .. })
550+
));
551+
}
381552
}

src/btree/tree/bulk.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::Result;
66
use crate::errors::PagedbError;
77
use crate::vfs::Vfs;
88

9+
use crate::btree::internal;
910
use crate::btree::leaf::{Leaf, LeafValue};
1011
use crate::btree::node;
1112
use crate::btree::overflow;
@@ -34,6 +35,18 @@ impl<V: Vfs> BTree<V> {
3435
return Ok(());
3536
}
3637

38+
for pair in pairs.windows(2) {
39+
if pair[0].0 >= pair[1].0 {
40+
return Err(PagedbError::Io(std::io::Error::new(
41+
std::io::ErrorKind::InvalidInput,
42+
"bulk_load input keys must be strictly increasing",
43+
)));
44+
}
45+
}
46+
for (key, value) in &pairs {
47+
self.validate_insert_record_fits(key, value)?;
48+
}
49+
3750
// Resolve each value to its stored form, spilling values past the inline
3851
// threshold to overflow chains (same threshold as `put`). Inlining an
3952
// oversized value would exceed leaf capacity and fail the encode.
@@ -130,13 +143,19 @@ impl<V: Vfs> BTree<V> {
130143
let mut used = node::HEADER_LEN;
131144
for item in remaining.iter().skip(1) {
132145
let sep_key = &item.1;
133-
let entry_bytes = 2 + sep_key.len() + 8 + 2; // record + slot
134-
if used + entry_bytes > body_cap {
146+
let entry_bytes = internal::separator_entry_size(sep_key.len())?;
147+
if used
148+
.checked_add(entry_bytes)
149+
.is_none_or(|needed| needed > body_cap)
150+
{
135151
break;
136152
}
137153
used += entry_bytes;
138154
count += 1;
139155
}
156+
if count == 1 && remaining.len() > 1 {
157+
return Err(PagedbError::PayloadTooLarge);
158+
}
140159
let chunk = &remaining[..count];
141160
remaining = &remaining[count..];
142161

0 commit comments

Comments
 (0)