diff --git a/Cargo.lock b/Cargo.lock index a8621e8d..06e65724 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -1116,6 +1125,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1523,6 +1543,17 @@ dependencies = [ "utoipa", ] +[[package]] +name = "garage-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "garage_db", + "garage_model", + "garage_table", + "libfuzzer-sys", +] + [[package]] name = "garage_api_admin" version = "2.3.0" @@ -1702,6 +1733,7 @@ dependencies = [ name = "garage_model" version = "2.3.0" dependencies = [ + "arbitrary", "argon2", "async-trait", "base64 0.22.1", @@ -1810,6 +1842,7 @@ dependencies = [ name = "garage_util" version = "2.3.0" dependencies = [ + "arbitrary", "arc-swap", "async-trait", "blake2", @@ -2934,6 +2967,16 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libsodium-sys" version = "0.2.7" diff --git a/Cargo.toml b/Cargo.toml index 9cd06778..3ff6d5a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "src/garage", "src/k2v-client", "src/format-table", + "fuzz", ] default-members = ["src/garage"] @@ -40,6 +41,7 @@ k2v-client = { version = "0.0.4", path = "src/k2v-client" } # External crates from crates.io arc-swap = "1.8" +arbitrary = { version = "1.4.2"} argon2 = "0.5" async-trait = "0.1" backtrace = "0.3" @@ -59,6 +61,7 @@ hmac = "0.12" itertools = "0.14" ipnet = "2.11" lazy_static = "1.5" +libfuzzer-sys = "0.4" md-5 = "0.10" mktemp = "0.5" nix = { version = "0.31", default-features = false, features = ["fs"] } diff --git a/flake.nix b/flake.nix index 00f0efe1..25a3fee5 100644 --- a/flake.nix +++ b/flake.nix @@ -95,6 +95,14 @@ killall ]; }; + + # dev shell for fuzzing + fuzz = pkgs.mkShell { + buildInputs = with pkgs; [ + targets.toolchainNightly + cargo-fuzz + ]; + }; }; }); } diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 00000000..1a45eee7 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..40b0a118 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "garage-fuzz" +version = "0.0.0" +publish = false +edition = "2018" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +arbitrary = { workspace = true, features = ["derive"]} +libfuzzer-sys = { workspace = true } + +garage_db.workspace = true +garage_table.workspace = true +garage_model = { workspace = true, default-features = false, features = ["arbitrary"] } + +[[bin]] +name = "version_crdt" +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/README.md b/fuzz/README.md new file mode 100644 index 00000000..c098e5a8 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,11 @@ +# Fuzzing + +## Setup + +Install cargo fuzz: `cargo install cargo-fuzz` + +## Launch + +Run `cargo fuzz run ` where `` is the name (without extension) of one of the `.rs` files in the `fuzz_targets` directory. + +If you launch the command outside of the fuzz directory, you need to force the nightly toolchain with `cargo +nightly`. \ No newline at end of file diff --git a/fuzz/fuzz_targets/mpu_crdt.rs b/fuzz/fuzz_targets/mpu_crdt.rs new file mode 100644 index 00000000..82d6b613 --- /dev/null +++ b/fuzz/fuzz_targets/mpu_crdt.rs @@ -0,0 +1,93 @@ +#![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. +/// `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, parts: Vec<(MpuPartKey, MpuPart)>) -> MultipartUpload { + 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/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/fuzz/rust-toolchain.toml b/fuzz/rust-toolchain.toml new file mode 100644 index 00000000..5d56faf9 --- /dev/null +++ b/fuzz/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/nix/compile.nix b/nix/compile.nix index 799bcc10..da81f093 100644 --- a/nix/compile.nix +++ b/nix/compile.nix @@ -148,6 +148,14 @@ let in rec { toolchain = toolchainFn pkgs; + toolchainNightly = pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override { + targets = lib.optionals (target != null) [ rustTarget ]; + extensions = [ + "rust-src" + "rustfmt" + ]; + }); + devShell = pkgs.mkShell { buildInputs = [ toolchain diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index 12683a0e..bb3d9aac 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -21,6 +21,7 @@ garage_block.workspace = true garage_util.workspace = true garage_net.workspace = true +arbitrary = { optional = true, workspace = true } argon2.workspace = true async-trait.workspace = true blake2.workspace = true @@ -46,6 +47,7 @@ k2v = ["garage_util/k2v"] lmdb = ["garage_db/lmdb"] sqlite = ["garage_db/sqlite"] fjall = ["garage_db/fjall"] +arbitrary = ["dep:arbitrary","garage_util/arbitrary"] [lints] workspace = true 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]), diff --git a/src/model/s3/version_table.rs b/src/model/s3/version_table.rs index 45be5af8..5124f556 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, diff --git a/src/util/Cargo.toml b/src/util/Cargo.toml index cbba213c..37799fd7 100644 --- a/src/util/Cargo.toml +++ b/src/util/Cargo.toml @@ -17,6 +17,7 @@ path = "lib.rs" garage_db.workspace = true garage_net.workspace = true +arbitrary = { optional = true, workspace = true } arc-swap.workspace = true async-trait.workspace = true blake2.workspace = true @@ -52,6 +53,7 @@ mktemp.workspace = true [features] k2v = [] +arbitrary = ["dep:arbitrary"] [lints] workspace = true diff --git a/src/util/data.rs b/src/util/data.rs index e4ce316b..63c4379f 100644 --- a/src/util/data.rs +++ b/src/util/data.rs @@ -6,6 +6,7 @@ use std::fmt; /// An array of 32 bytes #[derive(Default, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Copy)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FixedBytes32([u8; 32]); impl From<[u8; 32]> for FixedBytes32 { diff --git a/typos.toml b/typos.toml index f76d002d..70d63060 100644 --- a/typos.toml +++ b/typos.toml @@ -3,4 +3,4 @@ PN = "PN" substituters = "substituters" [files] -extend-exclude = ["CHANGELOG.md", "**.js", "**.svg", "doc/talks/*"] +extend-exclude = ["CHANGELOG.md", "**.js", "**.svg", "doc/talks/*", "fuzz/*"]