diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index bbddfd0..281cc18 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -32,6 +32,8 @@ jobs: cmake --build build-fuzz -j - name: Smoke-run multislab fuzzer run: ./build-fuzz/fuzz/fuzz_multislab -max_total_time=60 -print_final_stats=1 + - name: Smoke-run pool fuzzer + run: ./build-fuzz/fuzz/fuzz_pool -max_total_time=45 -print_final_stats=1 - name: Smoke-run sparse_set fuzzer run: ./build-fuzz/fuzz/fuzz_sparse_set -max_total_time=45 -print_final_stats=1 - name: Smoke-run sparse_map fuzzer diff --git a/README.md b/README.md index 25c37ad..305492e 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ To use it from another project, see [docs/integration.md](docs/integration.md). CI builds and runs the suite with **both GCC and Clang** on every push, in both **`c++26` and `gnu++26`**, under **AddressSanitizer + UndefinedBehaviorSanitizer**, with the ring handoff also run under **ThreadSanitizer**. Coverage-guided **libFuzzer** harnesses drive -`multislab`, `sparse_set`, and `sparse_map`; the two sparse ones are differential, +`multislab`, `pool`, `sparse_set`, and `sparse_map`; the two sparse ones are differential, comparing every operation against a standard container. This is an early-stage library, so treat that as what is exercised today rather diff --git a/docs/testing.md b/docs/testing.md index 9924c37..6d18e62 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -72,8 +72,8 @@ mismatch reaching `deallocate` is undefined behaviour that neither ASan nor ## Fuzzing -Coverage-guided **libFuzzer** harnesses drive `multislab`, `sparse_set`, and -`sparse_map` under ASan + UBSan (`-DLIBMEM_BUILD_FUZZERS=ON`, Clang only, Debug +Coverage-guided **libFuzzer** harnesses drive `multislab`, `pool`, `sparse_set`, +and `sparse_map` under ASan + UBSan (`-DLIBMEM_BUILD_FUZZERS=ON`, Clang only, Debug only). CI runs a short, time-boxed smoke pass on each. The two sparse harnesses are differential, comparing every operation against a standard container. diff --git a/fuzz/CMakeLists.txt b/fuzz/CMakeLists.txt index bb05320..c4e0d60 100644 --- a/fuzz/CMakeLists.txt +++ b/fuzz/CMakeLists.txt @@ -23,5 +23,6 @@ function(add_libmem_fuzzer name) endfunction() add_libmem_fuzzer(fuzz_multislab) +add_libmem_fuzzer(fuzz_pool) add_libmem_fuzzer(fuzz_sparse_set) add_libmem_fuzzer(fuzz_sparse_map) diff --git a/fuzz/README.md b/fuzz/README.md index 8dfda5f..376ea16 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -6,7 +6,8 @@ Requires **Clang** and a **Debug** build (so the sanitizers are active). | Target | Exercises | |--------------------|--------------------------------------------------------------------------| -| `fuzz_multislab` | `multislab` alloc/release op-streams across block sizes, caps, policies | +| `fuzz_multislab` | `multislab` alloc/release op-streams and mutating traversals, across caps | +| `fuzz_pool` | `pool` inserts, erases, mutating sweeps and clones, lifetime-trapping payload | | `fuzz_sparse_set` | `sparse_set` against a `std::unordered_set` model, all three storage kinds | | `fuzz_sparse_map` | `sparse_map` against a `std::unordered_map` model, lifetime-trapping payload | @@ -16,6 +17,13 @@ aborts via `FUZZ_CHECK` when a structural invariant is violated, ASan/UBSan catch memory/UB bugs, the invariants catch logic bugs (e.g. the iteration-count check catches lost/leaked slabs). +The `multislab` and `pool` harnesses also hold iterators across mutation. A traversal +that releases and allocates as it walks must visit only live blocks, none twice, and +every block live for the whole walk exactly once; a stored `pool` iterator must still +dereference to its own element. Under a slab cap, an allocation must succeed exactly +when the cap leaves room, so a spurious `nullptr` fails. The `pool` payload carries a +magic word cleared by its destructor, like the map's. + The two sparse harnesses are differential: every operation runs against a standard container and the results are compared, on top of the structural invariants. The load-bearing ones are `index_of(keys()[i]) == i` (a sparse array left pointing at diff --git a/fuzz/fuzz_multislab.cpp b/fuzz/fuzz_multislab.cpp index 4a8aa2c..ef67e4b 100644 --- a/fuzz/fuzz_multislab.cpp +++ b/fuzz/fuzz_multislab.cpp @@ -4,30 +4,25 @@ * @file fuzz_multislab.cpp * @brief Coverage-guided fuzzer for `libmem::multislab`. * - * Interprets the libFuzzer input as a config header (which compile-time - * `BlocksPerSlab` instantiation to drive, plus a runtime max-slab cap) followed - * by an alloc/release opcode stream. Only valid operations are issued (releases - * always target a currently-live block), so the allocator's defensive asserts - * are never tripped; ASan, UBSan, and the structural invariants below surface - * the bugs. + * A config header picks a compile-time `BlocksPerSlab`, a runtime slab cap and a + * hysteresis reserve; the rest is an opcode stream of allocations, releases (by + * pointer and by iterator), and traversals that mutate the allocator as they go. + * Only valid operations are issued, so the allocator's asserts never fire. * - * Invariants (checked after every operation): - * - allocations never alias a still-live block, and are owned by the allocator; - * - the number of blocks reachable by iteration equals the number we hold live; - * - empty_slab_count() never exceeds slab_count(). - * On teardown a counting memory_resource verifies destroy() returns every byte. - * - * The iteration-count invariant is what catches the lazy full-list corruption - * class of bug (a lost slab makes the live count diverge); this drives it across - * random block sizes, caps, and interleavings. + * Invariants: + * - an allocation succeeds iff the cap leaves room, and never aliases a live block; + * - `begin()` reaches exactly the live blocks; `empty_slab_count() <= slab_count()`; + * - a traversal that releases and allocates while it walks visits only live blocks, + * none twice, and every block live for the whole walk exactly once; + * - an `allocate_at` iterator held across operations still starts at its block; + * - teardown returns every byte to the resource. */ +#include "fuzz_support.h" + #include #include -#include -#include -#include #include -#include +#include import libmem; @@ -36,128 +31,168 @@ using libmem::threshold_policy; namespace { -#define FUZZ_CHECK(cond) \ - do { \ - if (!(cond)) { \ - std::fprintf(stderr, "FUZZ INVARIANT FAILED: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \ - std::abort(); \ - } \ - } while (false) - -/* Byte-stream reader: yields zero once exhausted so the harness can drain input - * with a simple `while (more())` loop. */ -struct reader { - const std::uint8_t* data; - std::size_t size; - std::size_t pos{0}; - - std::uint8_t u8() { return pos < size ? data[pos++] : std::uint8_t{0}; } - - std::uint32_t u32() { - std::uint32_t v{0}; - for (int i{0}; i < 4; ++i) { - v = (v << 8) | u8(); - } - return v; - } +constexpr std::size_t live_cap{1024}; - std::uint32_t range(const std::uint32_t lo, const std::uint32_t hi) { return hi <= lo ? lo : lo + (u32() % (hi - lo + 1)); } +template class harness { + using multislab_type = multislab; + +public: + harness(fuzz::reader& r, fuzz::stats& st, const std::uint32_t max_slabs, const std::uint32_t reserve) + : r_{r}, max_slabs_{max_slabs}, ms_{max_slabs, fuzz::counting_resource{&st}, threshold_policy{.max_empty_reserve = reserve}} {} + + void run() { + while (r_.more()) { + switch (r_.u8() % 6u) { + case 0: + case 1: + allocate(); + break; + case 2: + case 3: + release_any(nullptr); + break; + case 4: + walk(); + break; + default: + hold(); + break; + } + check(); + } - bool more() const { return pos < size; } -}; + for (void* p : live_.keys()) { + ms_.deallocate(p); + } + ms_.destroy(); + } -struct stats { - std::size_t live_bytes{0}; - std::size_t allocs{0}; - std::size_t frees{0}; -}; +private: + fuzz::reader& r_; + std::uint32_t max_slabs_; + multislab_type ms_; + fuzz::live_set live_{}; + /* Everything released during the current walk; a reused address is not "live throughout". */ + std::unordered_set released_{}; -struct counting_resource { - stats* s{}; + bool has_room() const { return max_slabs_ == 0 || live_.size() < std::size_t{max_slabs_} * BlocksPerSlab; } - void* allocate(const std::size_t size) { - ++s->allocs; - s->live_bytes += size; - return ::operator new(size); + void allocate() { + if (live_.size() >= live_cap) { + return; + } + const bool room{has_room()}; + void* p{ms_.allocate()}; + FUZZ_CHECK((p != nullptr) == room); + if (p) { + FUZZ_CHECK(live_.insert(p)); // aliasing a live block fails the insert + } } - void deallocate(void* ptr, const std::size_t size) noexcept { - ++s->frees; - s->live_bytes -= size; - ::operator delete(ptr, size); + void release(void* p) { + if (r_.u8() & 1u) { + ms_.deallocate(ms_.make_iterator(p)); + } else { + ms_.deallocate(p); + } + live_.erase(p); + released_.insert(p); } - /* multislab takes its slab memory through these, so a resource backing one must be an - * aligned_memory_resource. */ - void* allocate(const std::size_t size, const std::size_t align) { - ++s->allocs; - s->live_bytes += size; - return ::operator new(size, std::align_val_t{align}); + /** @brief Release a random live block other than `keep`. */ + void release_any(const void* keep) { + if (live_.empty()) { + return; + } + void* p{live_.pick(r_)}; + if (p != keep) { + release(p); + } } - void deallocate(void* ptr, const std::size_t size, const std::size_t align) noexcept { - ++s->frees; - s->live_bytes -= size; - ::operator delete(ptr, size, std::align_val_t{align}); - } -}; - -constexpr std::size_t live_cap{1024}; + /* Erase-while-iterating, plus allocations and releases elsewhere, one per step. */ + void walk() { + const auto before{live_.keys()}; + std::unordered_set seen{}; + released_.clear(); + + for (auto it{ms_.begin()}; it != ms_.end();) { + void* const p{*it}; + FUZZ_CHECK(live_.contains(p)); + FUZZ_CHECK(seen.insert(p).second); + ++it; + switch (r_.u8() % 4u) { + case 0: + release(p); + break; + case 1: + allocate(); + break; + case 2: + release_any(it == ms_.end() ? nullptr : *it); + break; + default: + break; + } + } -template void run(reader& r) { - using ms_t = multislab; + for (void* p : before) { + FUZZ_CHECK(released_.contains(p) || seen.contains(p)); + } + } - stats st{}; - const std::uint32_t max_slabs{r.range(0, 6)}; // 0 = unlimited - const std::uint32_t reserve{r.range(0, 3)}; // hysteresis reserve + void hold() { + if (live_.size() >= live_cap) { + return; + } + const bool room{has_room()}; + const auto held{ms_.allocate_at()}; + FUZZ_CHECK((held.ptr != nullptr) == room); + if (!held.ptr) { + return; + } + FUZZ_CHECK(live_.insert(held.ptr)); - { - ms_t ms{max_slabs, counting_resource{&st}, threshold_policy{.max_empty_reserve = reserve}}; - - std::vector live{}; - - while (r.more()) { - const std::uint8_t op{r.u8()}; - - if ((op & 1u) == 0u) { - if (live.size() < live_cap) { - void* p{ms.allocate()}; - if (p) { - for (void* q : live) { - FUZZ_CHECK(q != p); // must not alias a live block - } - live.push_back(p); - } - } - } else if (!live.empty()) { - const std::size_t idx{r.u8() % live.size()}; - ms.deallocate(live[idx]); - live[idx] = live.back(); - live.pop_back(); + for (std::uint32_t n{r_.range(1, 8)}; n > 0 && r_.more(); --n) { + if (r_.u8() & 1u) { + allocate(); + } else { + release_any(held.ptr); } - - /* the blocks reachable by iteration must be exactly those we hold */ - const auto reachable{std::ranges::distance(ms.begin(), ms.end())}; - FUZZ_CHECK(static_cast(reachable) == live.size()); - FUZZ_CHECK(ms.empty_slab_count() <= ms.slab_count()); } - for (void* p : live) { - ms.deallocate(p); + auto it{held.it}; + FUZZ_CHECK(*it == held.ptr); + std::unordered_set seen{}; + for (; it != ms_.end(); ++it) { + FUZZ_CHECK(live_.contains(*it)); + FUZZ_CHECK(seen.insert(*it).second); } - ms.destroy(); + } + + void check() { + FUZZ_CHECK(static_cast(std::ranges::distance(ms_.begin(), ms_.end())) == live_.size()); + FUZZ_CHECK(ms_.empty_slab_count() <= ms_.slab_count()); + FUZZ_CHECK(max_slabs_ == 0 || ms_.slab_count() <= max_slabs_); + } +}; - FUZZ_CHECK(st.allocs == st.frees); - FUZZ_CHECK(st.live_bytes == 0); +template void run(fuzz::reader& r) { + fuzz::stats st{}; + const std::uint32_t max_slabs{r.range(0, 6)}; // 0 = unlimited + const std::uint32_t reserve{r.range(0, 3)}; + { + harness h{r, st, max_slabs, reserve}; + h.run(); } + fuzz::check_balanced(st); } } // namespace extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { - reader r{data, size}; + fuzz::reader r{data, size}; - /* dispatch over a handful of compile-time block-per-slab instantiations */ switch (r.u8() % 5u) { case 0: run<1>(r); diff --git a/fuzz/fuzz_pool.cpp b/fuzz/fuzz_pool.cpp new file mode 100644 index 0000000..f606267 --- /dev/null +++ b/fuzz/fuzz_pool.cpp @@ -0,0 +1,242 @@ +// Copyright (c) 2026 Aoto +// SPDX-License-Identifier: MIT +/** + * @file fuzz_pool.cpp + * @brief Coverage-guided fuzzer for `libmem::pool`. + * + * A config header picks a compile-time `BlocksPerSlab`, a slab cap and a hysteresis + * reserve; the rest is an opcode stream of inserts, erases through stored iterators, + * erase-while-iterating sweeps, clones and clears. Every element carries a unique id + * and a magic word its destructor clears, so a read of a destroyed element or a + * second destructor call aborts where it happens. + * + * Invariants (after every operation): + * - an insert succeeds iff the cap leaves room; + * - `size()`, the live element count and the length of `begin()..end()` agree with the model; + * - every stored iterator still dereferences to its own element; + * - `erase` returns `end()` or a live element; + * - a sweep visits only live elements, none twice, and every element live for the + * whole sweep exactly once; + * - `try_clone` holds the same ids; + * - teardown destroys every element and returns every byte to the resource. + */ +#include "fuzz_support.h" + +#include +#include +#include +#include +#include +#include +#include + +import libmem; + +using libmem::threshold_policy; + +namespace { + +constexpr std::size_t live_cap{1024}; + +struct guarded { + static constexpr std::uint32_t magic{0xC0FFEEu}; + static int live; + + std::uint32_t id{}; + std::uint32_t alive{magic}; + + explicit guarded(const std::uint32_t v) : id{v} { ++live; } + + guarded(const guarded& other) : id{other.read()} { ++live; } + guarded(guarded&& other) noexcept : id{other.read()} { ++live; } + guarded& operator=(const guarded&) = delete; + guarded& operator=(guarded&&) = delete; + + ~guarded() { + FUZZ_CHECK(alive == magic); // a second destructor call lands here + alive = 0; + --live; + } + + std::uint32_t read() const { + FUZZ_CHECK(alive == magic); + return id; + } +}; + +int guarded::live{0}; + +template class harness { + using pool_type = libmem::pool; + using iterator = typename pool_type::iterator; + +public: + harness(fuzz::reader& r, fuzz::stats& st, const std::uint32_t max_slabs, const std::uint32_t reserve) + : r_{r}, max_slabs_{max_slabs}, pool_{libmem::slab_limit{max_slabs}, fuzz::counting_resource{&st}, threshold_policy{.max_empty_reserve = reserve}} {} + + void run() { + while (r_.more()) { + switch (r_.u8() % 16u) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + insert(); + break; + case 6: + case 7: + case 8: + case 9: + erase_any(); + break; + case 10: + case 11: + sweep(); + break; + case 12: + clone(); + break; + case 13: + pool_.clear(); + ids_ = {}; + where_.clear(); + break; + default: + break; + } + check(); + } + } + +private: + fuzz::reader& r_; + std::uint32_t max_slabs_; + pool_type pool_; + fuzz::live_set ids_{}; + std::unordered_map where_{}; + std::uint32_t next_id_{0}; + + bool has_room() const { return max_slabs_ == 0 || ids_.size() < std::size_t{max_slabs_} * BlocksPerSlab; } + + void insert() { + if (ids_.size() >= live_cap) { + return; + } + const bool room{has_room()}; + const std::uint32_t id{next_id_++}; + const auto it{pool_.emplace(id)}; + FUZZ_CHECK((it != pool_.end()) == room); + if (it != pool_.end()) { + FUZZ_CHECK(it->read() == id); + FUZZ_CHECK(ids_.insert(id)); + where_.emplace(id, it); + } + } + + void forget(const std::uint32_t id) { + ids_.erase(id); + where_.erase(id); + } + + void check_next(const iterator next) const { FUZZ_CHECK(next == pool_.end() || ids_.contains(next->read())); } + + void erase_any() { + if (ids_.empty()) { + return; + } + const std::uint32_t id{ids_.pick(r_)}; + const iterator it{where_.at(id)}; + FUZZ_CHECK(it->read() == id); + const auto next{pool_.erase(it)}; + forget(id); + check_next(next); + } + + /* The erase-while-iterating idiom, with inserts along the way. Ids are never + * reused, so an id still live at the end was live for the whole sweep. */ + void sweep() { + const auto before{ids_.keys()}; + std::unordered_set seen{}; + + for (auto it{pool_.begin()}; it != pool_.end();) { + const std::uint32_t id{it->read()}; + FUZZ_CHECK(ids_.contains(id)); + FUZZ_CHECK(seen.insert(id).second); + switch (r_.u8() % 3u) { + case 0: + it = pool_.erase(it); + forget(id); + check_next(it); + break; + case 1: + ++it; + insert(); + break; + default: + ++it; + break; + } + } + + for (const std::uint32_t id : before) { + FUZZ_CHECK(!ids_.contains(id) || seen.contains(id)); + } + } + + void clone() { + auto copy{pool_.try_clone()}; + FUZZ_CHECK(copy.has_value()); // a dense copy never needs more slabs than the original + FUZZ_CHECK(copy->size() == pool_.size()); + FUZZ_CHECK(guarded::live == static_cast(2 * ids_.size())); + + auto ids{*copy | std::views::transform(&guarded::read) | std::ranges::to()}; + auto expected{ids_.keys()}; + std::ranges::sort(ids); + std::ranges::sort(expected); + FUZZ_CHECK(ids == expected); + } + + void check() { + FUZZ_CHECK(pool_.size() == ids_.size()); + FUZZ_CHECK(guarded::live == static_cast(ids_.size())); + FUZZ_CHECK(static_cast(std::ranges::distance(pool_.begin(), pool_.end())) == ids_.size()); + FUZZ_CHECK(max_slabs_ == 0 || pool_.slab_count() <= max_slabs_); + if (!ids_.empty()) { + const std::uint32_t id{ids_.pick(r_)}; + FUZZ_CHECK(where_.at(id)->read() == id); + } + } +}; + +template void run(fuzz::reader& r) { + fuzz::stats st{}; + const std::uint32_t max_slabs{r.range(0, 6)}; // 0 = unlimited + const std::uint32_t reserve{r.range(0, 3)}; + { + harness h{r, st, max_slabs, reserve}; + h.run(); + } + FUZZ_CHECK(guarded::live == 0); + fuzz::check_balanced(st); +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + fuzz::reader r{data, size}; + + switch (r.u8() % 3u) { + case 0: + run<1>(r); + break; + case 1: + run<4>(r); + break; + default: + run<64>(r); + break; + } + return 0; +} diff --git a/fuzz/fuzz_support.h b/fuzz/fuzz_support.h index 497725c..462aa2d 100644 --- a/fuzz/fuzz_support.h +++ b/fuzz/fuzz_support.h @@ -4,9 +4,9 @@ * @file fuzz_support.h * @brief Shared plumbing for the libFuzzer harnesses. * - * The input reader, the invariant-check macro, and a byte-counting + * The input reader, the invariant-check macro, a byte-counting * `memory_resource` used by every target to prove teardown returns everything it - * took. + * took, and the harnesses' record of what is live. */ #pragma once @@ -15,6 +15,8 @@ #include #include #include +#include +#include /** @brief Abort with the failing expression; libFuzzer records the input. */ #define FUZZ_CHECK(cond) \ @@ -93,6 +95,43 @@ struct counting_resource { } }; +/** @brief The harness's record of live keys: O(1) insert, erase, lookup and uniform pick. */ +template class live_set { +public: + bool contains(const K& key) const { return index_.contains(key); } + std::size_t size() const { return keys_.size(); } + bool empty() const { return keys_.empty(); } + const std::vector& keys() const { return keys_; } + + /** @return `false` when `key` is already live. */ + bool insert(const K& key) { + if (!index_.emplace(key, keys_.size()).second) { + return false; + } + keys_.push_back(key); + return true; + } + + void erase(const K& key) { + const auto it{index_.find(key)}; + FUZZ_CHECK(it != index_.end()); + const std::size_t i{it->second}; + index_.erase(it); + if (i + 1 != keys_.size()) { + keys_[i] = keys_.back(); + index_[keys_[i]] = i; + } + keys_.pop_back(); + } + + /** @pre `!empty()`. */ + const K& pick(reader& r) const { return keys_[r.range(0, static_cast(keys_.size() - 1))]; } + +private: + std::vector keys_{}; + std::unordered_map index_{}; +}; + /** @brief Assert the resource handed back every byte it gave out. */ inline void check_balanced(const stats& s) { FUZZ_CHECK(s.allocs == s.frees);