LeVCS/crates/levcs-core/tests/proptest_objects.rs

158 lines
5.6 KiB
Rust

//! Property tests for object body codecs.
//!
//! Complements `fuzz.rs` (which throws random bytes at parsers and asserts
//! no panic) with structured round-trips: build valid objects by
//! construction, serialize, parse, and assert structural equality. When
//! a property fails, proptest shrinks toward a minimal failing case.
use levcs_core::object::RawObject;
use levcs_core::{Blob, Commit, CommitFlags, EntryType, FileMode, ObjectId, Tree, TreeEntry};
use proptest::collection::vec;
use proptest::prelude::*;
/// A name that satisfies `TreeEntry::validate_name` — non-empty, ≤255 bytes,
/// no '/' or NUL, not "." or "..". We restrict to ASCII letters/digits/
/// underscore so the proptest generator stays simple and the names are
/// trivially unique-able.
fn name_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9_]{1,32}".prop_filter("reserved", |s| s != "." && s != "..")
}
fn object_id_strategy() -> impl Strategy<Value = ObjectId> {
any::<[u8; 32]>().prop_map(ObjectId)
}
fn entry_type_strategy() -> impl Strategy<Value = EntryType> {
prop_oneof![Just(EntryType::Blob), Just(EntryType::Tree)]
}
fn file_mode_strategy() -> impl Strategy<Value = FileMode> {
(0u8..=0b11).prop_map(FileMode)
}
fn tree_entry_strategy() -> impl Strategy<Value = TreeEntry> {
(
name_strategy(),
entry_type_strategy(),
file_mode_strategy(),
object_id_strategy(),
)
.prop_map(|(name, entry_type, mode, hash)| TreeEntry {
name,
entry_type,
mode,
hash,
})
}
/// Tree with unique-by-name entries (validated trees can't have dupes).
fn tree_strategy() -> impl Strategy<Value = Tree> {
vec(tree_entry_strategy(), 0..16).prop_map(|entries| {
// Dedupe by name: keep first occurrence so the property domain
// matches what `sort_and_validate` accepts.
let mut seen = std::collections::HashSet::new();
let unique: Vec<_> = entries
.into_iter()
.filter(|e| seen.insert(e.name.clone()))
.collect();
let mut t = Tree { entries: unique };
t.sort_and_validate().unwrap();
t
})
}
fn commit_flags_strategy() -> impl Strategy<Value = CommitFlags> {
(0u8..=0b11).prop_map(CommitFlags)
}
fn commit_strategy() -> impl Strategy<Value = Commit> {
(
object_id_strategy(),
vec(object_id_strategy(), 0..8),
object_id_strategy(),
any::<[u8; 32]>(),
any::<i64>(),
commit_flags_strategy(),
".{0,256}",
)
.prop_map(
|(tree, parents, authority, author_key, ts, flags, message)| Commit {
tree,
parents,
authority,
author_key,
timestamp_micros: ts,
flags,
message,
},
)
}
proptest! {
/// Tree::body → Tree::parse_body is a structural identity for any
/// valid tree.
#[test]
fn tree_body_roundtrip(t in tree_strategy()) {
let body = t.body();
let t2 = Tree::parse_body(&body).expect("valid tree must parse");
prop_assert_eq!(t.entries.len(), t2.entries.len());
for (a, b) in t.entries.iter().zip(t2.entries.iter()) {
prop_assert_eq!(&a.name, &b.name);
prop_assert_eq!(a.entry_type as u8, b.entry_type as u8);
prop_assert_eq!(a.mode.0, b.mode.0);
prop_assert_eq!(a.hash, b.hash);
}
}
/// Commit::body → Commit::parse_body is a structural identity for
/// any valid commit. Exercises every field including the variable-
/// length parents list and message.
#[test]
fn commit_body_roundtrip(c in commit_strategy()) {
let body = c.body().expect("valid commit must serialize");
let c2 = Commit::parse_body(&body).expect("valid commit must parse");
prop_assert_eq!(c.tree, c2.tree);
prop_assert_eq!(&c.parents, &c2.parents);
prop_assert_eq!(c.authority, c2.authority);
prop_assert_eq!(c.author_key, c2.author_key);
prop_assert_eq!(c.timestamp_micros, c2.timestamp_micros);
prop_assert_eq!(c.flags.0, c2.flags.0);
prop_assert_eq!(c.message, c2.message);
}
/// Blob serialize → RawObject::parse → Blob::from_body round-trip.
/// `Blob::serialize` wraps the bytes in an unsigned object frame, so
/// the natural inverse is to parse the frame and reconstruct.
#[test]
fn blob_roundtrip(bytes in vec(any::<u8>(), 0..16_384)) {
let blob = Blob::new(bytes.clone());
let serialized = blob.serialize();
let raw = RawObject::parse(&serialized).expect("valid blob frame must parse");
let parsed = Blob::from_body(raw.body);
prop_assert_eq!(parsed.bytes, bytes);
}
/// One-byte truncation of any valid tree body must not panic. The
/// fuzz suite covers random truncation; this version focuses proptest
/// shrinkage on the tightest failing case.
#[test]
fn tree_body_one_byte_short_does_not_panic(t in tree_strategy()) {
let body = t.body();
if !body.is_empty() {
let short = &body[..body.len() - 1];
let _ = Tree::parse_body(short);
}
}
/// Same property for commits — truncate the body by one byte and
/// require the parser to return an error rather than panic.
#[test]
fn commit_body_one_byte_short_does_not_panic(c in commit_strategy()) {
let body = c.body().expect("valid commit must serialize");
if !body.is_empty() {
let short = &body[..body.len() - 1];
let _ = Commit::parse_body(short);
}
}
}