diff --git a/.github/workflows/ci-fuzz.yml b/.github/workflows/ci-fuzz.yml new file mode 100644 index 0000000..c93c021 --- /dev/null +++ b/.github/workflows/ci-fuzz.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +# SPDX-License-Identifier: MIT + +name: CI (Build + Short Fuzz) + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-fuzz: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@stable + + - name: Install nightly toolchain for fuzzing + run: rustup toolchain install nightly + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + + - name: Build library + run: cargo build --release + + - name: Run short fuzz test (60s) + run: cargo +nightly fuzz run fuzz_map_basic -- -max_total_time=60 + diff --git a/.github/workflows/nightly-fuzz.yml b/.github/workflows/nightly-fuzz.yml new file mode 100644 index 0000000..e2c9a36 --- /dev/null +++ b/.github/workflows/nightly-fuzz.yml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +# SPDX-License-Identifier: MIT + +name: Nightly Fuzz Testing + +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + +jobs: + nightly-fuzz: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchains + run: | + rustup toolchain install nightly + rustup default nightly + cargo install cargo-fuzz + + - name: Enable sanitizers + run: | + export RUSTFLAGS="-Zsanitizer=address" + export ASAN_OPTIONS=detect_leaks=1 + + - name: Run extended fuzzing (1h) + run: cargo +nightly fuzz run fuzz_map_basic -- -max_total_time=3600 + + - name: Upload fuzz artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crashes + path: fuzz/artifacts/fuzz_map_basic/ + diff --git a/.gitignore b/.gitignore index ac212c2..c23bc84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.DS_Store +.DS_Store .idea/ *.bak *.relf @@ -7,3 +7,4 @@ bin/ node_modules/ target/ tmp/ +fuzz/artifacts/ diff --git a/Cargo.toml b/Cargo.toml index 378c6a4..df0e214 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +# SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko # SPDX-License-Identifier: MIT [package] @@ -12,6 +12,7 @@ readme = "README.md" license = "MIT" homepage = "https://github.com/yegor256/micromap" keywords = ["memory", "map"] +exclude = ["fuzz"] categories = ["data-structures", "memory-management"] [dependencies] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..f41d30b --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +# SPDX-License-Identifier: MIT + +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.2.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "micromap" +version = "0.0.0" + +[[package]] +name = "micromap-fuzz" +version = "0.1.0" +dependencies = [ + "arbitrary", + "libfuzzer-sys", + "micromap", + "rand", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..8860679 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "micromap-fuzz" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +micromap = { path = "../" } +libfuzzer-sys = "0.4" +arbitrary = "1.3" + +[dev-dependencies] +rand = "0.9.2" + +[profile.release] +debug = true + +[workspace] +# Keep this fuzz crate isolated from the main workspace. + +[package.metadata] +cargo-fuzz = true + +[[bin]] +name = "fuzz_map_basic" +path = "fuzz_targets/fuzz_map_basic.rs" diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..bc3f984 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,84 @@ +# Fuzz Testing for Issue #299 + +This directory hosts the fuzzing infrastructure that closes [Issue #299](https://github.com/yegor256/micromap/issues/299): *"we don''t use fuzz testing"*. The goal is to continuously stress the `micromap::Map` API against random sequences of operations and keep parity with a reference `HashMap` implementation. + +## Layout + +- `Cargo.toml` — standalone fuzz crate (`micromap-fuzz`). +- `src/` — shared support code: + - `input.rs` defines the `Op` enum, probabilities, and bounds (`MAX_OPS = 64`). + - `apply.rs` executes operations on both `micromap::Map` and the shadow `HashMap`, using `checked_insert` to avoid aborts. +- `fuzz_targets/fuzz_map_basic.rs` — main fuzz target wired into libFuzzer. +- `corpus/` (auto-created) — initial inputs; empty by default. +- `artifacts/` — crashes, timeouts, hangs produced by the fuzzer. +- `fuzz-findings.md` — running log of investigations (see also `tests/regressions/README.md`). + +## Prerequisites + +- Rust stable toolchain (for the library itself). +- Rust nightly toolchain with minimal profile: + ```bash + rustup toolchain install nightly --profile minimal + ``` +- `cargo-fuzz` binary: + ```bash + cargo install cargo-fuzz + ``` + +On Windows the recommended approach is to run fuzzing under WSL/Ubuntu to avoid missing `clang_rt.fuzzer` DLLs. + +## Quick Verification Steps + +Run from the repository root (`~/micromap`): + +```bash +cargo fmt --all +cargo check +cargo test +cargo check --manifest-path fuzz/Cargo.toml +cargo +nightly fuzz run fuzz_map_basic -- -max_total_time=60 +``` + +The final command performs a 60-second fuzz campaign using libFuzzer. Adjust the duration via `-max_total_time=`. + +## Interpreting Results + +- Crashes are stored under `fuzz/artifacts/fuzz_map_basic/` with a `crash-*` filename. +- Reproduce a crash locally: + ```bash + cargo fuzz run fuzz_map_basic fuzz/artifacts/fuzz_map_basic/ + ``` +- Minimise an input before turning it into a regression test: + ```bash + cargo fuzz tmin fuzz_map_basic fuzz/artifacts/fuzz_map_basic/ + ``` +- Add a deterministic regression test to `tests/regressions/` using the `template.rs` scaffold and document it in `fuzz/fuzz-findings.md`. + +The `apply_op` helper keeps the shadow `HashMap` aligned even when the map reaches capacity, so panics should only indicate genuine bugs. + +## CI Integration + +Two GitHub Actions workflows exercise the fuzz target: + +- `.github/workflows/ci-fuzz.yml` — short (~60s) sanity check on each PR. +- `.github/workflows/nightly-fuzz.yml` — hourly fuzzing with sanitizers and artifact upload on failure. + +## Suggested Enhancements + +- Save the recommended dictionary produced by `cargo fuzz` into `fuzz/dictionaries/` to accelerate future campaigns. +- Track outstanding findings in `fuzz/fuzz-findings.md` and mirror resolved ones in regression tests. + +## Useful Commands + +```bash +# Run with a persistent corpus directory +cargo +nightly fuzz run fuzz_map_basic -- -max_total_time=300 + +# Reset corpus/artifacts +rm -rf fuzz/corpus/fuzz_map_basic fuzz/artifacts/fuzz_map_basic + +# Switch to a different fuzzing mutator budget +cargo +nightly fuzz run fuzz_map_basic -- -runs=100000 +``` + +Maintain this README alongside the fuzz crate whenever the API or workflows evolve. diff --git a/fuzz/fuzz-findings.md b/fuzz/fuzz-findings.md new file mode 100644 index 0000000..2044299 --- /dev/null +++ b/fuzz/fuzz-findings.md @@ -0,0 +1,10 @@ +# Fuzz Findings Log + +| artifact_path | status | repro_steps | owner | regression | +|---------------|--------|-------------|-------|------------| +| _example:_ `fuzz/artifacts/fuzz_map_basic/crash-20251018-120000` | triage | `cargo +nightly fuzz run fuzz_map_basic fuzz/artifacts/fuzz_map_basic/crash-20251018-120000` | @owner | _pending_ | + +Statuses: `triage`, `investigating`, `fixed`, `won't-fix`. + +When a finding is resolved, update the `regression` column with a link to the test or PR that shipped the fix. + diff --git a/fuzz/fuzz_targets/fuzz_map_basic.rs b/fuzz/fuzz_targets/fuzz_map_basic.rs new file mode 100644 index 0000000..9efeb96 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_map_basic.rs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +// SPDX-License-Identifier: MIT + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use micromap::Map; +use std::collections::{HashMap, HashSet}; + +use micromap_fuzz::{apply_op, FuzzInput, MAX_CAPACITY, MAX_OPS}; + +fuzz_target!(|data: FuzzInput| { + let mut map = Map::::new(); + let mut shadow = HashMap::::new(); + + for op in data.ops.iter().take(MAX_OPS) { + apply_op(&mut map, &mut shadow, op); + } + + let lhs: HashSet<_> = map.iter().map(|(k, v)| (*k, *v)).collect(); + let rhs: HashSet<_> = shadow.iter().map(|(k, v)| (*k, *v)).collect(); + assert_eq!(map.len(), shadow.len(), "final length mismatch"); + assert_eq!(lhs, rhs, "final content mismatch"); +}); + diff --git a/fuzz/src/apply.rs b/fuzz/src/apply.rs new file mode 100644 index 0000000..8767b33 --- /dev/null +++ b/fuzz/src/apply.rs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +// SPDX-License-Identifier: MIT + +use std::collections::{HashMap, HashSet}; + +use micromap::Map; + +use crate::{input::Op, MAX_CAPACITY}; + +pub fn apply_op( + map: &mut Map, + shadow: &mut HashMap, + op: &Op, +) { + match *op { + Op::Insert { key, value } => { + match map.checked_insert(key, value) { + Some(Some(old)) => { + let prev = shadow.insert(key, value); + assert_eq!(prev, Some(old), "shadow must replace the same value"); + } + Some(None) => { + let prev = shadow.insert(key, value); + assert!(prev.is_none(), "shadow unexpectedly replaced a value"); + } + None => { + // Map is full and the key was absent. Shadow should mirror this state. + assert!(!shadow.contains_key(&key)); + assert_eq!(map.len(), MAX_CAPACITY); + assert_eq!(shadow.len(), MAX_CAPACITY); + } + } + } + Op::Remove { key } => { + let left = map.remove(&key); + let right = shadow.remove(&key); + assert_eq!(left, right, "remove mismatch for key {key}"); + } + Op::Get { key } => { + let left = map.get(&key); + let right = shadow.get(&key); + assert_eq!(left, right, "get mismatch for key {key}"); + } + Op::Iterate => { + let lhs: HashSet<_> = map.iter().map(|(k, v)| (*k, *v)).collect(); + let rhs: HashSet<_> = shadow.iter().map(|(k, v)| (*k, *v)).collect(); + assert_eq!(lhs, rhs, "iter mismatch"); + } + Op::CloneMap => { + let cloned = map.clone(); + for (key, value) in map.iter() { + assert_eq!(cloned.get(key), Some(value), "clone mismatch for key {key}"); + } + } + } + + debug_assert_eq!(map.len(), shadow.len(), "length divergence after apply_op"); +} + diff --git a/fuzz/src/input.rs b/fuzz/src/input.rs new file mode 100644 index 0000000..2793ec0 --- /dev/null +++ b/fuzz/src/input.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +// SPDX-License-Identifier: MIT + +use arbitrary::{Arbitrary, Result, Unstructured}; + +pub const MAX_OPS: usize = 64; + +#[derive(Clone, Copy, Debug)] +pub enum Op { + Insert { key: u8, value: u8 }, + Get { key: u8 }, + Remove { key: u8 }, + Iterate, + CloneMap, +} + +#[derive(Clone, Debug)] +pub struct FuzzInput { + pub ops: Vec, +} + +impl<'a> Arbitrary<'a> for Op { + fn arbitrary(u: &mut Unstructured<'a>) -> Result { + let roll = u.int_in_range(0..=99)?; + let op = match roll { + 0..=39 => Self::Insert { + key: u8::arbitrary(u)?, + value: u8::arbitrary(u)?, + }, + 40..=59 => Self::Remove { + key: u8::arbitrary(u)?, + }, + 60..=84 => Self::Get { + key: u8::arbitrary(u)?, + }, + 85..=94 => Self::Iterate, + _ => Self::CloneMap, + }; + Ok(op) + } +} + +impl<'a> Arbitrary<'a> for FuzzInput { + fn arbitrary(u: &mut Unstructured<'a>) -> Result { + let len = usize::min(u.int_in_range(0..=MAX_OPS)?, MAX_OPS); + let mut ops = Vec::with_capacity(len); + for _ in 0..len { + ops.push(Op::arbitrary(u)?); + } + Ok(Self { ops }) + } +} + diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs new file mode 100644 index 0000000..871fa37 --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +// SPDX-License-Identifier: MIT + +#![allow(clippy::new_without_default)] + +pub mod apply; +pub mod input; + +pub const MAX_CAPACITY: usize = 16; + +pub use apply::apply_op; +pub use input::{FuzzInput, Op, MAX_OPS}; + diff --git a/tests/regressions/README.md b/tests/regressions/README.md new file mode 100644 index 0000000..60ef074 --- /dev/null +++ b/tests/regressions/README.md @@ -0,0 +1,12 @@ +# Fuzz Regression Tests + +This folder stores regression scenarios recovered from fuzz artifacts (crash-*, imeout-*, leak-*). + +How to add a new test: + +1. Copy emplate.rs, rename it following the crash_YYYYMMDD_HHMM.rs pattern. +2. Replace the placeholder sequence with the actual operations from the artifact. +3. Remove #[ignore] once the test is stable and should run by default. +4. Add links to the original artifact and the tracking issue/PR in comments. + +Keep each regression test minimal and deterministic. diff --git a/tests/regressions/template.rs b/tests/regressions/template.rs new file mode 100644 index 0000000..fd2d3e3 --- /dev/null +++ b/tests/regressions/template.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2023-2025 Yegor Bugayenko +// SPDX-License-Identifier: MIT + +//! Template for a fuzz regression test. +//! Copy this file, rename it, replace the operation sequence, +//! and remove #[ignore] once the test is ready to run by default. + +use micromap::Map; + +#[test] +#[ignore] +fn fuzz_regression_template() { + const MAX_CAPACITY: usize = 16; + let mut map = Map::::new(); + + // NOTE: Insert the restored sequence of operations from the artifact. + map.insert(1, 42); + map.remove(&1); + map.insert(1, 99); + + assert_eq!(map.get(&1), Some(&99)); +} + +