diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 124fc28f..899416e5 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -24,3 +24,10 @@ path = "fuzz_targets/version_crdt.rs" test = false doc = false bench = false + +[[bin]] +name = "mpu_crdt" +path = "fuzz_targets/mpu_crdt.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/mpu_crdt.rs b/fuzz/fuzz_targets/mpu_crdt.rs new file mode 100644 index 00000000..b3e5130e --- /dev/null +++ b/fuzz/fuzz_targets/mpu_crdt.rs @@ -0,0 +1,96 @@ +#![no_main] + +use garage_model::s3::mpu_table::{MpuPart, MpuPartKey, MultipartUpload}; +use garage_table::crdt::Crdt; +use libfuzzer_sys::fuzz_target; + +/// Build a MultipartUpload from an arbitrary deleted flag and parts list, using a fixed +/// upload_id/bucket_id/key so that CRDT state can be compared across merge results. +/// Duplicate part keys are dropped before construction. +/// `MpuPart.version` is fixed to a constant since it is identity data, not CRDT state: +/// two replicas of the same part (same MpuPartKey) always share the same version UUID. +/// If deleted, parts are cleared to ensure a valid initial CRDT state. +fn make_mpu(deleted: bool, mut parts: Vec<(MpuPartKey, MpuPart)>) -> MultipartUpload { + parts.sort_by_key(|(k, _)| *k); + parts.dedup_by_key(|(k, _)| *k); + let mut mpu = MultipartUpload::new( + [0u8; 32].into(), + 0, + [0u8; 32].into(), + String::new(), + deleted, + ); + for (key, mut part) in parts { + part.version = [0u8; 32].into(); + mpu.parts.put(key, part); + } + if mpu.deleted.get() { + mpu.parts.clear(); + } + mpu +} + +fn crdt_state(mpu: &MultipartUpload) -> (bool, &[(MpuPartKey, MpuPart)]) { + (mpu.deleted.get(), mpu.parts.items()) +} + +fuzz_target!(|inputs: ( + (bool, Vec<(MpuPartKey, MpuPart)>), + (bool, Vec<(MpuPartKey, MpuPart)>), + (bool, Vec<(MpuPartKey, MpuPart)>) +)| { + let ((d1, p1), (d2, p2), (d3, p3)) = inputs; + let a = make_mpu(d1, p1); + let b = make_mpu(d2, p2); + let c = make_mpu(d3, p3); + + // 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/mpu_table.rs b/src/model/s3/mpu_table.rs index 52734d27..dcae6344 100644 --- a/src/model/s3/mpu_table.rs +++ b/src/model/s3/mpu_table.rs @@ -48,6 +48,7 @@ mod v09 { } #[derive(PartialEq, Eq, Clone, Copy, Debug, Serialize, Deserialize)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MpuPartKey { /// Number of the part pub part_number: u64, @@ -57,6 +58,7 @@ mod v09 { /// The version of an uploaded part #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MpuPart { /// Links to a Version in `VersionTable` pub version: Uuid, diff --git a/src/model/s3/object_table.rs b/src/model/s3/object_table.rs index aff4bbfd..b5bb7584 100644 --- a/src/model/s3/object_table.rs +++ b/src/model/s3/object_table.rs @@ -291,6 +291,7 @@ mod v010 { /// Checksum value for x-amz-checksum-algorithm #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug, Serialize, Deserialize)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ChecksumValue { Crc32(#[serde(with = "serde_bytes")] [u8; 4]), Crc32c(#[serde(with = "serde_bytes")] [u8; 4]),