diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index b5cb21e5..124fc28f 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -18,3 +18,9 @@ path = "../src/table" path = "../src/model" features = ["arbitrary"] +[[bin]] +name = "version_crdt" +path = "fuzz_targets/version_crdt.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/version_crdt.rs b/fuzz/fuzz_targets/version_crdt.rs new file mode 100644 index 00000000..4be47666 --- /dev/null +++ b/fuzz/fuzz_targets/version_crdt.rs @@ -0,0 +1,94 @@ +#![no_main] + +use garage_model::s3::version_table::{Version, VersionBacklink, VersionBlock, VersionBlockKey}; +use garage_table::crdt::Crdt; +use libfuzzer_sys::fuzz_target; + +/// Build a Version from an arbitrary deleted flag and block list, using a fixed uuid/backlink +/// so that CRDT state can be compared across merge results. +/// Duplicate block keys are dropped before construction. +/// If deleted, blocks are cleared to ensure a valid initial CRDT state. +fn make_version(deleted: bool, mut blocks: Vec<(VersionBlockKey, VersionBlock)>) -> Version { + blocks.sort_by_key(|(k, _)| *k); + blocks.dedup_by_key(|(k, _)| *k); + let mut v = Version::new( + [0u8; 32].into(), + VersionBacklink::Object { + bucket_id: [0u8; 32].into(), + key: String::new(), + }, + deleted, + ); + for (key, block) in blocks { + v.blocks.put(key, block); + } + if v.deleted.get() { + v.blocks.clear(); + } + v +} + +fn crdt_state(v: &Version) -> (bool, &[(VersionBlockKey, VersionBlock)]) { + (v.deleted.get(), v.blocks.items()) +} + +fuzz_target!(|inputs: ( + (bool, Vec<(VersionBlockKey, VersionBlock)>), + (bool, Vec<(VersionBlockKey, VersionBlock)>), + (bool, Vec<(VersionBlockKey, VersionBlock)>) +)| { + let ((d1, b1), (d2, b2), (d3, b3)) = inputs; + let a = make_version(d1, b1); + let b = make_version(d2, b2); + let c = make_version(d3, b3); + + // Idempotency: merge(a, a) == a + { + let mut a2 = a.clone(); + a2.merge(&a.clone()); + assert_eq!( + crdt_state(&a2), + crdt_state(&a), + "merge is not idempotent: {a2:#?} != {a:#?}" + ); + } + + // Commutativity: crdt_state(merge(a, b)) == crdt_state(merge(b, a)) + let ab = { + let mut t = a.clone(); + t.merge(&b); + t + }; + let ba = { + let mut t = b.clone(); + t.merge(&a); + t + }; + assert_eq!( + crdt_state(&ab), + crdt_state(&ba), + "merge is not commutative: {ab:#?} != {ba:#?}" + ); + + // Associativity: crdt_state(merge(merge(a, b), c)) == crdt_state(merge(a, merge(b, c))) + let ab_c = { + let mut t = ab.clone(); + t.merge(&c); + t + }; + let bc = { + let mut t = b.clone(); + t.merge(&c); + t + }; + let a_bc = { + let mut t = a.clone(); + t.merge(&bc); + t + }; + assert_eq!( + crdt_state(&ab_c), + crdt_state(&a_bc), + "merge is not associative: {ab_c:#?} != {a_bc:#?}" + ); +}); diff --git a/src/model/s3/version_table.rs b/src/model/s3/version_table.rs index 45be5af8..13a37166 100644 --- a/src/model/s3/version_table.rs +++ b/src/model/s3/version_table.rs @@ -41,6 +41,7 @@ mod v08 { } #[derive(PartialEq, Eq, Clone, Copy, Debug, Serialize, Deserialize)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct VersionBlockKey { /// Number of the part pub part_number: u64, @@ -51,6 +52,7 @@ mod v08 { /// Information about a single block #[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Copy, Debug, Serialize, Deserialize)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct VersionBlock { /// Blake2 sum of the block pub hash: Hash, @@ -91,6 +93,7 @@ pub(crate) mod v09 { } #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum VersionBacklink { Object { /// Bucket in which the related object is stored