Initial commit: ClawSync v0.1.0
8-crate pure-Rust workspace for revision-aware HDF5 sync. ## Crates - clawhdf5-onion: ClawOnion VFD — page-level versioned HDF5 storage, binary format, writer/reader, branch DAG, GC, snapshots, provenance - clawsync-core: BLAKE3, xxHash3, FastCDC (+ SIMD NEON), zstd/lz4 - clawsync-onion: IBLT sketch, Merkle tree differ, packet differ/merger, ClawSyncManifest, SyncSelector - clawsync-hdf5: dataset-level manifest, differ, patcher, wire payload reconstruction (apply_received_payloads) - clawsync-transport: TCP, QUIC (quinn 0.11/TLS 1.3), SyncPeer abstraction, length-prefixed rkyv wire protocol (21 SyncMessage variants) - clawsync-agent: OnionMemory, SyncScheduler, TcpSyncBackend, PeerCapabilities negotiation - clawsync-fs: CDC-based delta sync for any file type; FsSyncClient/Server, W=16 pipelining, atomic writes - clawsync-cli: push/pull/serve/hdf5-sync/serve-hdf5/sync/serve-fs + all local management commands; --quic on all network commands ## Key features - IBLT pre-flight: O(revision count) vs rsync's O(file size) - W=16 sliding-window push: 13–15x speedup over stop-and-wait at WAN RTT - Dataset-granular HDF5 sync: only modified datasets transferred - CDC delta for any file type: insertion-stable chunk boundaries - Full revision DAG: branch, merge, rollback, export, snapshot, GC - QUIC transport: TLS 1.3, per-message streams via quinn 0.11 ## Tests ~573 passing (default features); ~589 with --features simd-cdc ## Performance (Apple Silicon) - Reconstruct rev=100: 68 µs (target ≤ 1 ms) - BLAKE3 Rayon 1 MB: 10.3 GiB/s (target ≥ 5 GB/s) - GC 500 revisions: 20.6 µs (target ≤ 2 s) - W=16 vs W=1 at 5 ms RTT: 14.8x speedup - No-op pre-flight at 16 MB: 4 ms vs rsync 35 ms (7.8x) Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
//! Golden-file test for ClawOnion v1 binary format.
|
||||
//!
|
||||
//! Asserts that a minimal `.onion` file serialises to exact byte values at
|
||||
//! every spec-defined offset. This test exists solely to catch **silent
|
||||
//! `zerocopy` layout regressions** — if any struct field shifts by even one
|
||||
//! byte, at least one assertion below will fire.
|
||||
//!
|
||||
//! The test vector matches §13 of `CLAWONION_SPEC.md`:
|
||||
//! - 1 revision (rev 0) on branch `main`
|
||||
//! - 1 page at `h5_offset = 0`, 4 096 bytes, filled with `0xAB`
|
||||
//! - No revision annotation
|
||||
//!
|
||||
//! All offsets are little-endian (LE) as per the spec.
|
||||
|
||||
use clawhdf5_onion::format::Codec;
|
||||
use clawhdf5_onion::writer::OnionFile;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Layout constants (must match CLAWONION_SPEC.md §2 + §3)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEADER_SIZE: usize = 128;
|
||||
const REV_ENTRY_SIZE: usize = 112;
|
||||
const BRANCH_ENTRY_SIZE: usize = 40;
|
||||
const PAGE_ENTRY_SIZE: usize = 32;
|
||||
const PAGE_SIZE: u32 = 4096;
|
||||
|
||||
const INDEX_OFFSET: usize = HEADER_SIZE; // 128
|
||||
const BRANCH_OFFSET: usize = INDEX_OFFSET + REV_ENTRY_SIZE; // 240
|
||||
const PT_OFFSET: usize = BRANCH_OFFSET + BRANCH_ENTRY_SIZE; // 280
|
||||
const PD_OFFSET: usize = PT_OFFSET + PAGE_ENTRY_SIZE; // 312
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn le64(v: u64) -> [u8; 8] { v.to_le_bytes() }
|
||||
fn le32(v: u32) -> [u8; 4] { v.to_le_bytes() }
|
||||
|
||||
/// Build the expected BLAKE3 for one 4096-byte page of 0xAB at h5_offset = 0.
|
||||
///
|
||||
/// Algorithm from spec §5:
|
||||
/// hasher.update(h5_offset.to_le_bytes())
|
||||
/// hasher.update(uncompressed_page_data)
|
||||
fn expected_blake3(page_bytes: &[u8]) -> [u8; 32] {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(&0u64.to_le_bytes()); // h5_offset = 0
|
||||
h.update(page_bytes);
|
||||
*h.finalize().as_bytes()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Golden test
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn golden_minimal_onion_byte_layout() {
|
||||
// ── Build the test vector ────────────────────────────────────────────────
|
||||
|
||||
let _tmp = NamedTempFile::new().unwrap();
|
||||
let h5_path = _tmp.path().with_extension("h5");
|
||||
let base_bytes = b"\x89HDF\r\n\x1a\n".to_vec();
|
||||
std::fs::write(&h5_path, &base_bytes).unwrap();
|
||||
|
||||
let mut onion = OnionFile::create(&h5_path, PAGE_SIZE).unwrap();
|
||||
|
||||
// Use Codec::None so data_size == orig_size and page data is unambiguous.
|
||||
// This also matches the §13 spec test vector which specifies codec = None.
|
||||
onion.set_codec(Codec::None);
|
||||
|
||||
let page_bytes = vec![0xABu8; PAGE_SIZE as usize];
|
||||
let mut session = onion.begin_session(None).unwrap();
|
||||
session.record_page(0, &page_bytes);
|
||||
onion.commit_session(session, None).unwrap();
|
||||
|
||||
let bytes = onion.to_bytes().unwrap();
|
||||
|
||||
// ── Verify total file size ───────────────────────────────────────────────
|
||||
// Header(128) + RevisionEntry(112) + BranchEntry(40) + PageTableEntry(32)
|
||||
// + PageData(4096) + AnnotationHeap(9)
|
||||
let ann_heap: &[u8] = &[0x00, 0x04, 0x00, 0x00, 0x00, b'm', b'a', b'i', b'n'];
|
||||
let expected_total = PD_OFFSET + PAGE_SIZE as usize + ann_heap.len(); // 4417
|
||||
assert_eq!(
|
||||
bytes.len(), expected_total,
|
||||
"total file size mismatch (got {}, expected {expected_total})",
|
||||
bytes.len()
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// §3.1 OnionHeader — 128 bytes @ offset 0
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// [0..9] magic
|
||||
assert_eq!(&bytes[0..9], b"CLAWONION", "magic mismatch");
|
||||
|
||||
// [9] format_version
|
||||
assert_eq!(bytes[9], 0x01, "format_version");
|
||||
|
||||
// [10..16] _pad_align — must be zero
|
||||
assert_eq!(&bytes[10..16], &[0u8; 6], "_pad_align must be zero");
|
||||
|
||||
// [16..24] feature_flags = 0x07 (COMPRESSION | BRANCHING | PROVENANCE)
|
||||
assert_eq!(&bytes[16..24], &le64(0x07), "feature_flags");
|
||||
|
||||
// [24..28] page_size = 4096
|
||||
assert_eq!(&bytes[24..28], &le32(4096), "page_size");
|
||||
|
||||
// [28..32] _pad_ps — must be zero
|
||||
assert_eq!(&bytes[28..32], &[0u8; 4], "_pad_ps must be zero");
|
||||
|
||||
// [32..40] revision_count = 1
|
||||
assert_eq!(&bytes[32..40], &le64(1), "revision_count");
|
||||
|
||||
// [40..44] branch_count = 1
|
||||
assert_eq!(&bytes[40..44], &le32(1), "branch_count");
|
||||
|
||||
// [44..48] _pad_bc — must be zero
|
||||
assert_eq!(&bytes[44..48], &[0u8; 4], "_pad_bc must be zero");
|
||||
|
||||
// [48..56] index_offset = 128
|
||||
assert_eq!(&bytes[48..56], &le64(128), "index_offset");
|
||||
|
||||
// [56..64] branch_offset = 240
|
||||
assert_eq!(&bytes[56..64], &le64(240), "branch_offset");
|
||||
|
||||
// [64..72] created_at — f64, time-dependent; skip exact value check,
|
||||
// but assert it's non-zero (a real Unix timestamp).
|
||||
let created_at = f64::from_le_bytes(bytes[64..72].try_into().unwrap());
|
||||
assert!(created_at > 0.0, "created_at should be a positive Unix timestamp");
|
||||
|
||||
// [72..128] reserved — 56 bytes, must all be zero
|
||||
assert_eq!(&bytes[72..128], &[0u8; 56], "reserved header bytes must be zero");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// §3.2 RevisionEntry — 112 bytes @ offset 128
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let re = INDEX_OFFSET; // 128
|
||||
|
||||
// [re+0..re+8] revision = 0
|
||||
assert_eq!(&bytes[re..re+8], &le64(0), "revision number");
|
||||
|
||||
// [re+8..re+12] branch_id = 0 (main)
|
||||
assert_eq!(&bytes[re+8..re+12], &le32(0), "branch_id");
|
||||
|
||||
// [re+12..re+16] _pad_bi — must be zero
|
||||
assert_eq!(&bytes[re+12..re+16], &[0u8; 4], "_pad_bi must be zero");
|
||||
|
||||
// [re+16..re+24] parent_rev = u64::MAX (root has no parent)
|
||||
assert_eq!(&bytes[re+16..re+24], &le64(u64::MAX), "parent_rev (NO_PARENT sentinel)");
|
||||
|
||||
// [re+24..re+28] page_count = 1
|
||||
assert_eq!(&bytes[re+24..re+28], &le32(1), "page_count");
|
||||
|
||||
// [re+28..re+32] _pad_pc — must be zero
|
||||
assert_eq!(&bytes[re+28..re+32], &[0u8; 4], "_pad_pc must be zero");
|
||||
|
||||
// [re+32..re+40] page_table_off = 280 (PT_OFFSET)
|
||||
assert_eq!(&bytes[re+32..re+40], &le64(PT_OFFSET as u64), "page_table_off");
|
||||
|
||||
// [re+40..re+48] timestamp — f64, time-dependent; assert positive
|
||||
let ts = f64::from_le_bytes(bytes[re+40..re+48].try_into().unwrap());
|
||||
assert!(ts > 0.0, "revision timestamp should be positive");
|
||||
|
||||
// [re+48..re+80] blake3 — verify against spec §5 algorithm
|
||||
let computed_blake3 = expected_blake3(&page_bytes);
|
||||
assert_eq!(&bytes[re+48..re+80], &computed_blake3, "BLAKE3 hash mismatch");
|
||||
|
||||
// [re+80..re+96] session_uuid — 16 bytes UUIDv7; must be non-zero
|
||||
assert_ne!(&bytes[re+80..re+96], &[0u8; 16], "session_uuid must not be all zeros");
|
||||
|
||||
// [re+96..re+104] annotation_off = 0 (no annotation)
|
||||
assert_eq!(&bytes[re+96..re+104], &le64(0), "annotation_off must be 0 (no annotation)");
|
||||
|
||||
// [re+104] flags = 0 (not a snapshot)
|
||||
assert_eq!(bytes[re+104], 0x00, "revision flags");
|
||||
|
||||
// [re+105..re+112] _pad_flags — must be zero
|
||||
assert_eq!(&bytes[re+105..re+112], &[0u8; 7], "_pad_flags must be zero");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// §3.3 BranchEntry — 40 bytes @ offset 240
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let be = BRANCH_OFFSET; // 240
|
||||
|
||||
// [be+0..be+4] id = 0 (main)
|
||||
assert_eq!(&bytes[be..be+4], &le32(0), "branch id");
|
||||
|
||||
// [be+4..be+8] _pad_id — must be zero
|
||||
assert_eq!(&bytes[be+4..be+8], &[0u8; 4], "_pad_id must be zero");
|
||||
|
||||
// [be+8..be+16] name_off = 1 (heap-relative; first string after reserved null)
|
||||
assert_eq!(&bytes[be+8..be+16], &le64(1), "name_off for main branch");
|
||||
|
||||
// [be+16..be+24] head_rev = 0 (updated after commit)
|
||||
assert_eq!(&bytes[be+16..be+24], &le64(0), "head_rev after first commit");
|
||||
|
||||
// [be+24..be+32] fork_rev = u64::MAX (main is not forked)
|
||||
assert_eq!(&bytes[be+24..be+32], &le64(u64::MAX), "fork_rev (NO_PARENT for main)");
|
||||
|
||||
// [be+32..be+40] created_at — f64, time-dependent; assert positive
|
||||
let branch_ts = f64::from_le_bytes(bytes[be+32..be+40].try_into().unwrap());
|
||||
assert!(branch_ts > 0.0, "branch created_at should be positive");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// §3.4 PageTableEntry — 32 bytes @ offset 280
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let pt = PT_OFFSET; // 280
|
||||
|
||||
// [pt+0..pt+8] h5_offset = 0
|
||||
assert_eq!(&bytes[pt..pt+8], &le64(0), "h5_offset");
|
||||
|
||||
// [pt+8..pt+16] data_offset = 312 (PD_OFFSET, absolute file position)
|
||||
assert_eq!(&bytes[pt+8..pt+16], &le64(PD_OFFSET as u64), "data_offset (absolute)");
|
||||
|
||||
// [pt+16..pt+20] orig_size = 4096
|
||||
assert_eq!(&bytes[pt+16..pt+20], &le32(4096), "orig_size");
|
||||
|
||||
// [pt+20..pt+24] data_size = 4096 (codec=None → no compression)
|
||||
assert_eq!(&bytes[pt+20..pt+24], &le32(4096), "data_size (codec=None, uncompressed)");
|
||||
|
||||
// [pt+24] codec = 0 (None)
|
||||
assert_eq!(bytes[pt+24], 0x00, "codec = None (0)");
|
||||
|
||||
// [pt+25..pt+32] _pad — must be zero
|
||||
assert_eq!(&bytes[pt+25..pt+32], &[0u8; 7], "_pad must be zero");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// PageData — 4096 bytes @ offset 312
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
assert_eq!(&bytes[PD_OFFSET..PD_OFFSET + 4096], page_bytes.as_slice(),
|
||||
"page data must equal the uncompressed original bytes");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// §3.5 AnnotationHeap — tail of file
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Last 9 bytes: [0x00 reserved] [u32 LE length=4] ["main"]
|
||||
assert_eq!(&bytes[bytes.len()-9..], ann_heap, "annotation heap content");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// §7 Reconstruction — verify round-trip
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let reconstructed = onion.reconstruct_revision(0, &base_bytes).unwrap();
|
||||
assert_eq!(
|
||||
&reconstructed[..PAGE_SIZE as usize],
|
||||
page_bytes.as_slice(),
|
||||
"reconstruct_revision must reproduce the original page"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Byte-exact format freeze — §13 canonical test vector
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// This test builds the COMPLETE expected byte buffer from pure spec knowledge,
|
||||
// then compares it against the actual serialised output byte-for-byte.
|
||||
//
|
||||
// Dynamic fields (wall-clock timestamps, UUIDs) are zeroed in BOTH buffers
|
||||
// before the comparison — this preserves the freeze property for all layout
|
||||
// fields while remaining deterministic.
|
||||
//
|
||||
// This complements `golden_minimal_onion_byte_layout` (which checks individual
|
||||
// fields) by catching any bytes in between that neither test checks explicitly.
|
||||
|
||||
#[test]
|
||||
fn byte_exact_format_freeze() {
|
||||
let re = INDEX_OFFSET; // 128 — RevisionEntry start
|
||||
let be = BRANCH_OFFSET; // 240 — BranchEntry start
|
||||
let pt = PT_OFFSET; // 280 — PageTableEntry start
|
||||
let pd = PD_OFFSET; // 312 — PageData start
|
||||
|
||||
// ── Build the actual serialised file ────────────────────────────────────
|
||||
|
||||
let _tmp = NamedTempFile::new().unwrap();
|
||||
let h5_path = _tmp.path().with_extension("h5");
|
||||
std::fs::write(&h5_path, b"\x89HDF\r\n\x1a\n").unwrap();
|
||||
|
||||
let mut onion = OnionFile::create(&h5_path, PAGE_SIZE).unwrap();
|
||||
onion.set_codec(Codec::None);
|
||||
|
||||
let page_bytes = vec![0xABu8; PAGE_SIZE as usize];
|
||||
let mut session = onion.begin_session(None).unwrap();
|
||||
session.record_page(0, &page_bytes);
|
||||
onion.commit_session(session, None).unwrap();
|
||||
|
||||
let mut actual = onion.to_bytes().unwrap();
|
||||
|
||||
// ── Build the expected buffer from spec ──────────────────────────────────
|
||||
|
||||
let ann_heap: &[u8] = &[0x00, 0x04, 0x00, 0x00, 0x00, b'm', b'a', b'i', b'n'];
|
||||
let total = pd + PAGE_SIZE as usize + ann_heap.len(); // 4417
|
||||
let mut expected = vec![0u8; total];
|
||||
|
||||
// §3.1 OnionHeader (128 bytes)
|
||||
expected[0..9].copy_from_slice(b"CLAWONION"); // magic
|
||||
expected[9] = 0x01; // format_version
|
||||
// [10..16] = 0 (_pad_align)
|
||||
expected[16..24].copy_from_slice(&le64(0x07)); // feature_flags
|
||||
expected[24..28].copy_from_slice(&le32(4096)); // page_size
|
||||
// [28..32] = 0 (_pad_ps)
|
||||
expected[32..40].copy_from_slice(&le64(1)); // revision_count = 1
|
||||
expected[40..44].copy_from_slice(&le32(1)); // branch_count = 1
|
||||
// [44..48] = 0 (_pad_bc)
|
||||
expected[48..56].copy_from_slice(&le64(128)); // index_offset
|
||||
expected[56..64].copy_from_slice(&le64(240)); // branch_offset
|
||||
// [64..72] = 0 (created_at — zeroed, dynamic)
|
||||
// [72..128] = 0 (reserved)
|
||||
|
||||
// §3.2 RevisionEntry (112 bytes @ 128)
|
||||
expected[re..re+8] .copy_from_slice(&le64(0)); // revision = 0
|
||||
expected[re+8..re+12].copy_from_slice(&le32(0)); // branch_id = 0
|
||||
// [re+12..re+16] = 0 (_pad_bi)
|
||||
expected[re+16..re+24].copy_from_slice(&le64(u64::MAX)); // parent_rev = NO_PARENT
|
||||
expected[re+24..re+28].copy_from_slice(&le32(1)); // page_count = 1
|
||||
// [re+28..re+32] = 0 (_pad_pc)
|
||||
expected[re+32..re+40].copy_from_slice(&le64(pt as u64)); // page_table_off
|
||||
// [re+40..re+48] = 0 (timestamp — zeroed, dynamic)
|
||||
expected[re+48..re+80].copy_from_slice(&expected_blake3(&page_bytes)); // blake3
|
||||
// [re+80..re+96] = 0 (session_uuid — zeroed, dynamic)
|
||||
// [re+96..re+104] = 0 (annotation_off = 0, no annotation)
|
||||
// [re+104] = 0 (flags = 0, not a snapshot)
|
||||
// [re+105..re+112] = 0 (_pad_flags)
|
||||
|
||||
// §3.3 BranchEntry (40 bytes @ 240)
|
||||
expected[be..be+4] .copy_from_slice(&le32(0)); // id = 0 (main)
|
||||
// [be+4..be+8] = 0 (_pad_id)
|
||||
expected[be+8..be+16].copy_from_slice(&le64(1)); // name_off = 1 (heap offset of "main")
|
||||
expected[be+16..be+24].copy_from_slice(&le64(0)); // head_rev = 0
|
||||
expected[be+24..be+32].copy_from_slice(&le64(u64::MAX)); // fork_rev = NO_PARENT
|
||||
// [be+32..be+40] = 0 (created_at — zeroed, dynamic)
|
||||
|
||||
// §3.4 PageTableEntry (32 bytes @ 280)
|
||||
expected[pt..pt+8] .copy_from_slice(&le64(0)); // h5_offset = 0
|
||||
expected[pt+8..pt+16].copy_from_slice(&le64(pd as u64)); // data_offset
|
||||
expected[pt+16..pt+20].copy_from_slice(&le32(4096)); // orig_size
|
||||
expected[pt+20..pt+24].copy_from_slice(&le32(4096)); // data_size (no compression)
|
||||
// [pt+24] = 0 (codec = None)
|
||||
// [pt+25..pt+32] = 0 (_pad)
|
||||
|
||||
// PageData (4096 bytes @ 312)
|
||||
expected[pd..pd+4096].fill(0xAB);
|
||||
|
||||
// §3.5 AnnotationHeap (9 bytes at end)
|
||||
expected[pd+4096..].copy_from_slice(ann_heap);
|
||||
|
||||
// ── Zero dynamic fields in both buffers ──────────────────────────────────
|
||||
// OnionHeader.created_at
|
||||
actual[64..72].fill(0);
|
||||
// RevisionEntry.timestamp
|
||||
actual[re+40..re+48].fill(0);
|
||||
// RevisionEntry.session_uuid
|
||||
actual[re+80..re+96].fill(0);
|
||||
// BranchEntry.created_at
|
||||
actual[be+32..be+40].fill(0);
|
||||
|
||||
// ── Compare byte-for-byte ────────────────────────────────────────────────
|
||||
assert_eq!(actual.len(), expected.len(),
|
||||
"total file size mismatch: actual={} expected={}",
|
||||
actual.len(), expected.len());
|
||||
|
||||
// Find the first differing byte for a useful failure message.
|
||||
if actual != expected {
|
||||
for i in 0..actual.len() {
|
||||
if actual[i] != expected[i] {
|
||||
panic!(
|
||||
"byte mismatch at offset 0x{i:03X} ({i}): \
|
||||
actual=0x{:02X} expected=0x{:02X}\n\
|
||||
(fields: header=0..128, rev_entry=128..240, branch=240..280, \
|
||||
page_table=280..312, page_data=312..4408, ann_heap=4408..4417)",
|
||||
actual[i], expected[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Struct size guards — compile-time; here as runtime assertions too so that
|
||||
// a zerocopy version bump that somehow bypasses the const asserts still fires.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn struct_sizes_match_spec() {
|
||||
use std::mem::size_of;
|
||||
use clawhdf5_onion::format::{OnionHeader, RevisionEntry, BranchEntry, PageTableEntry};
|
||||
|
||||
assert_eq!(size_of::<OnionHeader>(), 128, "OnionHeader must be 128 bytes (§3.1)");
|
||||
assert_eq!(size_of::<RevisionEntry>(), 112, "RevisionEntry must be 112 bytes (§3.2)");
|
||||
assert_eq!(size_of::<BranchEntry>(), 40, "BranchEntry must be 40 bytes (§3.3)");
|
||||
assert_eq!(size_of::<PageTableEntry>(), 32, "PageTableEntry must be 32 bytes (§3.4)");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Magic / version rejection — §10 conformance
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bad_magic_is_rejected() {
|
||||
let _tmp = NamedTempFile::new().unwrap();
|
||||
let h5_path = _tmp.path().with_extension("h5");
|
||||
std::fs::write(&h5_path, b"\x89HDF\r\n\x1a\n").unwrap();
|
||||
|
||||
let mut onion = OnionFile::create(&h5_path, 4096).unwrap();
|
||||
onion.set_codec(Codec::None);
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![0u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
|
||||
let mut bytes = onion.to_bytes().unwrap();
|
||||
|
||||
// Corrupt the magic
|
||||
bytes[0] = b'X';
|
||||
|
||||
let sidecar = h5_path.with_extension("h5.onion");
|
||||
std::fs::write(&sidecar, &bytes).unwrap();
|
||||
|
||||
let err = OnionFile::open(&h5_path).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, clawhdf5_onion::error::OnionError::InvalidMagic),
|
||||
"expected InvalidMagic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_version_is_rejected() {
|
||||
let _tmp = NamedTempFile::new().unwrap();
|
||||
let h5_path = _tmp.path().with_extension("h5");
|
||||
std::fs::write(&h5_path, b"\x89HDF\r\n\x1a\n").unwrap();
|
||||
|
||||
let mut onion = OnionFile::create(&h5_path, 4096).unwrap();
|
||||
onion.set_codec(Codec::None);
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![0u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
|
||||
let mut bytes = onion.to_bytes().unwrap();
|
||||
|
||||
// Bump format_version to something unknown
|
||||
bytes[9] = 0xFF;
|
||||
|
||||
let sidecar = h5_path.with_extension("h5.onion");
|
||||
std::fs::write(&sidecar, &bytes).unwrap();
|
||||
|
||||
let err = OnionFile::open(&h5_path).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, clawhdf5_onion::error::OnionError::UnknownVersion(0xFF)),
|
||||
"expected UnknownVersion(0xFF), got {err:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc cc7ca5089f0e2c2c7c49ebf02df844567d33c457fca22d2faf0b6333d2b3d443 # shrinks to n_revisions = 7, keep_n = 1
|
||||
cc e9399e4043dbd1fb1b69988cd09e3b5746da4dc0f699d9dca701192e8a594dfd # shrinks to writes = [(0, 0), (0, 0), (0, 0), (0, 0), (0, 0)], keep_n = 1
|
||||
@@ -0,0 +1,815 @@
|
||||
//! Property-based tests for `clawhdf5-onion`.
|
||||
//!
|
||||
//! These tests use `proptest` to verify invariants over arbitrary sequences of
|
||||
//! write, branch, merge, GC, and read operations, ensuring the onion format
|
||||
//! stays consistent regardless of the operation order.
|
||||
//!
|
||||
//! Invariants checked:
|
||||
//! - `head_rev` is always valid (< revision_count)
|
||||
//! - No orphan page entries (every revision index entry has accessible pages)
|
||||
//! - BLAKE3 hashes are consistent after any sequence of writes
|
||||
//! - Branch count equals unique branch IDs in revision index
|
||||
//! - GC followed by any read produces no error
|
||||
//! - Serialise → deserialise round-trip preserves all revision annotations
|
||||
//! - Merge produces a new revision on the target branch
|
||||
//! - `revision_count` is monotonically non-decreasing
|
||||
|
||||
use clawhdf5_onion::branch::MergeStrategy;
|
||||
use clawhdf5_onion::format::NO_PARENT;
|
||||
use clawhdf5_onion::gc::GcPolicy;
|
||||
use clawhdf5_onion::writer::OnionFile;
|
||||
use proptest::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Generators
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An operation to apply to an OnionFile during a property test.
|
||||
#[derive(Debug, Clone)]
|
||||
enum Op {
|
||||
/// Write a page at a given offset with a given fill byte.
|
||||
Write { page_offset: u32, fill: u8 },
|
||||
/// Fork a new branch named "branch-N" off main.
|
||||
Fork,
|
||||
/// Commit the current session and start fresh.
|
||||
Flush,
|
||||
}
|
||||
|
||||
fn arb_op() -> impl Strategy<Value = Op> {
|
||||
prop_oneof![
|
||||
(0u32..4u32, any::<u8>()).prop_map(|(off, fill)| Op::Write {
|
||||
page_offset: off,
|
||||
fill,
|
||||
}),
|
||||
Just(Op::Fork),
|
||||
Just(Op::Flush),
|
||||
]
|
||||
}
|
||||
|
||||
fn arb_ops(min: usize, max: usize) -> impl Strategy<Value = Vec<Op>> {
|
||||
proptest::collection::vec(arb_op(), min..=max)
|
||||
}
|
||||
|
||||
fn arb_page_size() -> impl Strategy<Value = u32> {
|
||||
prop_oneof![Just(512u32), Just(1024), Just(4096)]
|
||||
}
|
||||
|
||||
/// Create a fresh OnionFile in a temp directory.
|
||||
fn fresh_onion(page_size: u32) -> (NamedTempFile, PathBuf, OnionFile) {
|
||||
let f = NamedTempFile::new().unwrap();
|
||||
let h5 = f.path().with_extension("h5");
|
||||
std::fs::write(&h5, b"\x89HDF\r\n\x1a\n").unwrap();
|
||||
let onion = OnionFile::create(&h5, page_size).unwrap();
|
||||
(f, h5, onion)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Apply a list of ops to an OnionFile. Returns the number of revisions committed.
|
||||
fn apply_ops(onion: &mut OnionFile, ops: &[Op]) -> u64 {
|
||||
let mut session = onion.begin_session(None).unwrap();
|
||||
let page_size = onion.page_size() as usize;
|
||||
let mut fork_count = 0usize;
|
||||
let mut has_writes = false;
|
||||
|
||||
for op in ops {
|
||||
match op {
|
||||
Op::Write { page_offset, fill } => {
|
||||
let offset = (*page_offset as u64) * (page_size as u64);
|
||||
session.record_page(offset, &vec![*fill; page_size]);
|
||||
has_writes = true;
|
||||
}
|
||||
Op::Fork => {
|
||||
// Commit current session first
|
||||
if has_writes {
|
||||
onion.commit_session(session, Some("auto")).unwrap();
|
||||
has_writes = false;
|
||||
} else {
|
||||
drop(session);
|
||||
}
|
||||
// Fork a new branch
|
||||
let branch_name = format!("branch-{fork_count}");
|
||||
fork_count += 1;
|
||||
let _ = onion.create_branch(&branch_name, "main");
|
||||
// Restart session on main
|
||||
session = onion.begin_session(None).unwrap();
|
||||
}
|
||||
Op::Flush => {
|
||||
if has_writes {
|
||||
onion.commit_session(session, Some("flush")).unwrap();
|
||||
has_writes = false;
|
||||
} else {
|
||||
drop(session);
|
||||
}
|
||||
session = onion.begin_session(None).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit trailing session if it has writes
|
||||
if has_writes {
|
||||
onion.commit_session(session, Some("final")).unwrap();
|
||||
} else {
|
||||
drop(session);
|
||||
}
|
||||
|
||||
onion.revision_count()
|
||||
}
|
||||
|
||||
/// Check all invariants on an OnionFile.
|
||||
fn assert_invariants(onion: &OnionFile) {
|
||||
let revisions = onion.list_revisions();
|
||||
let rev_count = onion.revision_count();
|
||||
|
||||
// 1. revision_count matches actual revision list length
|
||||
assert_eq!(
|
||||
rev_count as usize,
|
||||
revisions.len(),
|
||||
"revision_count mismatch: header={rev_count} list={}",
|
||||
revisions.len()
|
||||
);
|
||||
|
||||
// 2. Revision numbers are monotonically increasing from 0
|
||||
for (i, rev) in revisions.iter().enumerate() {
|
||||
assert_eq!(
|
||||
rev.revision, i as u64,
|
||||
"non-contiguous revision at index {i}: revision={}",
|
||||
rev.revision
|
||||
);
|
||||
}
|
||||
|
||||
// 3. parent_rev is either NO_PARENT or a valid earlier revision
|
||||
for rev in &revisions {
|
||||
if rev.parent_rev != NO_PARENT {
|
||||
assert!(
|
||||
rev.parent_rev < rev.revision,
|
||||
"revision {} has parent {} ≥ itself",
|
||||
rev.revision,
|
||||
rev.parent_rev
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. branch_id references are all valid
|
||||
let branch_ids: std::collections::HashSet<u32> =
|
||||
onion.list_branches().iter().map(|b| b.id).collect();
|
||||
for rev in &revisions {
|
||||
assert!(
|
||||
branch_ids.contains(&rev.branch_id),
|
||||
"revision {} references unknown branch_id {}",
|
||||
rev.revision,
|
||||
rev.branch_id
|
||||
);
|
||||
}
|
||||
|
||||
// 5. blake3_hex has correct length (64 chars)
|
||||
for rev in &revisions {
|
||||
assert_eq!(
|
||||
rev.blake3_hex.len(),
|
||||
64,
|
||||
"revision {} has blake3_hex of wrong length {}",
|
||||
rev.revision,
|
||||
rev.blake3_hex.len()
|
||||
);
|
||||
assert!(
|
||||
rev.blake3_hex.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"revision {} has non-hex blake3_hex: {}",
|
||||
rev.revision,
|
||||
&rev.blake3_hex
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Pages are accessible for every revision
|
||||
for rev in &revisions {
|
||||
onion
|
||||
.revision_pages(rev.revision)
|
||||
.unwrap_or_else(|e| panic!("revision_pages({}) failed: {e}", rev.revision));
|
||||
}
|
||||
|
||||
// 7. branch head revisions are valid
|
||||
for branch in onion.list_branches() {
|
||||
if branch.head_rev != NO_PARENT {
|
||||
assert!(
|
||||
branch.head_rev < rev_count,
|
||||
"branch {} head_rev {} >= revision_count {}",
|
||||
branch.name,
|
||||
branch.head_rev,
|
||||
rev_count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check GC-safe invariants — like `assert_invariants` but allows non-contiguous
|
||||
/// revision numbering (GC keeps original IDs) and gaps in the parent chain.
|
||||
fn assert_gc_invariants(onion: &OnionFile) {
|
||||
let revisions = onion.list_revisions();
|
||||
let rev_count = onion.revision_count();
|
||||
|
||||
// 1. revision_count matches actual revision list length
|
||||
assert_eq!(
|
||||
rev_count as usize,
|
||||
revisions.len(),
|
||||
"revision_count mismatch: header={rev_count} list={}",
|
||||
revisions.len()
|
||||
);
|
||||
|
||||
// 2. Revision numbers are monotonically increasing (gaps allowed after GC)
|
||||
for w in revisions.windows(2) {
|
||||
assert!(
|
||||
w[0].revision < w[1].revision,
|
||||
"revisions not sorted: {} then {}",
|
||||
w[0].revision,
|
||||
w[1].revision
|
||||
);
|
||||
}
|
||||
|
||||
// 3. parent_rev is either NO_PARENT or a valid earlier revision number
|
||||
let rev_set: std::collections::HashSet<u64> = revisions.iter().map(|r| r.revision).collect();
|
||||
for rev in &revisions {
|
||||
if rev.parent_rev != NO_PARENT {
|
||||
assert!(
|
||||
rev.parent_rev < rev.revision,
|
||||
"revision {} has parent {} ≥ itself",
|
||||
rev.revision,
|
||||
rev.parent_rev
|
||||
);
|
||||
// After GC the parent may have been pruned (oldest_surviving is set to NO_PARENT
|
||||
// during consolidation), so we only check that parent < self (not that it exists).
|
||||
let _ = &rev_set; // suppress unused warning
|
||||
}
|
||||
}
|
||||
|
||||
// 4. branch_id references are all valid
|
||||
let branch_ids: std::collections::HashSet<u32> =
|
||||
onion.list_branches().iter().map(|b| b.id).collect();
|
||||
for rev in &revisions {
|
||||
assert!(
|
||||
branch_ids.contains(&rev.branch_id),
|
||||
"revision {} references unknown branch_id {}",
|
||||
rev.revision,
|
||||
rev.branch_id
|
||||
);
|
||||
}
|
||||
|
||||
// 5. blake3_hex has correct length (64 chars)
|
||||
for rev in &revisions {
|
||||
assert_eq!(rev.blake3_hex.len(), 64,
|
||||
"revision {} has blake3_hex of wrong length {}", rev.revision, rev.blake3_hex.len());
|
||||
}
|
||||
|
||||
// 6. Pages are accessible for every revision
|
||||
for rev in &revisions {
|
||||
onion
|
||||
.revision_pages(rev.revision)
|
||||
.unwrap_or_else(|e| panic!("revision_pages({}) failed: {e}", rev.revision));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Property tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig {
|
||||
cases: 300,
|
||||
max_shrink_iters: 100,
|
||||
..ProptestConfig::default()
|
||||
})]
|
||||
|
||||
/// Any sequence of write ops leaves the onion in a valid state.
|
||||
#[test]
|
||||
fn prop_any_write_sequence_is_consistent(
|
||||
page_size in arb_page_size(),
|
||||
ops in arb_ops(1, 20),
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(page_size);
|
||||
apply_ops(&mut onion, &ops);
|
||||
assert_invariants(&onion);
|
||||
}
|
||||
|
||||
/// revision_count never decreases.
|
||||
#[test]
|
||||
fn prop_revision_count_monotone(ops in arb_ops(1, 15)) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
let mut prev = 0u64;
|
||||
// Apply ops in batches of 3, checking after each batch
|
||||
for chunk in ops.chunks(3) {
|
||||
apply_ops(&mut onion, chunk);
|
||||
let cur = onion.revision_count();
|
||||
prop_assert!(cur >= prev, "revision_count decreased: {prev} → {cur}");
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
/// Every revision annotation stored is retrievable.
|
||||
#[test]
|
||||
fn prop_annotations_roundtrip(
|
||||
annotations in proptest::collection::vec(
|
||||
proptest::option::of("[a-zA-Z0-9 _-]{0,40}"),
|
||||
1..=10usize,
|
||||
)
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
for (i, ann) in annotations.iter().enumerate() {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8; 4096]);
|
||||
onion.commit_session(s, ann.as_deref()).unwrap();
|
||||
}
|
||||
let revisions = onion.list_revisions();
|
||||
for (rev_entry, expected_ann) in revisions.iter().zip(annotations.iter()) {
|
||||
prop_assert_eq!(
|
||||
&rev_entry.annotation,
|
||||
expected_ann,
|
||||
"annotation mismatch at revision {}",
|
||||
rev_entry.revision
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creating any number of branches in [0, 8] leaves list_branches consistent.
|
||||
#[test]
|
||||
fn prop_branch_count_consistent(n_branches in 0usize..=8usize) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
// Write one revision to main so fork has a valid HEAD
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![0xAAu8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
|
||||
let mut created = 0;
|
||||
for i in 0..n_branches {
|
||||
let name = format!("branch-{i}");
|
||||
if onion.create_branch(&name, "main").is_ok() {
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let branches = onion.list_branches();
|
||||
// +1 for main
|
||||
prop_assert_eq!(branches.len(), created + 1,
|
||||
"expected {} branches (including main), got {}", created + 1, branches.len());
|
||||
}
|
||||
|
||||
/// Page data for the most recent revision of main always matches the last written fill byte.
|
||||
#[test]
|
||||
fn prop_latest_page_reflects_last_write(
|
||||
fills in proptest::collection::vec(any::<u8>(), 1..=10usize),
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
for &fill in &fills {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![fill; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
let head_rev = onion.revision_count() - 1;
|
||||
let pages = onion.revision_pages(head_rev).unwrap();
|
||||
let page_data = &pages[0].1;
|
||||
let expected_fill = *fills.last().unwrap();
|
||||
prop_assert!(
|
||||
page_data.iter().all(|&b| b == expected_fill),
|
||||
"head page fill mismatch: expected 0x{:02x}, got first byte 0x{:02x}",
|
||||
expected_fill, page_data[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// BLAKE3 hex for every revision is unique (no two revisions with identical page content
|
||||
/// sharing a hash — all revisions have distinct page contents due to unique fill bytes).
|
||||
///
|
||||
/// We write n revisions each with a distinct fill byte, then verify all blake3s differ.
|
||||
#[test]
|
||||
fn prop_distinct_writes_have_distinct_hashes(
|
||||
fills in proptest::collection::vec(0u8..=127u8, 2..=8usize).prop_filter(
|
||||
"fills must be unique",
|
||||
|v| {
|
||||
let s: std::collections::HashSet<u8> = v.iter().copied().collect();
|
||||
s.len() == v.len()
|
||||
}
|
||||
)
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
for &fill in &fills {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![fill; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
let hashes: Vec<_> = onion.list_revisions().into_iter().map(|r| r.blake3_hex).collect();
|
||||
let unique: std::collections::HashSet<_> = hashes.iter().collect();
|
||||
prop_assert_eq!(unique.len(), hashes.len(), "duplicate blake3 hashes found");
|
||||
}
|
||||
|
||||
/// Serialize → deserialize round-trip: all revision annotations survive.
|
||||
#[test]
|
||||
fn prop_serde_roundtrip_preserves_annotations(
|
||||
n_revisions in 1usize..=10usize,
|
||||
ann_len in 0usize..=30usize,
|
||||
) {
|
||||
let (_f, h5_path, mut onion) = fresh_onion(4096);
|
||||
for i in 0..n_revisions {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8 % 255; 4096]);
|
||||
let ann: String = format!("rev-{i:0>width$}", width = ann_len.min(20));
|
||||
onion.commit_session(s, Some(&ann)).unwrap();
|
||||
}
|
||||
onion.flush().unwrap();
|
||||
|
||||
// Reload from disk
|
||||
let reloaded = OnionFile::open(&h5_path).unwrap();
|
||||
let orig_revs = onion.list_revisions();
|
||||
let reloaded_revs = reloaded.list_revisions();
|
||||
|
||||
prop_assert_eq!(orig_revs.len(), reloaded_revs.len());
|
||||
for (o, r) in orig_revs.iter().zip(reloaded_revs.iter()) {
|
||||
prop_assert_eq!(&o.annotation, &r.annotation,
|
||||
"annotation mismatch at revision {}", o.revision);
|
||||
prop_assert_eq!(&o.blake3_hex, &r.blake3_hex,
|
||||
"blake3 mismatch at revision {}", o.revision);
|
||||
}
|
||||
}
|
||||
|
||||
/// After any fork, the new branch starts with the same HEAD as main.
|
||||
#[test]
|
||||
fn prop_fork_head_equals_source_head(n_writes in 1usize..=5usize) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
for i in 0..n_writes {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
let main_head_before = onion.list_branches()
|
||||
.iter().find(|b| b.name == "main").unwrap().head_rev;
|
||||
|
||||
onion.create_branch("feat", "main").unwrap();
|
||||
|
||||
let feat_branch = onion.list_branches()
|
||||
.into_iter().find(|b| b.name == "feat").unwrap();
|
||||
|
||||
prop_assert_eq!(
|
||||
feat_branch.fork_rev, main_head_before,
|
||||
"fork_rev should equal main HEAD at fork time"
|
||||
);
|
||||
}
|
||||
|
||||
/// Merging branch into main with LatestWins always produces at least one new revision.
|
||||
#[test]
|
||||
fn prop_merge_latest_wins_produces_new_revision(
|
||||
n_main in 1usize..=4usize,
|
||||
n_feat in 1usize..=4usize,
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
for i in 0..n_main {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
onion.create_branch("feat", "main").unwrap();
|
||||
let feat_id = onion.branch_by_name("feat").unwrap().id;
|
||||
|
||||
for i in 0..n_feat {
|
||||
let mut s = onion.begin_session(Some(feat_id)).unwrap();
|
||||
s.record_page(0, &vec![0x80 + i as u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
let rev_before = onion.revision_count();
|
||||
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
|
||||
let rev_after = onion.revision_count();
|
||||
|
||||
prop_assert!(rev_after > rev_before, "merge did not produce a new revision");
|
||||
assert_invariants(&onion);
|
||||
}
|
||||
|
||||
/// Multiple sequential merges stay consistent.
|
||||
#[test]
|
||||
fn prop_sequential_merges_consistent(n_rounds in 1usize..=3usize) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
// One main revision
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![0u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
|
||||
for round in 0..n_rounds {
|
||||
let branch_name = format!("feat-{round}");
|
||||
onion.create_branch(&branch_name, "main").unwrap();
|
||||
let bid = onion.branch_by_name(&branch_name).unwrap().id;
|
||||
|
||||
let mut s = onion.begin_session(Some(bid)).unwrap();
|
||||
s.record_page(0, &vec![round as u8 + 1; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
|
||||
onion.merge_into(&branch_name, "main", MergeStrategy::LatestWins).unwrap();
|
||||
assert_invariants(&onion);
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty sessions (no pages recorded) can be committed without breaking state.
|
||||
#[test]
|
||||
fn prop_empty_sessions_do_not_corrupt(n_empty in 1usize..=5usize) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
// One real write to anchor things
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![0xAAu8; 4096]);
|
||||
onion.commit_session(s, Some("anchor")).unwrap();
|
||||
|
||||
for _ in 0..n_empty {
|
||||
let s = onion.begin_session(None).unwrap();
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
assert_invariants(&onion);
|
||||
}
|
||||
|
||||
/// revision_pages never returns data of the wrong size.
|
||||
#[test]
|
||||
fn prop_revision_pages_correct_size(
|
||||
n_revisions in 1usize..=8usize,
|
||||
page_size in arb_page_size(),
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(page_size);
|
||||
for i in 0..n_revisions {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8; page_size as usize]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
for rev in 0..n_revisions as u64 {
|
||||
let pages = onion.revision_pages(rev).unwrap();
|
||||
for (_, data) in &pages {
|
||||
prop_assert_eq!(
|
||||
data.len(), page_size as usize,
|
||||
"page data len {} != page_size {} at rev {}", data.len(), page_size, rev
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a rename, the branch is accessible under the new name only.
|
||||
#[test]
|
||||
fn prop_rename_branch_accessible_by_new_name(
|
||||
old_suffix in "[a-z]{3,6}",
|
||||
new_suffix in "[a-z]{3,6}",
|
||||
) {
|
||||
prop_assume!(old_suffix != new_suffix);
|
||||
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![1u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
|
||||
let old_name = format!("branch-{old_suffix}");
|
||||
let new_name = format!("branch-{new_suffix}");
|
||||
|
||||
onion.create_branch(&old_name, "main").unwrap();
|
||||
onion.rename_branch(&old_name, &new_name).unwrap();
|
||||
|
||||
prop_assert!(onion.branch_by_name(&old_name).is_none(),
|
||||
"old name still accessible after rename");
|
||||
prop_assert!(onion.branch_by_name(&new_name).is_some(),
|
||||
"new name not accessible after rename");
|
||||
}
|
||||
|
||||
/// GC `KeepLastN` — surviving revisions reconstruct to the same page data as before GC.
|
||||
#[test]
|
||||
fn prop_gc_keep_last_n_surviving_pages_unchanged(
|
||||
// Write between 2 and 12 revisions, each writing distinct fill bytes at
|
||||
// one of 4 page offsets.
|
||||
writes in proptest::collection::vec(
|
||||
(0u32..4u32, any::<u8>()),
|
||||
2..=12usize,
|
||||
),
|
||||
// Keep between 1 and all revisions.
|
||||
keep_n in 1u64..=12u64,
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
|
||||
for (page_off, fill) in &writes {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page((*page_off as u64) * 4096, &vec![*fill; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
let rev_count = onion.revision_count();
|
||||
let keep_n = keep_n.min(rev_count);
|
||||
|
||||
// Capture page data for all revisions before GC.
|
||||
let before: Vec<Vec<(u64, Vec<u8>)>> = (0..rev_count)
|
||||
.map(|rev| onion.revision_pages(rev).unwrap())
|
||||
.collect();
|
||||
|
||||
// Run GC.
|
||||
let stats = onion.gc(GcPolicy::KeepLastN(keep_n)).unwrap();
|
||||
|
||||
// The number of surviving revisions should equal min(keep_n, rev_count).
|
||||
let surviving = onion.revision_count();
|
||||
prop_assert_eq!(surviving, keep_n.min(rev_count),
|
||||
"after KeepLastN({}), expected {}, got {} revisions",
|
||||
keep_n, keep_n.min(rev_count), surviving);
|
||||
|
||||
// GC removed rev_count - surviving revisions.
|
||||
prop_assert_eq!(stats.revisions_removed, rev_count - surviving,
|
||||
"revisions_removed mismatch");
|
||||
|
||||
// Surviving revisions satisfy GC-safe invariants (non-contiguous IDs allowed).
|
||||
assert_gc_invariants(&onion);
|
||||
|
||||
// For every surviving revision, `revision_pages()` must succeed and
|
||||
// return pages of the correct size. GC preserves original revision IDs
|
||||
// so we query by original revision number from list_revisions().
|
||||
let surviving_revs: Vec<u64> = onion.list_revisions().iter().map(|r| r.revision).collect();
|
||||
for &rev_id in &surviving_revs {
|
||||
let pages = onion.revision_pages(rev_id)
|
||||
.unwrap_or_else(|e| panic!("revision_pages({rev_id}) failed after GC: {e}"));
|
||||
for (_, data) in &pages {
|
||||
prop_assert_eq!(data.len(), 4096usize,
|
||||
"page data len {} != 4096 at post-GC rev {}", data.len(), rev_id);
|
||||
}
|
||||
}
|
||||
|
||||
// The HEAD revision (last surviving) must have the same page content
|
||||
// as the original HEAD. GC keeps original IDs so the last entry in
|
||||
// list_revisions() gives the actual revision number to query.
|
||||
let original_head_pages = &before[rev_count as usize - 1];
|
||||
let gc_head_id = *surviving_revs.last().unwrap();
|
||||
let gc_head_pages = onion.revision_pages(gc_head_id).unwrap();
|
||||
|
||||
// Build maps keyed by page offset for comparison.
|
||||
let orig_map: std::collections::HashMap<u64, &Vec<u8>> =
|
||||
original_head_pages.iter().map(|(off, data)| (*off, data)).collect();
|
||||
let gc_map: std::collections::HashMap<u64, &Vec<u8>> =
|
||||
gc_head_pages.iter().map(|(off, data)| (*off, data)).collect();
|
||||
|
||||
for (off, orig_data) in &orig_map {
|
||||
if let Some(gc_data) = gc_map.get(off) {
|
||||
prop_assert_eq!(
|
||||
orig_data.as_slice(), gc_data.as_slice(),
|
||||
"HEAD page at offset {} changed after GC", off
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pages present in GC head but not in original head must be zero
|
||||
// (filled from h5_base = HDF5 magic + zeros) — we don't assert this
|
||||
// as it depends on consolidation, but the invariant check above covers consistency.
|
||||
}
|
||||
|
||||
/// GC `KeepLastN(n)` with n >= revision_count is a no-op.
|
||||
#[test]
|
||||
fn prop_gc_keep_all_is_noop(
|
||||
n_revisions in 1usize..=8usize,
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
for i in 0..n_revisions {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
let before_hashes: Vec<String> = onion.list_revisions().iter()
|
||||
.map(|r| r.blake3_hex.clone()).collect();
|
||||
|
||||
// Keep more than we have — should remove nothing.
|
||||
let stats = onion.gc(GcPolicy::KeepLastN(n_revisions as u64 + 10)).unwrap();
|
||||
prop_assert_eq!(stats.revisions_removed, 0, "expected no removals");
|
||||
|
||||
let after_hashes: Vec<String> = onion.list_revisions().iter()
|
||||
.map(|r| r.blake3_hex.clone()).collect();
|
||||
|
||||
prop_assert_eq!(before_hashes, after_hashes, "hashes changed after no-op GC");
|
||||
assert_gc_invariants(&onion);
|
||||
}
|
||||
|
||||
/// `KeepTagged` retains exactly the annotated revisions and prunes the rest.
|
||||
///
|
||||
/// After GC, every surviving revision must have a non-empty annotation, and
|
||||
/// the count of surviving revisions must equal the number of annotated
|
||||
/// revisions before GC.
|
||||
#[test]
|
||||
fn prop_gc_keep_tagged_retains_exactly_annotated(
|
||||
// Bit mask: 1 = annotated, 0 = unannotated, for up to 10 revisions.
|
||||
annotation_mask in 0u16..1024u16,
|
||||
n_revisions in 1usize..=10usize,
|
||||
) {
|
||||
let (_f, _h5, mut onion) = fresh_onion(4096);
|
||||
let mut annotated_count: usize = 0;
|
||||
|
||||
for i in 0..n_revisions {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8; 4096]);
|
||||
let ann: Option<&str> = if (annotation_mask >> i) & 1 == 1 {
|
||||
annotated_count += 1;
|
||||
Some("keep")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
onion.commit_session(s, ann).unwrap();
|
||||
}
|
||||
|
||||
onion.gc(GcPolicy::KeepTagged).unwrap();
|
||||
|
||||
let revisions = onion.list_revisions();
|
||||
// All surviving revisions must have annotations.
|
||||
for rev in &revisions {
|
||||
prop_assert!(
|
||||
rev.annotation.is_some(),
|
||||
"revision {} survived KeepTagged but has no annotation", rev.revision
|
||||
);
|
||||
}
|
||||
// The count of survivors equals the number of annotated inputs.
|
||||
prop_assert_eq!(
|
||||
revisions.len(), annotated_count,
|
||||
"expected {} annotated revisions to survive, got {}",
|
||||
annotated_count, revisions.len()
|
||||
);
|
||||
assert_gc_invariants(&onion);
|
||||
}
|
||||
|
||||
/// `KeepSince(cutoff)` retains revisions whose timestamp >= cutoff and
|
||||
/// prunes those strictly below cutoff.
|
||||
///
|
||||
/// We use a synthetic test: all revisions are written in rapid succession so
|
||||
/// their timestamps cluster together. We then test two extremes:
|
||||
/// - `KeepSince(0.0)` — keeps everything (all timestamps post-epoch 0)
|
||||
/// - `KeepSince(far future)` — removes everything
|
||||
///
|
||||
/// For intermediate cutoffs we verify the monotonicity property: increasing
|
||||
/// the cutoff never *increases* the number of surviving revisions.
|
||||
#[test]
|
||||
fn prop_gc_keep_since_monotone_in_cutoff(
|
||||
n_revisions in 2usize..=8usize,
|
||||
) {
|
||||
// Write revisions; timestamps are set by the implementation (current time).
|
||||
let (_f, h5_path, mut onion) = fresh_onion(4096);
|
||||
for i in 0..n_revisions {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
onion.flush().unwrap();
|
||||
|
||||
// cutoff = 0 keeps everything (all real timestamps > Unix epoch 0).
|
||||
let mut o0 = OnionFile::open(&h5_path).unwrap();
|
||||
o0.gc(GcPolicy::KeepSince(0.0)).unwrap();
|
||||
let kept_at_epoch = o0.revision_count();
|
||||
prop_assert_eq!(
|
||||
kept_at_epoch, n_revisions as u64,
|
||||
"KeepSince(0) should keep all {} revisions", n_revisions
|
||||
);
|
||||
|
||||
// cutoff = far future removes everything.
|
||||
let far_future = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs_f64())
|
||||
.unwrap_or(0.0)
|
||||
+ 1_000_000.0;
|
||||
let mut o_future = OnionFile::open(&h5_path).unwrap();
|
||||
o_future.gc(GcPolicy::KeepSince(far_future)).unwrap();
|
||||
let kept_at_future = o_future.revision_count();
|
||||
prop_assert_eq!(
|
||||
kept_at_future, 0,
|
||||
"KeepSince(far future) should remove all revisions"
|
||||
);
|
||||
|
||||
// Monotonicity: kept_at_epoch >= kept_at_future (trivially: n >= 0).
|
||||
prop_assert!(
|
||||
kept_at_epoch >= kept_at_future,
|
||||
"more revisions kept at a tighter cutoff: {} > {}",
|
||||
kept_at_future, kept_at_epoch
|
||||
);
|
||||
}
|
||||
|
||||
/// GC followed by flush and reload preserves all surviving revisions.
|
||||
#[test]
|
||||
fn prop_gc_flush_reload_consistent(
|
||||
n_revisions in 2usize..=8usize,
|
||||
keep_n in 1u64..=8u64,
|
||||
) {
|
||||
let (_f, h5_path, mut onion) = fresh_onion(4096);
|
||||
for i in 0..n_revisions {
|
||||
let mut s = onion.begin_session(None).unwrap();
|
||||
s.record_page(0, &vec![i as u8; 4096]);
|
||||
onion.commit_session(s, None).unwrap();
|
||||
}
|
||||
|
||||
let keep_n = keep_n.min(n_revisions as u64);
|
||||
onion.gc(GcPolicy::KeepLastN(keep_n)).unwrap();
|
||||
let pre_flush_count = onion.revision_count();
|
||||
onion.flush().unwrap();
|
||||
|
||||
// Reload and verify.
|
||||
let reloaded = OnionFile::open(&h5_path).unwrap();
|
||||
prop_assert_eq!(reloaded.revision_count(), pre_flush_count,
|
||||
"revision count changed across flush+reload");
|
||||
assert_gc_invariants(&reloaded);
|
||||
|
||||
// All revisions must be readable after reload (using original IDs).
|
||||
for rev in reloaded.list_revisions().iter().map(|r| r.revision) {
|
||||
reloaded.revision_pages(rev)
|
||||
.unwrap_or_else(|e| panic!("revision_pages({rev}) failed after reload: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Property-based tests for [`VersionedFile`].
|
||||
//!
|
||||
//! These tests verify that the high-level `VersionedFile` API preserves
|
||||
//! reconstruction correctness under arbitrary sequences of interleaved
|
||||
//! branch commits, snapshots, and flush+reload cycles.
|
||||
//!
|
||||
//! ## Key invariant
|
||||
//!
|
||||
//! For any sequence of `commit_on(branch, new_bytes, ...)` calls, reconstructing
|
||||
//! the resulting revision must return exactly `new_bytes` (for the pages that
|
||||
//! were written). This is the correctness guarantee of the per-branch diff
|
||||
//! baseline (`branch_state` map inside `VersionedFile`).
|
||||
//!
|
||||
//! ## Fill-byte constraint
|
||||
//!
|
||||
//! All writes use fill bytes in 1..=255 so that every page always differs from
|
||||
//! `h5_base` (a 4 KiB zero buffer). This avoids the documented edge case where
|
||||
//! committing bytes equal to `h5_base` on a branch that has ancestors with
|
||||
//! different content would silently record an empty diff, making the commit
|
||||
//! indistinguishable from a no-op at the page level.
|
||||
|
||||
use clawhdf5_onion::versioned_file::VersionedFile;
|
||||
use proptest::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE: u32 = 4096;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Generators
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A single round of operations: optionally create a new branch, then commit
|
||||
/// on `branch_idx % current_branch_count` with `fill`.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Round {
|
||||
/// If true, fork a new branch from main before this commit.
|
||||
create_branch: bool,
|
||||
/// Raw index — clamped to `% len(branches)` during execution.
|
||||
branch_idx: usize,
|
||||
/// Fill byte for the 4 KiB page. Kept in 1..=255 so the page always
|
||||
/// differs from the zero-filled `h5_base`.
|
||||
fill: u8,
|
||||
}
|
||||
|
||||
fn arb_round() -> impl Strategy<Value = Round> {
|
||||
(any::<bool>(), 0usize..8usize, 1u8..=255u8).prop_map(|(create_branch, branch_idx, fill)| {
|
||||
Round { create_branch, branch_idx, fill }
|
||||
})
|
||||
}
|
||||
|
||||
fn arb_rounds(min: usize, max: usize) -> impl Strategy<Value = Vec<Round>> {
|
||||
proptest::collection::vec(arb_round(), min..=max)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create a temp dir with a 4 KiB zero base file and a fresh `VersionedFile`.
|
||||
fn make_vf() -> (TempDir, std::path::PathBuf, VersionedFile) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let h5 = dir.path().join("base.h5");
|
||||
std::fs::write(&h5, vec![0u8; PAGE_SIZE as usize]).unwrap();
|
||||
let vf = VersionedFile::create(&h5, PAGE_SIZE).unwrap();
|
||||
(dir, h5, vf)
|
||||
}
|
||||
|
||||
/// Apply `rounds` to `vf`, returning a Vec of `(revision_number, expected_page_bytes)`.
|
||||
///
|
||||
/// `branches` starts as `["main"]` and grows as new branches are created.
|
||||
fn apply_rounds(
|
||||
vf: &mut VersionedFile,
|
||||
rounds: &[Round],
|
||||
) -> Vec<(u64, Vec<u8>)> {
|
||||
let mut branches: Vec<String> = vec!["main".to_string()];
|
||||
let mut expectations: Vec<(u64, Vec<u8>)> = Vec::new();
|
||||
let mut branch_counter: usize = 0;
|
||||
|
||||
for round in rounds {
|
||||
// Optionally create a new branch forked from main.
|
||||
if round.create_branch {
|
||||
let name = format!("b{branch_counter}");
|
||||
branch_counter += 1;
|
||||
// create_branch may fail if a branch with the same name already exists,
|
||||
// which can't happen here since names are unique via the counter.
|
||||
vf.onion_mut().create_branch(&name, "main").unwrap();
|
||||
branches.push(name);
|
||||
}
|
||||
|
||||
let branch_name = &branches[round.branch_idx % branches.len()];
|
||||
let branch_opt = if branch_name == "main" {
|
||||
None
|
||||
} else {
|
||||
Some(branch_name.as_str())
|
||||
};
|
||||
|
||||
let new_bytes = vec![round.fill; PAGE_SIZE as usize];
|
||||
let rev = vf.commit_on(branch_opt, new_bytes.clone(), None).unwrap();
|
||||
expectations.push((rev, new_bytes));
|
||||
}
|
||||
|
||||
expectations
|
||||
}
|
||||
|
||||
/// Verify every `(rev, expected_bytes)` pair: reconstruct the revision from
|
||||
/// `vf` and check that page 0 matches `expected_bytes`.
|
||||
fn verify_expectations(
|
||||
vf: &VersionedFile,
|
||||
h5_base: &[u8],
|
||||
expectations: &[(u64, Vec<u8>)],
|
||||
) -> Result<(), TestCaseError> {
|
||||
for (rev, expected) in expectations {
|
||||
let actual = vf
|
||||
.onion()
|
||||
.reconstruct_revision(*rev, h5_base)
|
||||
.map_err(|e| TestCaseError::fail(format!("reconstruct_revision({rev}) failed: {e}")))?;
|
||||
|
||||
// Check that the first page matches exactly.
|
||||
let page_end = PAGE_SIZE as usize;
|
||||
prop_assert_eq!(
|
||||
&actual[..page_end],
|
||||
expected.as_slice(),
|
||||
"revision {}: page 0 mismatch", rev
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Property tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig {
|
||||
cases: 200,
|
||||
max_shrink_iters: 100,
|
||||
..ProptestConfig::default()
|
||||
})]
|
||||
|
||||
/// After any sequence of interleaved commits on multiple branches, every
|
||||
/// committed revision reconstructs to the exact bytes that were passed to
|
||||
/// `commit_on`.
|
||||
///
|
||||
/// This is the core invariant of the per-branch diff baseline: even with
|
||||
/// arbitrary interleaving of commits across branches, `reconstruct_revision`
|
||||
/// always yields back the bytes that were committed.
|
||||
#[test]
|
||||
fn prop_vf_interleaved_commits_reconstruct_correctly(
|
||||
rounds in arb_rounds(1, 16),
|
||||
) {
|
||||
let (_dir, h5, mut vf) = make_vf();
|
||||
let h5_base = std::fs::read(&h5).unwrap();
|
||||
|
||||
let expectations = apply_rounds(&mut vf, &rounds);
|
||||
verify_expectations(&vf, &h5_base, &expectations)?;
|
||||
}
|
||||
|
||||
/// After commits, flushing the sidecar to disk and reloading it via
|
||||
/// `VersionedFile::open` preserves every revision's content.
|
||||
///
|
||||
/// This checks that the on-disk serialisation + deserialization path is
|
||||
/// lossless for the high-level API.
|
||||
#[test]
|
||||
fn prop_vf_flush_reload_preserves_all_commits(
|
||||
rounds in arb_rounds(1, 12),
|
||||
) {
|
||||
let (_dir, h5, mut vf) = make_vf();
|
||||
let h5_base = std::fs::read(&h5).unwrap();
|
||||
|
||||
let expectations = apply_rounds(&mut vf, &rounds);
|
||||
// Flush is already called inside commit_on, but call it once more to
|
||||
// exercise the explicit flush path.
|
||||
vf.onion_mut().flush().unwrap();
|
||||
|
||||
// Reload from disk.
|
||||
let reloaded = VersionedFile::open(&h5).unwrap();
|
||||
verify_expectations(&reloaded, &h5_base, &expectations)?;
|
||||
}
|
||||
|
||||
/// After any commits on main followed by a manual snapshot, the snapshot
|
||||
/// revision reconstructs to the same bytes as the pre-snapshot HEAD.
|
||||
/// Subsequent commits on main still reconstruct correctly.
|
||||
///
|
||||
/// This verifies that inserting a snapshot does not corrupt the revision
|
||||
/// chain for future commits.
|
||||
#[test]
|
||||
fn prop_vf_snapshot_does_not_corrupt_reconstruction(
|
||||
pre_fills in proptest::collection::vec(1u8..=255u8, 1..=6usize),
|
||||
post_fills in proptest::collection::vec(1u8..=255u8, 0..=4usize),
|
||||
) {
|
||||
let (_dir, h5, mut vf) = make_vf();
|
||||
let h5_base = std::fs::read(&h5).unwrap();
|
||||
let mut expectations: Vec<(u64, Vec<u8>)> = Vec::new();
|
||||
|
||||
// Pre-snapshot commits on main.
|
||||
for &fill in &pre_fills {
|
||||
let bytes = vec![fill; PAGE_SIZE as usize];
|
||||
let rev = vf.commit_on(None, bytes.clone(), None).unwrap();
|
||||
expectations.push((rev, bytes));
|
||||
}
|
||||
|
||||
// Snapshot — must reconstruct to the same bytes as the last pre-commit.
|
||||
let snap_rev = vf.snapshot(Some("test snapshot")).unwrap();
|
||||
let last_expected = expectations.last().unwrap().1.clone();
|
||||
let snap_actual = vf.onion().reconstruct_revision(snap_rev, &h5_base).unwrap();
|
||||
prop_assert_eq!(
|
||||
&snap_actual[..PAGE_SIZE as usize],
|
||||
last_expected.as_slice(),
|
||||
"snapshot revision {} does not match pre-snapshot HEAD", snap_rev
|
||||
);
|
||||
|
||||
// Post-snapshot commits — reconstruction must remain correct.
|
||||
for &fill in &post_fills {
|
||||
let bytes = vec![fill; PAGE_SIZE as usize];
|
||||
let rev = vf.commit_on(None, bytes.clone(), None).unwrap();
|
||||
expectations.push((rev, bytes));
|
||||
}
|
||||
|
||||
verify_expectations(&vf, &h5_base, &expectations)?;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user