From 569b7b38b586a61e1c6f681215c59a522536f518 Mon Sep 17 00:00:00 2001 From: Daniel Polo <106583643+danielPoloWork@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:40:56 +0200 Subject: [PATCH] test(fuzz): add a coverage-guided fuzzing harness for the pool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pool_fuzz.cpp, a stateful opcode interpreter that drives the pool's public surface — create -> alloc/free -> destroy, plus dynamic growth — through randomized operation sequences and asserts, against a shadow model, the invariants the pool promises: - no two live blocks alias (a free list vending a block twice is caught); - a per-block canary written on allocation is intact at free time; - free(NULL) and free(foreign) are defined no-ops that never corrupt the free list (ADR-0012); - the InstrumentedPool live count tracks the shadow set exactly, and after draining the pool reports zero live and balanced counters (ADR-0025). Both fixed and dynamic pools are exercised. A double-free of an in-range block is deliberately not exercised — the default build does not detect it by design (ADR-0012); hardening (ADR-0043) owns that. One engine-agnostic source yields two build roles: - pool_fuzz — the libFuzzer target, opt-in behind PBR_MEMORY_POOL_BUILD_FUZZERS (OFF by default) and Clang-only. A `fuzz` preset compiles every TU with -fsanitize=fuzzer-no-link,address,undefined and links the target with -fsanitize=fuzzer, so coverage feedback spans the library. A dedicated CI job replays the seed corpus and fuzzes for a bounded 60s on every PR, uploading any crash as a reproducer. - pool_fuzz_replay — built with the normal test suite (no engine): its standalone main replays the seed corpus (CTest pool_fuzz_replay), making the corpus a portable regression gate everywhere — including MSVC, where libFuzzer is unavailable — and keeping the harness inside the clang-tidy gate. Test-only and additive; the release build and the ADR-0014 benchmark numbers are untouched. Decided in ADR-0044; ROADMAP item 9.3. Closes #108. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 57 ++++ CHANGELOG.md | 13 + CMakeLists.txt | 14 + CMakePresets.json | 22 +- ROADMAP.md | 2 +- .../0044-coverage-guided-fuzzing-harness.md | 85 ++++++ docs/adr/README.md | 1 + docs/specs/01_spec_cpp_memory_pool.md | 6 +- .../cpp/it/d4np/memorypool/CMakeLists.txt | 63 +++++ src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp | 257 ++++++++++++++++++ .../memorypool/pool_fuzz_corpus/README.md | 56 ++++ .../pool_fuzz_corpus/seed_dynamic_growth | Bin 0 -> 27 bytes .../memorypool/pool_fuzz_corpus/seed_empty | 0 .../pool_fuzz_corpus/seed_fixed_basic | Bin 0 -> 11 bytes .../pool_fuzz_corpus/seed_fixed_exhaust | Bin 0 -> 19 bytes .../pool_fuzz_corpus/seed_foreign_null | Bin 0 -> 13 bytes .../memorypool/pool_fuzz_corpus/seed_mixed | Bin 0 -> 17 bytes 17 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0044-coverage-guided-fuzzing-harness.md create mode 100644 src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp create mode 100644 src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/README.md create mode 100644 src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_dynamic_growth create mode 100644 src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_empty create mode 100644 src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_fixed_basic create mode 100644 src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_fixed_exhaust create mode 100644 src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_foreign_null create mode 100644 src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_mixed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 359a125..1672e98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -597,3 +597,60 @@ jobs: # growth scenario grows under MUTEX and cleanly skips under LOCKFREE. "$bin" --scenario all --threads 4 --iterations 20000 --repeats 3 echo "OK — all bench scenarios ran to completion under ${{ matrix.mode }}." + + # --------------------------------------------------------------------------- + # ROADMAP §9.3 — coverage-guided fuzzing (ADR-0044). Builds the libFuzzer + # target under ASan/UBSan (Clang / POSIX only — libFuzzer is a Clang runtime), + # replays the seed corpus as a regression gate, then fuzzes for a bounded time + # on every PR. A crash fails the job and the offending input is uploaded as a + # reproducer for the bug ledger (ADR-0039). + # --------------------------------------------------------------------------- + fuzz: + name: fuzz / libFuzzer (asan+ubsan) + runs-on: ubuntu-24.04 + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Install CMake and Ninja + uses: lukka/get-cmake@latest + + - name: Configure (fuzz preset, Clang) + shell: bash + run: | + export CC=clang CXX=clang++ + cmake --preset fuzz + + - name: Build the fuzz target + shell: bash + run: cmake --build build/fuzz --target pool_fuzz + + - name: Replay the seed corpus (regression gate) + shell: bash + run: | + set -euo pipefail + bin="build/fuzz/src/test/cpp/it/d4np/memorypool/pool_fuzz" + corpus="src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus" + # Replaying named files runs each once and exits; a crash is a finding. + "$bin" "$corpus"/seed_* + echo "OK — seed corpus replayed clean." + + - name: Fuzz for a bounded time + shell: bash + run: | + set -euo pipefail + bin="build/fuzz/src/test/cpp/it/d4np/memorypool/pool_fuzz" + corpus="src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus" + "$bin" -max_total_time=60 -timeout=25 -rss_limit_mb=4096 "$corpus" + echo "OK — libFuzzer ran to the time budget with no crash." + + - name: Upload any crash reproducers + if: failure() + uses: actions/upload-artifact@v7 + with: + name: fuzz-crashes + path: | + crash-* + timeout-* + oom-* + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ecc09d..d799dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,19 @@ dated version block (`## [X.Y.Z] — YYYY-MM-DD`) when a release PR closes a mil ### Added +- **Coverage-guided fuzzing harness for the pool surface.** A new + [`pool_fuzz.cpp`](src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp) drives randomized + `alloc` / `free(valid)` / `free(NULL)` / `free(foreign)` sequences — over both fixed and + dynamic-growth pools — through a stateful opcode interpreter, asserting the no-alias, + per-block-canary, foreign/NULL-no-op ([ADR-0012](docs/adr/0012-foreign-pointer-and-out-of-range-pointer-policy.md)), + and `InstrumentedPool` accounting invariants against a shadow oracle so a corruption becomes + a saved reproducer. One engine-agnostic source yields the Clang-only libFuzzer target + `pool_fuzz` (opt-in `PBR_MEMORY_POOL_BUILD_FUZZERS`; a `fuzz` preset builds it under + `-fsanitize=fuzzer,address,undefined`, and a dedicated CI job replays the seed corpus and + fuzzes for a bounded time on every PR) and an always-built standalone `pool_fuzz_replay` that + makes the seed corpus a portable regression gate everywhere — including MSVC, where libFuzzer + is unavailable. Test-only and additive; the release build and the benchmark numbers are + untouched. [ADR-0044](docs/adr/0044-coverage-guided-fuzzing-harness.md). Closes #108. - **Opt-in debug hardening — freed-block poisoning, a guard word, and free-list safe-linking.** A compile-time knob `PBR_MEMORY_POOL_HARDENING` (OFF by default; a `harden` CMake preset turns it on) hardens the intrusive free list against the classic diff --git a/CMakeLists.txt b/CMakeLists.txt index 42fff53..fec2efd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -274,6 +274,19 @@ if(PBR_MEMORY_POOL_BUILD_BENCHMARKS) add_subdirectory(src/bench/cpp/it/d4np/memorypool) endif() +# --------------------------------------------------------------------------- +# Coverage-guided fuzzing (ADR-0044). When ON, the test tree adds the +# libFuzzer target `pool_fuzz` (Clang only — libFuzzer is a Clang runtime), +# built with -fsanitize=fuzzer,address,undefined and run by a dedicated CI +# job. OFF by default so it never perturbs the release build or the ADR-0014 +# benchmark methodology. The portable `pool_fuzz_replay` target (which replays +# the seed corpus as a regression gate) is built with the normal test suite +# regardless of this option; only the coverage-guided engine is gated here. +# Requires PBR_MEMORY_POOL_BUILD_TESTS, since the target lives in the test tree. +# --------------------------------------------------------------------------- +option(PBR_MEMORY_POOL_BUILD_FUZZERS + "Build the libFuzzer coverage-guided fuzz target (Clang only, ADR-0044)" OFF) + # --------------------------------------------------------------------------- # Configuration summary (debug aid; not output in -DCMAKE_MESSAGE_LOG_LEVEL=ERROR). # --------------------------------------------------------------------------- @@ -285,5 +298,6 @@ message(STATUS " C++ standard : C++${CMAKE_CXX_STANDARD} (extensions ${C message(STATUS " Build type : ${CMAKE_BUILD_TYPE}${CMAKE_CONFIGURATION_TYPES}") message(STATUS " Tests : ${PBR_MEMORY_POOL_BUILD_TESTS}") message(STATUS " Benchmarks : ${PBR_MEMORY_POOL_BUILD_BENCHMARKS}") +message(STATUS " Fuzzers : ${PBR_MEMORY_POOL_BUILD_FUZZERS}") message(STATUS " Install rules : ${PBR_MEMORY_POOL_INSTALL}") message(STATUS "") diff --git a/CMakePresets.json b/CMakePresets.json index 1286502..b538ce0 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -94,6 +94,24 @@ "PBR_MEMORY_POOL_HARDENING": "ON" } }, + { + "name": "fuzz", + "inherits": "debug", + "displayName": "Fuzzing (libFuzzer + ASan/UBSan)", + "description": "Debug + the coverage-guided libFuzzer target pool_fuzz, with ASan/UBSan over the whole build. Clang / POSIX only — libFuzzer is a Clang runtime (ADR-0044).", + "cacheVariables": { + "PBR_MEMORY_POOL_BUILD_FUZZERS": "ON", + "CMAKE_C_FLAGS": "-fsanitize=fuzzer-no-link,address,undefined -fno-omit-frame-pointer -g", + "CMAKE_CXX_FLAGS": "-fsanitize=fuzzer-no-link,address,undefined -fno-omit-frame-pointer -g", + "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined", + "CMAKE_SHARED_LINKER_FLAGS": "-fsanitize=address,undefined" + }, + "condition": { + "type": "notEquals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, { "name": "bench", "displayName": "Benchmark", @@ -114,6 +132,7 @@ {"name": "ubsan", "configurePreset": "ubsan"}, {"name": "tsan", "configurePreset": "tsan"}, {"name": "harden", "configurePreset": "harden"}, + {"name": "fuzz", "configurePreset": "fuzz"}, {"name": "bench", "configurePreset": "bench"} ], "testPresets": [ @@ -122,6 +141,7 @@ {"name": "asan", "configurePreset": "asan", "output": {"outputOnFailure": true}, "execution": {"jobs": 0}}, {"name": "ubsan", "configurePreset": "ubsan", "output": {"outputOnFailure": true}, "execution": {"jobs": 0}}, {"name": "tsan", "configurePreset": "tsan", "output": {"outputOnFailure": true}, "execution": {"jobs": 0}}, - {"name": "harden", "configurePreset": "harden", "output": {"outputOnFailure": true}, "execution": {"jobs": 0}} + {"name": "harden", "configurePreset": "harden", "output": {"outputOnFailure": true}, "execution": {"jobs": 0}}, + {"name": "fuzz", "configurePreset": "fuzz", "output": {"outputOnFailure": true}, "execution": {"jobs": 0}} ] } diff --git a/ROADMAP.md b/ROADMAP.md index 548a116..840499b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,7 +138,7 @@ Goal: a coherent, post-`v1.0.0` wave of **additive, ABI-compatible** capabilitie - [x] 9.1 **`std::pmr::memory_resource` adapter** (`PoolMemoryResource`) — the "door left open" in [ADR-0018](docs/adr/0018-stl-allocator-adapter.md): a `std::pmr::memory_resource` subclass binding one `Pool` so any `std::pmr`-aware container can draw from it through `std::pmr::polymorphic_allocator`, without the `PoolAllocator` per-type rebind. Deterministic `(bytes, alignment)` routing to the bound pool — over-sized / over-aligned requests delegate to a configurable upstream resource, and exhaustion of a pool-eligible request throws `std::bad_alloc` rather than falling back (preserving the deterministic deallocate routing) — with `is_equal` by `(pool, upstream)` identity, gated behind `PBR_MEMORY_POOL_HAS_PMR` where `` is available. Header-only, additive, ABI-compatible. Decided in [ADR-0042](docs/adr/0042-pmr-memory-resource-adapter.md); implemented in [`pool_memory_resource.hpp`](src/main/cpp/it/d4np/memorypool/pool_memory_resource.hpp) with [`pool_memory_resource_test.cpp`](src/test/cpp/it/d4np/memorypool/pool_memory_resource_test.cpp) (issue #107). - [x] 9.2 **Opt-in debug hardening** — freed-block poisoning, canaries, and free-list safe-linking (which also yields double-free detection); zero cost when the gate is off (issue #109). Decided in [ADR-0043](docs/adr/0043-opt-in-debug-hardening.md); implemented in [`memory_pool.cpp`](src/main/cpp/it/d4np/memorypool/memory_pool.cpp) behind the compile-time `PBR_MEMORY_POOL_HARDENING` knob (a `harden` CMake preset), with the swappable violation-handler surface in [`pool_hardening.hpp`](src/main/cpp/it/d4np/memorypool/pool_hardening.hpp) and [`pool_hardening_test.cpp`](src/test/cpp/it/d4np/memorypool/pool_hardening_test.cpp) (CTest `pool_hardening`). The "canary" is realized as one trailing **guard word** living in *added* slot stride — so the user-visible `block_size` and the ADR-0009 alignment guarantee are unchanged and the default build is byte-for-byte unchanged (the mechanism is fully compiled out). Poisoning (`0xDE`) catches use-after-free on the next allocation, the guard word catches a write past `block_size` and a double-free, and glibc-style safe-linking (`ptr XOR (slot_addr >> 12)`) protects the in-band next-link. A `harden` CI matrix cell builds and tests the hardened configuration on each Tier-1 platform. Works with fixed and dynamic pools across all three thread-safety policies. -- [ ] 9.3 **Coverage-guided fuzzing harness** for the pool surface — a libFuzzer target under `src/fuzz/`, time-boxed in CI (issue #108). +- [x] 9.3 **Coverage-guided fuzzing harness** for the pool surface — a libFuzzer target, time-boxed in CI (issue #108). Decided in [ADR-0044](docs/adr/0044-coverage-guided-fuzzing-harness.md); implemented in [`pool_fuzz.cpp`](src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp) as a stateful opcode interpreter with a shadow oracle that asserts the no-alias, canary-intact, foreign/NULL-no-op ([ADR-0012](docs/adr/0012-foreign-pointer-and-out-of-range-pointer-policy.md)) and `InstrumentedPool` accounting invariants across fixed and dynamic pools. One engine-agnostic source yields both the Clang-only libFuzzer target `pool_fuzz` (opt-in `PBR_MEMORY_POOL_BUILD_FUZZERS`, a `fuzz` preset under `-fsanitize=fuzzer,address,undefined`) and the always-built standalone [`pool_fuzz_replay`](src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/) target that replays the seed corpus as a portable regression gate (CTest `pool_fuzz_replay`) on every platform, including MSVC. A dedicated `fuzz` CI job replays the corpus and fuzzes for a bounded time on every PR; a crash is filed in the bug ledger ([ADR-0039](docs/adr/0039-bug-ledger-and-triage-protocol.md)). Test-only and additive — the release build and the benchmark numbers are untouched. - [ ] 9.4 **Benchmark extension** — external allocator baselines (jemalloc / tcmalloc) and p99 tail-latency reporting (issue #111). --- diff --git a/docs/adr/0044-coverage-guided-fuzzing-harness.md b/docs/adr/0044-coverage-guided-fuzzing-harness.md new file mode 100644 index 0000000..b617100 --- /dev/null +++ b/docs/adr/0044-coverage-guided-fuzzing-harness.md @@ -0,0 +1,85 @@ +# ADR-0044: A coverage-guided fuzzing harness for the pool surface + +- **Status:** Accepted +- **Date:** 2026-07-09 +- **Deciders:** Daniel Polo (maintainer / project architect) +- **Related:** [ADR-0007](0007-test-framework-doctest.md) (the doctest/CTest test tier this complements), [ADR-0009](0009-free-list-layout-block-size-constraints-and-alignment-guarantee.md) (the free-list invariants the harness asserts), [ADR-0012](0012-foreign-pointer-and-out-of-range-pointer-policy.md) (the foreign / NULL / out-of-range free semantics the harness exercises), [ADR-0022](0022-dynamic-growth-policy-and-chunk-linking.md) / [ADR-0024](0024-dynamic-growth-synchronization-and-creation-surface.md) (the dynamic-growth path fuzzed alongside the fixed one), [ADR-0025](0025-decorator-for-instrumented-pool.md) (the `InstrumentedPool` used as the accounting oracle), [ADR-0005](0005-toolchain-matrix-and-supported-platforms.md) (the sanitizer / platform split this inherits), [ADR-0039](0039-bug-ledger-and-triage-protocol.md) (where a fuzzer-found defect is filed), [ADR-0037](0037-new-feature-roadmap-placement.md) (roadmap Milestone 9), [ADR-0004](0004-versioning-and-release-policy.md) (the SemVer classification), issue #108, origin issue #105, spec [§6.4](../specs/01_spec_cpp_memory_pool.md#64-sanitizers--ci) + +## Context + +The test tier is example-based (doctest, [ADR-0007](0007-test-framework-doctest.md)) and the benchmark is a fixed loop ([ADR-0014](0014-microbenchmark-methodology-pool-vs-malloc.md)); there was **no fuzz target anywhere in the repo**. An allocator is exactly the component where fuzzing earns its keep: the interesting defects live in *sequences* — allocate-to-exhaustion, LIFO vs. FIFO frees, free-then-reallocate across a growth event, foreign-pointer rejections interleaved with valid traffic — not in any single call. The in-repo bug ledger ([ADR-0039](0039-bug-ledger-and-triage-protocol.md)) already shows this defect class matters. The spec review (#105) flagged the gap directly. + +Two forces shape the design: + +1. **It must run everywhere the project is developed, and gate on every PR.** libFuzzer is a Clang runtime and unavailable on MSVC ([ADR-0005](0005-toolchain-matrix-and-supported-platforms.md) §3) — the same platform split the ASan/UBSan presets already live with. But the seed corpus should still be a regression gate on *every* platform, including the maintainer's MSVC box, so a check-in that breaks the harness or a known-good input is caught locally, not only on the Clang CI leg. +2. **It must never perturb the release build or the [ADR-0014](0014-microbenchmark-methodology-pool-vs-malloc.md) benchmark numbers.** The coverage-guided engine and its instrumentation are strictly opt-in. + +## Decision + +Add a single harness translation unit, [`pool_fuzz.cpp`](../../src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp), that is **engine-agnostic**: it exposes the libFuzzer entry point `LLVMFuzzerTestOneInput` *and* a standalone replay `main`. The build decides which role it plays. + +### The harness — a stateful opcode interpreter with a shadow oracle + +`LLVMFuzzerTestOneInput` treats the input buffer as a tiny program. The leading three bytes configure a pool — `block_size = alignof(max_align_t) * (1 + (b & 3))` (valid by construction under [ADR-0009](0009-free-list-layout-block-size-constraints-and-alignment-guarantee.md) §2), a small `block_count`, and a fixed-or-dynamic mode with a growth factor — and each subsequent byte is an opcode over a state machine: `alloc`, `free(valid)` (an index byte picks which live block, so LIFO / FIFO / interleaved orders all arise), `free(NULL)`, and `free(foreign)`. A dynamic pool grows implicitly as allocation runs past its initial capacity ([ADR-0022](0022-dynamic-growth-policy-and-chunk-linking.md)). + +The harness keeps its own **shadow model** of the live blocks and asserts, on every step, the invariants the pool promises — turning a silent corruption into an immediate crash the fuzzer can minimise and save as a reproducer: + +- **No aliasing** — a freshly vended block is never already live (a free list that vends a block twice is caught here). +- **Canary integrity** — each block is stamped with a per-block byte on allocation and verified intact at free time, so any overlap or stray write through a live block is detected even without a sanitizer. +- **Defined foreign/NULL frees** — `free(NULL)` and `free(&stack_object)` are no-ops that never corrupt the free list ([ADR-0012](0012-foreign-pointer-and-out-of-range-pointer-policy.md)); the foreign free is issued through the C core so it does not perturb the accounting oracle. +- **Accounting** — an [`InstrumentedPool`](../../src/main/cpp/it/d4np/memorypool/instrumented_pool.hpp) ([ADR-0025](0025-decorator-for-instrumented-pool.md)) is the oracle: its `live_` count tracks the shadow set exactly after every operation, and after the final drain the pool reports zero live, balanced allocation/deallocation counts, and a peak-live matching the observed high-water mark. + +A **double-free of an in-range block is deliberately *not* exercised.** The default build does not detect it — an accepted, documented trade-off ([ADR-0012](0012-foreign-pointer-and-out-of-range-pointer-policy.md), spec §5.3) — so feeding one to this ASan/UBSan build would flag a documented decision as a bug. Double-free detection is instead proven by the opt-in hardening suite ([ADR-0043](0043-opt-in-debug-hardening.md), `pool_hardening_test`). + +### Two build roles from one source + +- **libFuzzer target `pool_fuzz`** — opt-in behind the CMake option `PBR_MEMORY_POOL_BUILD_FUZZERS` (**OFF** by default) and **Clang-only** (a `FATAL_ERROR` guards other compilers). The `fuzz` CMake preset compiles every TU (library + harness) with `-fsanitize=fuzzer-no-link,address,undefined` for edge coverage and links the target with `-fsanitize=fuzzer` — the standard split that keeps coverage feedback spanning the library under test, not just the harness. +- **Standalone replay target `pool_fuzz_replay`** — built with the *normal* test suite (no fuzzing engine, no option needed). Its `main` replays each file named on the command line (the OSS-Fuzz `StandaloneFuzzTargetMain` shape), guarded by `PBR_MEMORY_POOL_LIBFUZZER`. This makes the seed corpus a **portable regression gate** that runs under CTest on every platform — including MSVC — and, being an ordinary test target, keeps `pool_fuzz.cpp` inside the clang-tidy diff gate. The libFuzzer `main` is otherwise supplied by the fuzzer runtime. + +A small **seed corpus** lives beside the harness ([`pool_fuzz_corpus/`](../../src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/)) with a README documenting the byte format and a short local run. + +### CI + +A dedicated `fuzz` job (Clang, POSIX) builds the target, replays the seed corpus as a regression gate, then fuzzes for a bounded 60 s on every PR; a crash fails the job and uploads the offending input as an artifact. It is kept off the MSVC leg, consistent with the existing sanitizer-preset platform split. Any confirmed defect is filed in the bug ledger ([ADR-0039](0039-bug-ledger-and-triage-protocol.md)) with the crashing input as the reproducer. + +### Compatibility + +Test-only and additive — no product code changes. The knob and its instrumentation are compiled only under the `fuzz` preset / the opt-in option, so the release build and the [ADR-0014](0014-microbenchmark-methodology-pool-vs-malloc.md) numbers are untouched. SemVer-neutral; it can ride any release ([ADR-0004](0004-versioning-and-release-policy.md); roadmap item 9.3 under [ADR-0037](0037-new-feature-roadmap-placement.md)). + +## Alternatives Considered + +- **A libFuzzer-only target, no standalone replay.** Rejected. The seed corpus would then gate only on the Clang CI leg; a broken harness or a regressed input would slip past the maintainer's MSVC box, and `pool_fuzz.cpp` would fall outside the clang-tidy diff gate (it would not appear in the default build's compile database). The standalone replay costs one small `main` and buys portable, always-on validation. +- **Instrument only the harness (`-fsanitize=fuzzer` on the target, library uncovered).** Rejected as the default: coverage feedback would stop at the harness boundary, blinding the fuzzer to the library's own branches. The `fuzz` preset instruments every TU with `fuzzer-no-link` so the guidance spans the code under test. +- **Exercise double-free in the fuzzer.** Rejected — see above; it would flag the documented [ADR-0012](0012-foreign-pointer-and-out-of-range-pointer-policy.md) trade-off as a defect. Hardening ([ADR-0043](0043-opt-in-debug-hardening.md)) owns double-free detection. +- **A separate fuzzing framework (AFL++, standalone) or a new dependency.** Rejected. libFuzzer ships with the Clang the sanitizer matrix already uses (spec §3.3 zero-external-dependency posture), and the harness is written against the plain `LLVMFuzzerTestOneInput` ABI, so it also builds under OSS-Fuzz or AFL++'s libFuzzer-compatible mode without change. +- **Unbounded CI fuzzing / a nightly soak.** Deferred. A bounded 60 s per-PR run is the fast regression gate; a longer soak (or OSS-Fuzz onboarding) can layer on later without changing the harness. + +## Consequences + +**Positive** + +- Sequence-level defects in the allocator — aliasing, overlap, foreign-pointer corruption, growth-boundary bugs, accounting drift — are explored automatically under ASan/UBSan and caught deterministically by the shadow oracle, on every PR. +- The seed corpus is a regression gate on **every** platform via `pool_fuzz_replay`, and the harness stays inside the clang-tidy gate. +- Both fixed and dynamic pools, and the [ADR-0025](0025-decorator-for-instrumented-pool.md) decorator, are exercised by construction. A found crash drops straight into the [ADR-0039](0039-bug-ledger-and-triage-protocol.md) ledger workflow with a ready reproducer. +- Zero effect on the release build and the benchmark numbers — the engine and its instrumentation are strictly opt-in. + +**Negative** + +- Coverage-guided fuzzing runs only on Clang/POSIX (libFuzzer's platform reach); MSVC gets the corpus replay but not exploration. This is the same split the ASan/UBSan tiers already accept ([ADR-0005](0005-toolchain-matrix-and-supported-platforms.md) §3). +- The harness models the invariants it knows to assert; a defect it has no oracle for (e.g. one only a new opcode would reach) is invisible until the harness is extended. The opcode set and oracles are meant to grow as the surface does. +- A 60 s per-PR budget is a shallow search; it catches regressions and shallow bugs, not deep ones. A soak / OSS-Fuzz tier is deferred. + +**Testing / tooling / documentation (landing in the same PR)** + +- [`pool_fuzz.cpp`](../../src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp) + [`pool_fuzz_corpus/`](../../src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/) (seeds + README). +- [`CMakeLists.txt`](../../CMakeLists.txt) / [test `CMakeLists.txt`](../../src/test/cpp/it/d4np/memorypool/CMakeLists.txt) — the `PBR_MEMORY_POOL_BUILD_FUZZERS` option, the always-built `pool_fuzz_replay` (CTest `pool_fuzz_replay`), and the Clang-gated `pool_fuzz` (CTest `pool_fuzz_corpus`). +- [`CMakePresets.json`](../../CMakePresets.json) — the `fuzz` preset (POSIX-only). +- [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) — the `fuzz` job. +- [`docs/specs/01_spec_cpp_memory_pool.md`](../specs/01_spec_cpp_memory_pool.md) §6.4 / §7 and [`ROADMAP.md`](../../ROADMAP.md) item 9.3, [`CHANGELOG.md`](../../CHANGELOG.md) `Unreleased`. The `README.md` test-strategy refresh is **deferred to the `v1.2.0` release PR** to keep this PR off the translated docs surface (the same M8.8 / [ADR-0042](0042-pmr-memory-resource-adapter.md) sequencing used by [ADR-0043](0043-opt-in-debug-hardening.md)). + +## References + +- LLVM libFuzzer — the `LLVMFuzzerTestOneInput` entry point and `-fsanitize=fuzzer[-no-link]` instrumentation model. +- OSS-Fuzz `StandaloneFuzzTargetMain` — the engine-less replay `main` shape mirrored here so the corpus is portable. +- [ADR-0012](0012-foreign-pointer-and-out-of-range-pointer-policy.md) — the foreign / NULL / double-free semantics the harness respects. +- [ADR-0039](0039-bug-ledger-and-triage-protocol.md) — where a fuzzer-found defect is recorded. diff --git a/docs/adr/README.md b/docs/adr/README.md index a35c9d5..a7ad7e1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -76,5 +76,6 @@ Do **not** write one for purely local implementation details, formatting, or tri | 0041 | [Mermaid as the in-repo diagram tooling](0041-mermaid-diagram-tooling.md) | Accepted | | 0042 | [`std::pmr::memory_resource` adapter — `PoolMemoryResource`](0042-pmr-memory-resource-adapter.md) | Accepted | | 0043 | [Opt-in debug hardening — poisoning, a guard word, and free-list safe-linking](0043-opt-in-debug-hardening.md) | Accepted | +| 0044 | [A coverage-guided fuzzing harness for the pool surface](0044-coverage-guided-fuzzing-harness.md) | Accepted | When adding a new ADR, append a row to this table in the same PR. diff --git a/docs/specs/01_spec_cpp_memory_pool.md b/docs/specs/01_spec_cpp_memory_pool.md index a9ff7d9..3e9dd6a 100644 --- a/docs/specs/01_spec_cpp_memory_pool.md +++ b/docs/specs/01_spec_cpp_memory_pool.md @@ -208,7 +208,7 @@ valgrind --leak-check=full --show-leak-kinds=all ./test_pool ### 6.4 Sanitizers & CI -ASan, UBSan and TSan run via dedicated CMake presets; TSan covers the thread-safe configurations. The opt-in debug-hardening configuration ([§4.1](#41-constraints--guarantees), [ADR-0043](../adr/0043-opt-in-debug-hardening.md)) runs via a `harden` preset — a Tier-1 CI matrix cell builds it and runs its detection tests, so the quality bar holds in both the default and the hardened configuration. All of the above is wired into a multi-OS CI matrix (warnings-as-errors, `clang-tidy`, Valgrind). A coverage-guided fuzz target is deferred (Section 7). +ASan, UBSan and TSan run via dedicated CMake presets; TSan covers the thread-safe configurations. The opt-in debug-hardening configuration ([§4.1](#41-constraints--guarantees), [ADR-0043](../adr/0043-opt-in-debug-hardening.md)) runs via a `harden` preset — a Tier-1 CI matrix cell builds it and runs its detection tests, so the quality bar holds in both the default and the hardened configuration. A **coverage-guided fuzzing** tier ([ADR-0044](../adr/0044-coverage-guided-fuzzing-harness.md)) drives randomized `alloc`/`free`/grow sequences through the public surface under ASan/UBSan, asserting the no-alias, foreign-pointer-no-op, and accounting invariants against a shadow oracle: a Clang-only `fuzz` preset builds the libFuzzer target and a dedicated CI job replays the seed corpus and fuzzes for a bounded time on every PR, while a portable standalone replay target gates the corpus everywhere (including MSVC, where libFuzzer is unavailable). All of the above is wired into a multi-OS CI matrix (warnings-as-errors, `clang-tidy`, Valgrind). --- @@ -229,15 +229,17 @@ Every requirement above is realized and recorded. The table maps the spec to its | §5.3 error semantics | [ADR-0012](../adr/0012-foreign-pointer-and-out-of-range-pointer-policy.md), [ADR-0016](../adr/0016-exception-policy-at-the-c-cpp-boundary.md) | | §5.4 instrumentation / observers | [ADR-0025](../adr/0025-decorator-for-instrumented-pool.md), [ADR-0026](../adr/0026-observer-for-pool-lifecycle-events.md) | | §6.3 benchmark methodology | [ADR-0014](../adr/0014-microbenchmark-methodology-pool-vs-malloc.md) | +| §6.4 coverage-guided fuzzing harness | [ADR-0044](../adr/0044-coverage-guided-fuzzing-harness.md) | | Spec-compliance acceptance audit | [ADR-0029](../adr/0029-spec-compliance-acceptance-audit.md) | ### 7.1 Deferred / tracked These are explicitly out of the current build and tracked as issues: -- **Coverage-guided fuzzing harness** (issue #108). - **Benchmark extension** — external baselines (jemalloc/tcmalloc) and p99 percentile reporting (issue #111). **Opt-in debug hardening** (freed-block poisoning, a guard word for overflow + double-free detection, and free-list safe-linking), once deferred here, now ships behind the `PBR_MEMORY_POOL_HARDENING` compile-time knob — see [§4.1](#41-constraints--guarantees) and [ADR-0043](../adr/0043-opt-in-debug-hardening.md) (issue #109). +The **coverage-guided fuzzing harness**, once deferred here, now ships as the libFuzzer target `pool_fuzz` plus a portable standalone corpus-replay gate — see [§6.4](#64-sanitizers--ci) and [ADR-0044](../adr/0044-coverage-guided-fuzzing-harness.md) (issue #108). + The **C4 component diagram** of the pool internals, once deferred here, now ships in [Section 4.2](#42-component-diagram-c4) (its tooling decision is [ADR-0041](../adr/0041-mermaid-diagram-tooling.md)). diff --git a/src/test/cpp/it/d4np/memorypool/CMakeLists.txt b/src/test/cpp/it/d4np/memorypool/CMakeLists.txt index ec730ae..98821d3 100644 --- a/src/test/cpp/it/d4np/memorypool/CMakeLists.txt +++ b/src/test/cpp/it/d4np/memorypool/CMakeLists.txt @@ -247,6 +247,69 @@ add_test( set_tests_properties(pool_hardening PROPERTIES LABELS "smoke;milestone-9") +# ROADMAP item 9.3 — coverage-guided fuzzing harness (ADR-0044). The harness in +# pool_fuzz.cpp exposes LLVMFuzzerTestOneInput plus a standalone replay main. +# +# pool_fuzz_replay is built with the normal test suite (no fuzzing engine): its +# main replays each seed-corpus file, so the corpus is a portable regression +# gate that runs on every platform — including MSVC, where libFuzzer is +# unavailable. Being a normal test target it is also covered by the clang-tidy +# gate. The corpus README is excluded from the replayed file list. +set(_pool_fuzz_corpus_dir "${CMAKE_CURRENT_SOURCE_DIR}/pool_fuzz_corpus") +file(GLOB _pool_fuzz_corpus_files CONFIGURE_DEPENDS "${_pool_fuzz_corpus_dir}/*") +list(FILTER _pool_fuzz_corpus_files EXCLUDE REGEX "/README\\.md$") + +add_executable(pool_fuzz_replay + pool_fuzz.cpp) + +target_link_libraries(pool_fuzz_replay PRIVATE + pbr::memory_pool) + +target_compile_features(pool_fuzz_replay PRIVATE cxx_std_17) + +add_test( + NAME pool_fuzz_replay + COMMAND pool_fuzz_replay ${_pool_fuzz_corpus_files}) + +set_tests_properties(pool_fuzz_replay PROPERTIES + LABELS "smoke;milestone-9") + +# The true coverage-guided libFuzzer target is opt-in (PBR_MEMORY_POOL_BUILD_FUZZERS, +# default OFF — declared at the top level) and Clang-only, since libFuzzer is a +# Clang runtime. A dedicated CI job builds and runs it under a bounded time +# budget; it never perturbs the release build or the ADR-0014 benchmark numbers. +if(PBR_MEMORY_POOL_BUILD_FUZZERS) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR + "PBR_MEMORY_POOL_BUILD_FUZZERS requires Clang (libFuzzer is a Clang " + "runtime); got '${CMAKE_CXX_COMPILER_ID}'. The portable pool_fuzz_replay " + "target already gates the seed corpus on every platform.") + endif() + add_executable(pool_fuzz + pool_fuzz.cpp) + + target_link_libraries(pool_fuzz PRIVATE + pbr::memory_pool) + + target_compile_features(pool_fuzz PRIVATE cxx_std_17) + target_compile_definitions(pool_fuzz PRIVATE PBR_MEMORY_POOL_LIBFUZZER=1) + # The `fuzz` preset compiles every TU (library + harness) with + # -fsanitize=fuzzer-no-link for edge coverage; this target links the + # libFuzzer runtime + entry point. Splitting compile-coverage from + # link-engine is the standard libFuzzer wiring and keeps the coverage + # feedback spanning the library under test, not just the harness. + target_link_options(pool_fuzz PRIVATE -fsanitize=fuzzer) + + # libFuzzer replays file / directory arguments and exits 0, so this gates on + # the seed corpus as a fast regression check; the CI job adds a bounded run. + add_test( + NAME pool_fuzz_corpus + COMMAND pool_fuzz ${_pool_fuzz_corpus_dir}) + + set_tests_properties(pool_fuzz_corpus PROPERTIES + LABELS "fuzz;milestone-9") +endif() + # ROADMAP item 2.8 — spec §6.2 Valgrind demonstrative test. The directory # carries its own CMakeLists.txt because the target consumes a strict ANSI # C89 .c source and overrides the project-wide C99 floor on its own scope. diff --git a/src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp b/src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp new file mode 100644 index 0000000..d9a414b --- /dev/null +++ b/src/test/cpp/it/d4np/memorypool/pool_fuzz.cpp @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Daniel Polo + +/** + * @file pool_fuzz.cpp + * @brief Coverage-guided fuzzing harness for the pool surface (M9.3 / ADR-0044). + * + * `LLVMFuzzerTestOneInput` interprets the fuzzer's byte buffer as a small + * program: the first bytes configure a pool (block size / count / fixed-vs- + * dynamic), and the remaining bytes drive a state machine of `alloc`, + * `free(valid)`, `free(NULL)`, and `free(foreign)` operations. A shadow model + * of the live blocks lets the harness assert the invariants the pool promises + * (ADR-0009 / ADR-0012 / ADR-0022), so a violation surfaces as a crash the + * fuzzer can minimise and save as a reproducer: + * + * - every successful allocation returns a pointer that is not already live + * (no two live blocks alias — a corrupted free list vending a block twice + * is caught here); + * - a per-block canary written on allocation is still intact at free time + * (nothing wrote through a block while the harness owned it); + * - freeing `NULL` or a foreign / out-of-range pointer is a no-op that never + * corrupts the free list (ADR-0012); + * - the `InstrumentedPool` live count tracks the shadow set exactly, and after + * draining every block the pool reports zero live and balanced counters + * (ADR-0025). + * + * Both fixed and dynamic-growth pools are exercised; a dynamic pool grows + * implicitly as allocation runs past its initial capacity (ADR-0022 / ADR-0024). + * + * The harness is deliberately engine-agnostic. Built with Clang's + * `-fsanitize=fuzzer` it is a libFuzzer target (`PBR_MEMORY_POOL_LIBFUZZER`); + * built without it, the standalone `main` below replays the files named on the + * command line (the OSS-Fuzz "StandaloneFuzzTargetMain" shape), so the seed + * corpus runs as a portable regression gate everywhere the project builds — + * including MSVC, where libFuzzer is unavailable. See ADR-0044. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using it::d4np::memorypool::InstrumentedPool; + +// A live allocation the harness is tracking: the block plus the canary byte it +// was filled with, so overlap / corruption is detectable on release. +struct LiveBlock { + void* ptr_; + unsigned char canary_; +}; + +// Bound the shadow set (and therefore any dynamic pool's growth) so a +// pathological all-allocate input cannot exhaust host memory or wall-clock. +constexpr std::size_t MAX_LIVE = 1024U; + +// A forward byte cursor over the fuzzer input. A short or empty input is valid: +// once exhausted the cursor yields zero, which decodes to a short program. +class ByteReader { +public: + ByteReader(const std::uint8_t* data, std::size_t size) noexcept : data_(data), size_(size) {} + + std::uint8_t next() noexcept { + if (pos_ >= size_) { + return 0U; + } + // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) + return data_[pos_++]; + } + + [[nodiscard]] bool done() const noexcept { + return pos_ >= size_; + } + +private: + const std::uint8_t* data_; + std::size_t size_; + std::size_t pos_ = 0U; +}; + +// A detected invariant violation is a real finding: report it and crash so the +// fuzzer (or the standalone corpus replay) saves the offending input. Not marked +// noexcept: the std::cerr insertion may in principle throw, and abort() follows +// on that path anyway, so leaving it potentially-throwing keeps it off the +// bugprone-exception-escape radar without changing the observable behaviour. +void expect(bool condition, const char* what) { + if (!condition) { + std::cerr << "pool_fuzz: invariant violated: " << what << '\n'; + std::abort(); + } +} + +// Allocate one block, verify the no-alias invariant against the shadow set, and +// stamp it with a canary. Exhaustion (a fixed pool at capacity, or a failed +// growth) is a valid outcome, not a bug. +void do_alloc(InstrumentedPool& pool, std::vector& live, unsigned char canary) { + if (live.size() >= MAX_LIVE) { + return; + } + void* const block = pool.try_allocate(); + if (block == nullptr) { + return; + } + for (const LiveBlock& held : live) { + expect(held.ptr_ != block, "allocate returned an already-live block (free-list aliasing)"); + } + const std::size_t bytes = pool.block_size(); + std::memset(block, canary, bytes); + live.push_back(LiveBlock{block, canary}); +} + +// Free one live block chosen by @p index (modulo the live count, so any input +// byte selects a valid block — LIFO, FIFO, and interleaved orders all arise). +// Verifies the canary is intact before returning the block. +void do_free_valid(InstrumentedPool& pool, std::vector& live, std::size_t index) { + if (live.empty()) { + return; + } + const std::size_t at = index % live.size(); + const LiveBlock target = live.at(at); + const std::size_t bytes = pool.block_size(); + const auto* const raw = static_cast(target.ptr_); + for (std::size_t i = 0; i < bytes; ++i) { + // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) + expect(raw[i] == target.canary_, "canary mismatch (overlap or write to a live block)"); + } + pool.deallocate(target.ptr_); + live.erase(live.begin() + static_cast(at)); +} + +// Drive the pool through the opcode stream and assert the accounting invariants +// after each step and after the final drain. +void run_program(const std::uint8_t* data, std::size_t size) { + ByteReader reader{data, size}; + + // Configure a valid pool from the leading bytes. block_size is a multiple of + // max_align_t and >= sizeof(void*) by construction (ADR-0009 §2); block_count + // is small so exhaustion and growth are reached quickly. + constexpr std::size_t BASE_ALIGN = alignof(std::max_align_t); + const std::size_t block_size = BASE_ALIGN * (1U + (reader.next() & 0x03U)); + const std::size_t block_count = 1U + (reader.next() & 0x07U); + const std::uint8_t mode = reader.next(); + const bool dynamic = (mode & 0x01U) != 0U; + const std::size_t growth_factor = 2U + ((mode >> 1U) & 0x01U); + + std::optional pool = dynamic + ? InstrumentedPool::make_dynamic(block_size, block_count, growth_factor) + : InstrumentedPool::make(block_size, block_count); + // A dynamic pool is unsupported under the lock-free policy (ADR-0024 §2); the + // fuzz build uses the default policy, but fall back to fixed so the harness + // stays valid regardless of how the library was configured. + if (!pool.has_value()) { + pool = InstrumentedPool::make(block_size, block_count); + } + expect(pool.has_value(), "failed to create a pool from a valid configuration"); + if (!pool.has_value()) { + return; // unreachable after expect(); also proves the accesses below are guarded + } + InstrumentedPool& active = *pool; + + // A local object whose address is provably outside the pool backing — the + // ADR-0012 foreign-pointer rejection path. Freed through the C core directly + // so it does not perturb the instrumented counters. + int foreign = 0; + std::vector live; + std::size_t peak = 0U; + + while (!reader.done()) { + switch (reader.next() & 0x03U) { + case 0U: + do_alloc(active, live, static_cast(0xA0U + (live.size() & 0x0FU))); + break; + case 1U: + do_free_valid(active, live, reader.next()); + break; + case 2U: + // free(NULL) — a documented no-op (ADR-0012). + active.deallocate(nullptr); + break; + default: + // free(foreign) via the core — must be a defined no-op that + // leaves the free list intact (ADR-0012). + ::memory_pool_free(active.native_handle(), &foreign); + break; + } + peak = (live.size() > peak) ? live.size() : peak; + expect(active.stats().live_ == live.size(), "instrumented live count diverged from the shadow set"); + } + + // Drain everything and assert the pool is empty with balanced counters. + while (!live.empty()) { + do_free_valid(active, live, 0U); + } + const it::d4np::memorypool::PoolStats final_stats = active.stats(); + expect(final_stats.live_ == 0U, "blocks still live after draining"); + expect(final_stats.allocations_ == final_stats.deallocations_, "allocation / deallocation counts unbalanced"); + expect(final_stats.peak_live_ == peak, "instrumented peak-live disagrees with the observed high-water mark"); +} + +} // namespace + +// The libFuzzer entry point. Also called directly by the standalone replay main +// below when the target is built without a fuzzing engine. The name is fixed by +// the libFuzzer ABI, so it cannot follow the project's lower_case function style. +// NOLINTNEXTLINE(readability-identifier-naming) +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + run_program(data, size); + return 0; +} + +#ifndef PBR_MEMORY_POOL_LIBFUZZER + +namespace { + +// Read a whole file into a byte buffer. Returns false if it cannot be opened. +bool read_file(const char* path, std::vector& out) { + std::ifstream input{path, std::ios::binary}; + if (!input) { + return false; + } + out.assign(std::istreambuf_iterator{input}, std::istreambuf_iterator{}); + return true; +} + +} // namespace + +// Standalone replay entry point (OSS-Fuzz StandaloneFuzzTargetMain shape): each +// command-line argument is a corpus file replayed through the harness. Present +// only when the target is built without libFuzzer, so the seed corpus is a +// portable regression gate. The C-array argv parameter and its indexing are +// unavoidable per the language spec — same narrow NOLINTs the benchmark main uses. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays,hicpp-avoid-c-arrays,bugprone-exception-escape) +int main(int argc, char* argv[]) { + for (int i = 1; i < argc; ++i) { + std::vector buffer; + // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) + const char* const path = argv[i]; + if (!read_file(path, buffer)) { + std::cerr << "pool_fuzz: cannot open corpus file: " << path << '\n'; + return 1; + } + static_cast(LLVMFuzzerTestOneInput(buffer.data(), buffer.size())); + } + return 0; +} + +#endif // PBR_MEMORY_POOL_LIBFUZZER diff --git a/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/README.md b/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/README.md new file mode 100644 index 0000000..f3a66ea --- /dev/null +++ b/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/README.md @@ -0,0 +1,56 @@ +# Fuzzing seed corpus — `pool_fuzz` + +Seed inputs for the coverage-guided fuzzing harness +[`pool_fuzz.cpp`](../pool_fuzz.cpp) (ROADMAP item 9.3, decided in +[ADR-0044](../../../../../../../docs/adr/0044-coverage-guided-fuzzing-harness.md)). + +The harness drives the pool's public surface — `create` → +`alloc`/`free` → `destroy`, plus dynamic growth — through randomized +operation sequences and asserts the no-alias, canary-intact, +foreign-pointer-no-op, and instrumented-accounting invariants. A violation +aborts, so the fuzzer saves the offending input as a reproducer. + +## Input format + +Each input byte is interpreted as a tiny program: + +- **byte 0** — block-size selector: `block_size = alignof(max_align_t) * (1 + (b & 3))`. +- **byte 1** — block-count selector: `block_count = 1 + (b & 7)`. +- **byte 2** — mode: bit 0 selects a dynamic-growth pool; bit 1 selects a + growth factor of 2 or 3. +- **remaining bytes** — opcode stream, `op & 3`: + - `0` allocate one block; + - `1` free a live block (the next byte selects which, modulo the live count); + - `2` free `NULL` (a no-op); + - `3` free a foreign pointer (a defined no-op — ADR-0012). + +A short or empty input is valid; it simply yields a short program. + +## Running locally + +The standalone replay binary (built by default with the tests, no fuzzing +engine required) replays every file passed on the command line — this is the +regression gate that runs on every platform, including MSVC: + +```sh +cmake --preset debug +cmake --build build/debug --target pool_fuzz_replay +ctest --preset debug -R pool_fuzz_replay +``` + +A true coverage-guided run needs Clang's libFuzzer (POSIX). The `fuzz` preset +builds the `pool_fuzz` target with `-fsanitize=fuzzer,address,undefined`: + +```sh +CC=clang CXX=clang++ cmake --preset fuzz +cmake --build build/fuzz --target pool_fuzz +# replay the seed corpus, then fuzz for a bounded time +./build/fuzz/src/test/cpp/it/d4np/memorypool/pool_fuzz \ + src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus +./build/fuzz/src/test/cpp/it/d4np/memorypool/pool_fuzz \ + -max_total_time=60 src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus +``` + +Any crash found is filed in the bug ledger (`docs/bugs/`, +[ADR-0039](../../../../../../../docs/adr/0039-bug-ledger-and-triage-protocol.md)) +with the crashing input as the reproducer. diff --git a/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_dynamic_growth b/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_dynamic_growth new file mode 100644 index 0000000000000000000000000000000000000000..1c2becb19199a734067e63055933f7e656c8c25f GIT binary patch literal 27 McmZQ#WMser004vl1poj5 literal 0 HcmV?d00001 diff --git a/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_empty b/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_empty new file mode 100644 index 0000000..e69de29 diff --git a/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_fixed_basic b/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_fixed_basic new file mode 100644 index 0000000000000000000000000000000000000000..305f62bb0077806ba56f45b99056d8143fbb7c47 GIT binary patch literal 11 OcmZQzW?*1o1Y-aIF#rYt literal 0 HcmV?d00001 diff --git a/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_fixed_exhaust b/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_fixed_exhaust new file mode 100644 index 0000000000000000000000000000000000000000..259422ce7014b846de6f4038d3857e2ddda0e482 GIT binary patch literal 19 OcmZQ%Kmv>mAP4{eG5`et literal 0 HcmV?d00001 diff --git a/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_foreign_null b/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_foreign_null new file mode 100644 index 0000000000000000000000000000000000000000..6c6795d77d02fbc6f1eef9e685f3fb6612aad54a GIT binary patch literal 13 UcmZQzVPIfjW@2PyW?*0f004Xd5dZ)H literal 0 HcmV?d00001 diff --git a/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_mixed b/src/test/cpp/it/d4np/memorypool/pool_fuzz_corpus/seed_mixed new file mode 100644 index 0000000000000000000000000000000000000000..ce738a839e982c77c8f6a7e45da7fc594d4c7f69 GIT binary patch literal 17 WcmZQ(Vq^dUMkWSER%Ql91||Ri$N&}q literal 0 HcmV?d00001