From cc84d9065e34d223cfce7be379ca6be4b81dfc56 Mon Sep 17 00:00:00 2001 From: houseme Date: Thu, 18 Jun 2026 00:36:55 +0800 Subject: [PATCH] test(property): add filemeta invariants (#3536) * test(property): add filemeta invariants for #3523 * docs: relocate issue-3518 property testing plan * docs: keep issue-3518 property plan local only --- Cargo.lock | 1 + crates/filemeta/Cargo.toml | 1 + crates/filemeta/src/fileinfo.rs | 210 ++++++++++++++++++++++++++++++++ crates/filemeta/src/filemeta.rs | 10 ++ 4 files changed, 222 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 76d9c6f1b..bebfbecf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9439,6 +9439,7 @@ dependencies = [ "bytes", "crc-fast", "criterion", + "proptest", "regex", "rmp", "rmp-serde", diff --git a/crates/filemeta/Cargo.toml b/crates/filemeta/Cargo.toml index ba4846463..c35449bed 100644 --- a/crates/filemeta/Cargo.toml +++ b/crates/filemeta/Cargo.toml @@ -46,6 +46,7 @@ arc-swap.workspace = true [dev-dependencies] criterion = { workspace = true } tempfile = { workspace = true } +proptest = "1" [[bench]] name = "xl_meta_bench" diff --git a/crates/filemeta/src/fileinfo.rs b/crates/filemeta/src/fileinfo.rs index 353aba8c1..679048599 100644 --- a/crates/filemeta/src/fileinfo.rs +++ b/crates/filemeta/src/fileinfo.rs @@ -680,3 +680,213 @@ pub fn is_restored_object_on_disk(meta: &HashMap) -> bool { } false } + +#[cfg(test)] +mod tests { + use super::*; + use proptest::collection::{hash_map, vec}; + use proptest::prelude::*; + + fn small_string_strategy() -> impl Strategy { + proptest::string::string_regex("[A-Za-z0-9._/-]{0,16}").expect("small string regex should compile") + } + + fn small_required_string_strategy() -> impl Strategy { + proptest::string::string_regex("[A-Za-z0-9._/-]{1,16}").expect("required string regex should compile") + } + + fn bytes_strategy(max_len: usize) -> impl Strategy { + vec(any::(), 0..=max_len).prop_map(Bytes::from) + } + + fn uuid_strategy() -> impl Strategy { + any::<[u8; 16]>().prop_map(Uuid::from_bytes) + } + + fn optional_uuid_strategy() -> impl Strategy> { + proptest::option::of(uuid_strategy()) + } + + fn timestamp_strategy() -> impl Strategy { + (0i64..=4_102_444_800i64) + .prop_map(|seconds| OffsetDateTime::from_unix_timestamp(seconds).expect("bounded timestamp should be valid")) + } + + fn optional_timestamp_strategy() -> impl Strategy> { + proptest::option::of(timestamp_strategy()) + } + + fn hash_algorithm_strategy() -> impl Strategy { + prop_oneof![ + Just(HashAlgorithm::SHA256), + Just(HashAlgorithm::HighwayHash256), + Just(HashAlgorithm::HighwayHash256S), + Just(HashAlgorithm::HighwayHash256SLegacy), + Just(HashAlgorithm::BLAKE2b512), + Just(HashAlgorithm::Md5), + Just(HashAlgorithm::None), + ] + } + + fn checksum_info_strategy() -> impl Strategy { + (0usize..=4, hash_algorithm_strategy(), bytes_strategy(32)).prop_map(|(part_number, algorithm, hash)| ChecksumInfo { + part_number, + algorithm, + hash, + }) + } + + fn erasure_info_strategy() -> impl Strategy { + (1usize..=4, 0usize..=3, 2usize..=4096) + .prop_flat_map(|(data_blocks, parity_blocks, block_size)| { + let total_blocks = data_blocks + parity_blocks; + ( + Just(data_blocks), + Just(parity_blocks), + Just(block_size), + 0usize..=total_blocks, + vec(1usize..=(total_blocks.max(1)), total_blocks), + vec(checksum_info_strategy(), 0..=3), + small_required_string_strategy(), + ) + }) + .prop_map( + |(data_blocks, parity_blocks, block_size, index, distribution, checksums, algorithm)| ErasureInfo { + algorithm, + data_blocks, + parity_blocks, + block_size, + index, + distribution, + checksums, + }, + ) + } + + fn object_part_info_strategy() -> impl Strategy { + ( + small_string_strategy(), + 0usize..=8, + 0usize..=4096, + -1_000_000i64..=1_000_000i64, + optional_timestamp_strategy(), + proptest::option::of(bytes_strategy(16)), + proptest::option::of(hash_map(small_string_strategy(), small_string_strategy(), 0..=3)), + proptest::option::of(small_string_strategy()), + ) + .prop_map(|(etag, number, size, actual_size, mod_time, index, checksums, error)| ObjectPartInfo { + etag, + number, + size, + actual_size, + mod_time, + index, + checksums, + error, + }) + } + + fn file_info_strategy() -> impl Strategy { + ( + ( + small_string_strategy(), + small_string_strategy(), + optional_uuid_strategy(), + any::(), + any::(), + small_string_strategy(), + small_string_strategy(), + small_string_strategy(), + optional_uuid_strategy(), + any::(), + ), + ( + optional_uuid_strategy(), + optional_timestamp_strategy(), + -1_000_000i64..=1_000_000i64, + proptest::option::of(any::()), + proptest::option::of(any::()), + hash_map(small_string_strategy(), small_string_strategy(), 0..=4), + vec(object_part_info_strategy(), 0..=3), + erasure_info_strategy(), + any::(), + proptest::option::of(bytes_strategy(32)), + ), + ( + 0usize..=4, + optional_timestamp_strategy(), + any::(), + 0usize..=4, + proptest::option::of(bytes_strategy(32)), + any::(), + any::(), + ), + ) + .prop_map(|(head, middle, tail)| { + let ( + volume, + name, + version_id, + is_latest, + deleted, + transition_status, + transitioned_objname, + transition_tier, + transition_version_id, + expire_restored, + ) = head; + let (data_dir, mod_time, size, mode, written_by_version, metadata, parts, erasure, mark_deleted, data) = middle; + let (num_versions, successor_mod_time, fresh, idx, checksum, versioned, uses_legacy_checksum) = tail; + + FileInfo { + volume, + name, + version_id, + is_latest, + deleted, + transition_status, + transitioned_objname, + transition_tier, + transition_version_id, + expire_restored, + data_dir, + mod_time, + size, + mode, + written_by_version, + metadata, + parts, + erasure, + mark_deleted, + replication_state_internal: None, + data, + num_versions, + successor_mod_time, + fresh, + idx, + checksum, + versioned, + uses_legacy_checksum, + } + }) + } + + proptest! { + #[test] + fn fileinfo_msgpack_round_trips(value in file_info_strategy()) { + let encoded = value + .marshal_msg() + .expect("FileInfo should serialize in the property roundtrip test"); + let decoded = FileInfo::unmarshal(&encoded) + .expect("FileInfo should deserialize in the property roundtrip test"); + + prop_assert_eq!(decoded, value); + } + + #[test] + fn fileinfo_unmarshal_never_panics(input in vec(any::(), 0..=1024)) { + let result = std::panic::catch_unwind(|| FileInfo::unmarshal(&input)); + prop_assert!(result.is_ok(), "FileInfo::unmarshal panicked for arbitrary input"); + } + } +} diff --git a/crates/filemeta/src/filemeta.rs b/crates/filemeta/src/filemeta.rs index e86aad93b..022d19caa 100644 --- a/crates/filemeta/src/filemeta.rs +++ b/crates/filemeta/src/filemeta.rs @@ -910,6 +910,8 @@ impl FileMeta { mod test { use super::*; use crate::test_data::*; + use proptest::collection::vec; + use proptest::prelude::*; #[test] fn test_new_file_meta() { @@ -1339,6 +1341,14 @@ mod test { } } + proptest! { + #[test] + fn filemeta_load_never_panics_on_arbitrary_bytes(input in vec(any::(), 0..=4096)) { + let result = std::panic::catch_unwind(|| FileMeta::load(&input)); + prop_assert!(result.is_ok(), "FileMeta::load panicked for arbitrary input"); + } + } + #[test] fn test_performance_with_large_metadata() { // Test performance with large metadata files