Skip to content

Commit b4676c8

Browse files
committed
test: add property-based coverage for on-disk decoders and pointer walks
Add proptest as a dev-dependency and generate adversarial input for the decoders that turn already-authenticated bytes into structural claims (B+ tree nodes, extent index entries, catalog rows, commit-history rows, journal records, snapshot artifacts, page envelopes, native path resolution) plus the pointer graphs fed to the B+ tree and free-list walks. Hand-written regressions only cover shapes someone already imagined; these properties assert every case returns a typed error or a value, never a panic, and cover the rest of the space.
1 parent fcdb9cc commit b4676c8

12 files changed

Lines changed: 2460 additions & 0 deletions

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
142142
tempfile = "3"
143143
aes-gcm = "0.10"
144144
fluxbench = "0.1"
145+
# Generated adversarial input for the decoders and the structural walks. Runs
146+
# inside the normal `cargo nextest` suite on every Tier-1 target, which is why
147+
# it is proptest and not a nightly-only libFuzzer harness. Dev-only: it must
148+
# never reach the published surface or the default feature set.
149+
proptest = "1.11"
145150

146151
[[bin]]
147152
name = "pagedb-fsck"

src/segment/authenticated_metadata/tests.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,123 @@ async fn missing_persisted_epoch_key_has_deterministic_error() {
471471
})
472472
));
473473
}
474+
475+
/// Generated adversarial input for the extent-index page decoder.
476+
///
477+
/// An index page body is a run of fixed-width entries whose `start_page_id`,
478+
/// `page_count`, ordering, reserved bytes, and zero-tail placement are all
479+
/// structural claims made by authenticated bytes. The validator is reached only
480+
/// through a `pub(crate)` path that needs a real pager and a real segment file,
481+
/// so it has no integration surface — and widening its visibility to give it
482+
/// one would enlarge the published API to serve a test. The property lives
483+
/// beside the code instead.
484+
mod generated {
485+
use proptest::prelude::*;
486+
487+
use super::super::validate_index_entries;
488+
use crate::segment::types::{EXTENT_INDEX_ENTRY_LEN, ExtentIndexEntry};
489+
490+
fn cases() -> u32 {
491+
std::env::var("PAGEDB_PROPTEST_CASES")
492+
.ok()
493+
.and_then(|raw| raw.parse().ok())
494+
.unwrap_or(32)
495+
}
496+
497+
fn config() -> ProptestConfig {
498+
ProptestConfig {
499+
cases: cases(),
500+
failure_persistence: None,
501+
..ProptestConfig::default()
502+
}
503+
}
504+
505+
/// Drive one generated page body through the validator the way
506+
/// `decode_extent_index` does, carrying the same cross-page state.
507+
fn validate(page_body: &[u8], index_start_page: u64) -> crate::Result<Vec<ExtentIndexEntry>> {
508+
let mut previous_end = 0u64;
509+
let mut unused_tail = false;
510+
let mut entries = Vec::new();
511+
validate_index_entries(
512+
page_body,
513+
index_start_page,
514+
&mut previous_end,
515+
&mut unused_tail,
516+
&mut entries,
517+
)?;
518+
Ok(entries)
519+
}
520+
521+
proptest! {
522+
#![proptest_config(config())]
523+
524+
/// Wholly random bodies, at lengths that straddle the entry stride so
525+
/// the ragged-remainder path is reached from both sides.
526+
#[test]
527+
fn random_index_page_bodies_never_panic(
528+
body in prop::collection::vec(any::<u8>(), 0..=(EXTENT_INDEX_ENTRY_LEN * 4 + 7)),
529+
index_start_page in any::<u64>(),
530+
) {
531+
let _ = validate(&body, index_start_page);
532+
}
533+
534+
/// Structurally plausible entries: correct stride and zero reserved
535+
/// bytes, with the two fields that bound a page range left hostile.
536+
#[test]
537+
fn plausible_entries_are_accepted_only_when_every_claim_holds(
538+
entries in prop::collection::vec(
539+
(any::<u64>(), any::<u32>(), any::<u64>()),
540+
0..=4,
541+
),
542+
trailing_zero_entries in 0usize..=2,
543+
index_start_page in prop_oneof![Just(0u64), Just(1u64), 1u64..64, any::<u64>()],
544+
) {
545+
let mut body = Vec::new();
546+
for &(start_page_id, page_count, logical_bytes) in &entries {
547+
body.extend_from_slice(&ExtentIndexEntry {
548+
start_page_id,
549+
page_count,
550+
logical_bytes,
551+
}.encode());
552+
}
553+
for _ in 0..trailing_zero_entries {
554+
body.extend_from_slice(&[0u8; EXTENT_INDEX_ENTRY_LEN]);
555+
}
556+
557+
let Ok(decoded) = validate(&body, index_start_page) else {
558+
return Ok(());
559+
};
560+
// Acceptance is a promise about the whole run, not one entry:
561+
// extents are non-empty, in range, and non-overlapping in order.
562+
let mut previous_end = 0u64;
563+
for entry in &decoded {
564+
prop_assert!(entry.start_page_id != 0);
565+
prop_assert!(entry.page_count != 0);
566+
prop_assert!(entry.start_page_id >= previous_end);
567+
let end = entry
568+
.start_page_id
569+
.checked_add(u64::from(entry.page_count))
570+
.expect("an accepted extent cannot overflow its own end");
571+
prop_assert!(end <= index_start_page);
572+
previous_end = end;
573+
}
574+
}
575+
576+
/// A zero entry marks the unused tail. Anything after it is a page that
577+
/// two different writers could have produced, so it must be refused.
578+
#[test]
579+
fn a_live_entry_after_the_zero_tail_is_refused(
580+
leading_zero_entries in 1usize..=3,
581+
start_page_id in 1u64..64,
582+
page_count in 1u32..8,
583+
) {
584+
let mut body = vec![0u8; leading_zero_entries * EXTENT_INDEX_ENTRY_LEN];
585+
body.extend_from_slice(&ExtentIndexEntry {
586+
start_page_id,
587+
page_count,
588+
logical_bytes: 0,
589+
}.encode());
590+
prop_assert!(validate(&body, u64::MAX).is_err());
591+
}
592+
}
593+
}

