diff --git a/Cargo.lock b/Cargo.lock index fd6ffe9cd..eba65b8a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9362,6 +9362,7 @@ dependencies = [ "parking_lot 0.12.5", "path-absolutize", "pin-project-lite", + "proptest", "quick-xml 0.40.1", "rand 0.10.1", "ratelimit", diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index e9ec8d41f..36d7f021b 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -140,6 +140,7 @@ temp-env = { workspace = true, features = ["async_closure"] } tracing-subscriber = { workspace = true, features = ["json"] } serial_test = { workspace = true } opentelemetry_sdk = { workspace = true } +proptest = "1" [build-dependencies] shadow-rs = { workspace = true, features = ["build", "metadata"] } diff --git a/crates/ecstore/src/erasure_coding/erasure.rs b/crates/ecstore/src/erasure_coding/erasure.rs index d8919cdbb..f185b6a13 100644 --- a/crates/ecstore/src/erasure_coding/erasure.rs +++ b/crates/ecstore/src/erasure_coding/erasure.rs @@ -687,11 +687,43 @@ impl Erasure { #[cfg(test)] mod tests { use super::*; + use proptest::collection::{btree_set, vec}; + use proptest::prelude::*; fn optional_shards(shards: &[Bytes]) -> Vec>> { shards.iter().map(|shard| Some(shard.to_vec())).collect() } + fn recover_data(shards: &[Option>], data_shards: usize, original_len: usize) -> Vec { + let mut recovered = Vec::new(); + for shard in shards.iter().take(data_shards) { + recovered.extend_from_slice( + shard + .as_ref() + .expect("reconstructed data shard should be present after decode_data_and_parity"), + ); + } + recovered.truncate(original_len); + recovered + } + + fn erasure_recoverability_case_strategy() + -> impl Strategy, std::collections::BTreeSet)> { + (2usize..=8, 1usize..=4, prop::sample::select(vec![64usize, 256, 1024]), any::()).prop_flat_map( + |(data_shards, parity_shards, block_size, uses_legacy)| { + let total_shards = data_shards + parity_shards; + ( + Just(data_shards), + Just(parity_shards), + Just(block_size), + Just(uses_legacy), + vec(any::(), 0..=4096), + btree_set(0usize..total_shards, 0..=parity_shards), + ) + }, + ) + } + fn assert_owned_encode_matches_borrowed(erasure: &Erasure, data: Vec) { let borrowed = erasure.encode_data(&data).expect("borrowed encode should succeed"); let owned = erasure.encode_data_owned(data).expect("owned encode should succeed"); @@ -837,6 +869,38 @@ mod tests { assert_eq!(erasure.shard_file_size(1572864), 131073); } + proptest! { + #[test] + fn decode_data_and_parity_round_trips_bounded_recoverability( + (data_shards, parity_shards, block_size, uses_legacy, data, missing_indices) in + erasure_recoverability_case_strategy(), + ) { + let erasure = Erasure::new_with_options(data_shards, parity_shards, block_size, uses_legacy); + let encoded = erasure.encode_data(&data) + .expect("encode_data should succeed for bounded recoverability property cases"); + let mut shards = optional_shards(&encoded); + + for index in &missing_indices { + shards[*index] = None; + } + + erasure.decode_data_and_parity(&mut shards) + .expect("decode_data_and_parity should recover when missing shard count does not exceed parity"); + + let recovered = recover_data(&shards, data_shards, data.len()); + prop_assert_eq!(recovered, data); + + for (index, shard) in shards.iter().enumerate() { + prop_assert_eq!( + shard.as_deref(), + Some(encoded[index].as_ref()), + "reconstructed shard {} should match the original encoded shard", + index + ); + } + } + } + #[test] fn test_shard_file_size_cases() { let erasure = Erasure::new(4, 2, 8);