src/txn/db/core.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,78 @@ pub(crate) fn decode_commit_meta(bytes: &[u8]) -> Result<CommitHistoryMeta> {
503503
unix_seconds: read_u64(bytes, 32),
504504
})
505505
}
506+
507+
/// Generated adversarial input for the commit-history row decoder.
508+
///
509+
/// The row is reached through a `pub(crate)` path, so it has no integration
510+
/// surface to test from — widening its visibility to make it reachable would
511+
/// enlarge the published API to serve a test, which is the wrong trade. The
512+
/// property lives beside the code instead.
513+
#[cfg(test)]
514+
mod tests {
515+
use proptest::prelude::*;
516+
517+
use super::{CommitHistoryMeta, decode_commit_meta, encode_commit_meta};
518+
519+
const META_LEN: usize = 40;
520+
521+
fn cases() -> u32 {
522+
std::env::var("PAGEDB_PROPTEST_CASES")
523+
.ok()
524+
.and_then(|raw| raw.parse().ok())
525+
.unwrap_or(32)
526+
}
527+
528+
fn config() -> ProptestConfig {
529+
ProptestConfig {
530+
cases: cases(),
531+
failure_persistence: None,
532+
..ProptestConfig::default()
533+
}
534+
}
535+
536+
proptest! {
537+
#![proptest_config(config())]
538+
539+
/// A short row must be declined, never sliced. Trailing bytes are
540+
/// tolerated by design (the row is a fixed prefix), so acceptance is
541+
/// exactly "at least the fixed width".
542+
#[test]
543+
fn random_bytes_are_accepted_only_at_or_above_the_fixed_width(
544+
bytes in prop::collection::vec(any::<u8>(), 0..=(META_LEN + 16)),
545+
) {
546+
match decode_commit_meta(&bytes) {
547+
Ok(_) => prop_assert!(bytes.len() >= META_LEN),
548+
Err(_) => prop_assert!(bytes.len() < META_LEN),
549+
}
550+
}
551+
552+
#[test]
553+
fn encoded_rows_round_trip_for_every_field_value(
554+
active_root_page_id in any::<u64>(),
555+
catalog_root_page_id in any::<u64>(),
556+
free_list_root_page_id in any::<u64>(),
557+
next_page_id in any::<u64>(),
558+
unix_seconds in any::<u64>(),
559+
trailing in prop::collection::vec(any::<u8>(), 0..=16),
560+
) {
561+
let meta = CommitHistoryMeta {
562+
active_root_page_id,
563+
catalog_root_page_id,
564+
free_list_root_page_id,
565+
next_page_id,
566+
unix_seconds,
567+
};
568+
let mut encoded = encode_commit_meta(&meta);
569+
// A longer row is what a future format revision looks like from
570+
// here; the fixed prefix must still decode unchanged.
571+
encoded.extend_from_slice(&trailing);
572+
let decoded = decode_commit_meta(&encoded).unwrap();
573+
prop_assert_eq!(decoded.active_root_page_id, active_root_page_id);
574+
prop_assert_eq!(decoded.catalog_root_page_id, catalog_root_page_id);
575+
prop_assert_eq!(decoded.free_list_root_page_id, free_list_root_page_id);
576+
prop_assert_eq!(decoded.next_page_id, next_page_id);
577+
prop_assert_eq!(decoded.unix_seconds, unix_seconds);
578+
}
579+
}
580+
}

src/vfs/traits.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,4 +875,89 @@ mod tests {
875875
write_all_at(&mut file, 11, b"page").await.unwrap();
876876
assert_eq!(file.calls(), vec![(11, 4), (13, 2)]);
877877
}
878+
879+
/// Generated logical paths. A logical path is embedder-supplied and is the
880+
/// only thing standing between a segment name and the filesystem, so the
881+
/// interesting inputs are the ones that *look* like ordinary names on one
882+
/// platform and re-root the path on another. Enumerating those by hand is
883+
/// how a platform gets missed; generating them is not.
884+
mod generated {
885+
use proptest::prelude::*;
886+
887+
use super::super::{canonical_native_path, resolve_native_path};
888+
889+
fn cases() -> u32 {
890+
std::env::var("PAGEDB_PROPTEST_CASES")
891+
.ok()
892+
.and_then(|raw| raw.parse().ok())
893+
.unwrap_or(32)
894+
}
895+
896+
fn config() -> ProptestConfig {
897+
ProptestConfig {
898+
cases: cases(),
899+
failure_persistence: None,
900+
..ProptestConfig::default()
901+
}
902+
}
903+
904+
/// Characters chosen so separators, traversal, drive letters, UNC
905+
/// prefixes and NUL are all common draws rather than lottery wins.
906+
fn path_char() -> impl Strategy<Value = char> {
907+
prop_oneof![
908+
4 => prop::sample::select(vec![
909+
'/', '\\', '.', ':', 'a', 'b', 'C', '0', ' ', '\0',
910+
]),
911+
1 => any::<char>(),
912+
]
913+
}
914+
915+
proptest! {
916+
#![proptest_config(config())]
917+
918+
#[test]
919+
fn canonical_native_path_never_panics_and_never_re_roots(
920+
path in prop::collection::vec(path_char(), 0..=24)
921+
.prop_map(|chars| chars.into_iter().collect::<String>()),
922+
) {
923+
let Ok(canonical) = canonical_native_path(&path) else {
924+
return Ok(());
925+
};
926+
prop_assert!(canonical.starts_with('/'), "{canonical:?} is not rooted");
927+
prop_assert!(
928+
!canonical.contains(':'),
929+
"{canonical:?} kept a drive-letter separator"
930+
);
931+
prop_assert!(
932+
!canonical.contains('\\'),
933+
"{canonical:?} kept a platform separator"
934+
);
935+
for component in canonical.trim_start_matches('/').split('/') {
936+
if component.is_empty() {
937+
continue;
938+
}
939+
prop_assert_ne!(component, ".");
940+
prop_assert_ne!(component, "..");
941+
}
942+
}
943+
944+
/// Accepting a path must also mean it resolves beneath the root:
945+
/// canonicalisation is only useful if the resolved path cannot
946+
/// escape.
947+
#[test]
948+
fn accepted_paths_resolve_beneath_the_root(
949+
path in prop::collection::vec(path_char(), 0..=24)
950+
.prop_map(|chars| chars.into_iter().collect::<String>()),
951+
) {
952+
let root = std::path::Path::new("/pagedb-root");
953+
let Ok(resolved) = resolve_native_path(root, &path) else {
954+
return Ok(());
955+
};
956+
prop_assert!(
957+
resolved.starts_with(root),
958+
"{resolved:?} escaped {root:?} (from {path:?})"
959+
);
960+
}
961+
}
962+
}
878963
}

0 commit comments

Comments
 (0)