fold/tests/unit/test_boundary_seeds.cpp
trvon 7e33dd5276
feat(fuzz): S2 — integer boundary-derivative seeds (0/1/-1/MAX/MIN)
For each integer parameter in a PathWitness, derive boundary
perturbations: 0, 1, -1 (signed only), INT_MAX, INT_MIN (signed).
Classical fuzzing literature shows these catch off-by-one and
overflow bugs that random mutation needs many iterations to reach.

Derivatives share the primary witness's finding_id (so Phase 2's
crash-ingest still correlates by id equality) but are NOT added to
findings.predicted.json — adding them would bloat the prediction
map without changing the correlation key. They land in the same
corpus dir alongside the witness-derived and canonical seeds.

Content-hash filenames in corpus_writer naturally collapse
identical perturbations (e.g. when the primary already has flag=0).

* boundary_seeds.{hpp,cpp} — pure function over PathWitness POD
* fuzz_prep_runner.cpp — emit derivatives after each primary seed
* tests/unit/test_boundary_seeds.cpp — 7 cases / 39 assertions
  covering 32-bit signed (5), 32-bit unsigned (3), 8-bit signed,
  pointer skip, 2-param independence, bool (2), partial flag.

Smoke: leaky(int*, int) fixture grows from 4 seeds (1 witness +
3 canonical) to 9 seeds (1 witness + 5 boundaries + 3 canonical).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:03:10 -04:00

175 lines
5.9 KiB
C++

#include <algorithm>
#include <catch2/catch_test_macros.hpp>
#include <cstdint>
#include <vector>
#include "fold/fuzz/boundary_seeds.hpp"
#include "fold/fuzz/witness_recorder.hpp"
using fold::fuzz::deriveBoundarySeeds;
using fold::fuzz::ParamWitness;
using fold::fuzz::PathWitness;
using fold::fuzz::PointerShape;
using fold::fuzz::WitnessKind;
namespace {
PathWitness withSingleInt(std::uint32_t bit_width, bool is_signed) {
PathWitness w;
w.function = "f";
w.finding_id = "test:1";
ParamWitness p;
p.cpp_type = is_signed ? "int" : "unsigned";
p.kind = WitnessKind::Integer;
p.bit_width = bit_width;
p.is_signed = is_signed;
p.bytes =
std::vector<std::byte>{std::byte{0x42}, std::byte{0x00}, std::byte{0x00}, std::byte{0x00}};
w.params = {p};
return w;
}
std::uint64_t leAt(const std::vector<std::byte>& bytes, std::size_t off, std::size_t n) {
std::uint64_t out = 0;
for (std::size_t i = 0; i < n && off + i < bytes.size(); ++i)
out |= static_cast<std::uint64_t>(std::to_integer<std::uint8_t>(bytes[off + i])) << (8 * i);
return out;
}
} // namespace
TEST_CASE("deriveBoundarySeeds: signed int produces 5 derivatives (0, 1, -1, MAX, MIN)",
"[fuzz][boundary]") {
auto seeds = deriveBoundarySeeds(withSingleInt(32, /*is_signed=*/true));
REQUIRE(seeds.size() == 5);
// Spot-check each value at offset 0 (the only param).
std::vector<std::uint64_t> seen;
seen.reserve(seeds.size());
for (const auto& s : seeds) {
REQUIRE(s.params.size() == 1);
REQUIRE(s.params[0].bytes.size() == 4);
seen.push_back(leAt(s.params[0].bytes, 0, 4));
}
REQUIRE(std::find(seen.begin(), seen.end(), 0U) != seen.end());
REQUIRE(std::find(seen.begin(), seen.end(), 1U) != seen.end());
REQUIRE(std::find(seen.begin(), seen.end(), 0xFFFFFFFFU) != seen.end()); // -1
REQUIRE(std::find(seen.begin(), seen.end(), 0x7FFFFFFFU) != seen.end()); // INT_MAX
REQUIRE(std::find(seen.begin(), seen.end(), 0x80000000U) != seen.end()); // INT_MIN
}
TEST_CASE("deriveBoundarySeeds: unsigned int produces 3 derivatives (0, 1, MAX) — no -1/MIN",
"[fuzz][boundary]") {
auto seeds = deriveBoundarySeeds(withSingleInt(32, /*is_signed=*/false));
REQUIRE(seeds.size() == 3);
std::vector<std::uint64_t> seen;
seen.reserve(seeds.size());
for (const auto& s : seeds)
seen.push_back(leAt(s.params[0].bytes, 0, 4));
REQUIRE(std::find(seen.begin(), seen.end(), 0U) != seen.end());
REQUIRE(std::find(seen.begin(), seen.end(), 1U) != seen.end());
REQUIRE(std::find(seen.begin(), seen.end(), 0xFFFFFFFFU) != seen.end()); // UINT_MAX
}
TEST_CASE("deriveBoundarySeeds: 8-bit signed gives the right INT_MIN/INT_MAX", "[fuzz][boundary]") {
auto seeds = deriveBoundarySeeds(withSingleInt(8, true));
REQUIRE(seeds.size() == 5);
std::vector<std::uint64_t> seen;
for (const auto& s : seeds)
seen.push_back(leAt(s.params[0].bytes, 0, 1));
REQUIRE(std::find(seen.begin(), seen.end(), 0U) != seen.end());
REQUIRE(std::find(seen.begin(), seen.end(), 1U) != seen.end());
REQUIRE(std::find(seen.begin(), seen.end(), 0xFFU) != seen.end()); // -1 in u8
REQUIRE(std::find(seen.begin(), seen.end(), 0x7FU) != seen.end()); // INT8_MAX
REQUIRE(std::find(seen.begin(), seen.end(), 0x80U) != seen.end()); // INT8_MIN
}
TEST_CASE("deriveBoundarySeeds: skips Pointer and Unsupported params", "[fuzz][boundary]") {
PathWitness w;
w.function = "f";
w.finding_id = "test:1";
ParamWitness ptr;
ptr.cpp_type = "char*";
ptr.kind = WitnessKind::Pointer;
ptr.pointer.shape = PointerShape::NullableBuffer;
ptr.pointer.buffer_bytes = 64;
ParamWitness unsup;
unsup.cpp_type = "struct S";
unsup.kind = WitnessKind::Unsupported;
w.params = {ptr, unsup};
REQUIRE(deriveBoundarySeeds(w).empty());
}
TEST_CASE("deriveBoundarySeeds: 2-param witness perturbs each int independently",
"[fuzz][boundary]") {
PathWitness w;
w.function = "f";
w.finding_id = "test:1";
ParamWitness a;
a.cpp_type = "int";
a.kind = WitnessKind::Integer;
a.bit_width = 32;
a.is_signed = true;
a.bytes = std::vector<std::byte>(4, std::byte{0xAA});
ParamWitness b;
b.cpp_type = "unsigned char";
b.kind = WitnessKind::Integer;
b.bit_width = 8;
b.is_signed = false;
b.bytes = std::vector<std::byte>{std::byte{0xBB}};
w.params = {a, b};
auto seeds = deriveBoundarySeeds(w);
// 5 perturbations of `a` (each leaves b untouched) + 3 of `b`
// (each leaves a untouched) = 8 total.
REQUIRE(seeds.size() == 5 + 3);
// When `a` is perturbed, `b` should still be 0xBB. Spot-check
// one such seed.
bool found_a_perturbation_with_b_intact = false;
for (const auto& s : seeds) {
const std::uint64_t a_val = leAt(s.params[0].bytes, 0, 4);
const std::uint64_t b_val = leAt(s.params[1].bytes, 0, 1);
if (a_val == 0 && b_val == 0xBBU) {
found_a_perturbation_with_b_intact = true;
break;
}
}
REQUIRE(found_a_perturbation_with_b_intact);
}
TEST_CASE("deriveBoundarySeeds: bool param yields 2 derivatives (0, 1)", "[fuzz][boundary]") {
PathWitness w;
w.function = "f";
w.finding_id = "test:1";
ParamWitness p;
p.cpp_type = "bool";
p.kind = WitnessKind::Bool;
p.bit_width = 8;
p.bytes = std::vector<std::byte>{std::byte{1}};
w.params = {p};
auto seeds = deriveBoundarySeeds(w);
REQUIRE(seeds.size() == 2);
REQUIRE(static_cast<std::uint8_t>(seeds[0].params[0].bytes[0]) == 0);
REQUIRE(static_cast<std::uint8_t>(seeds[1].params[0].bytes[0]) == 1);
}
TEST_CASE("deriveBoundarySeeds marks derivatives as partial", "[fuzz][boundary]") {
PathWitness w = withSingleInt(32, true);
w.partial = false;
auto seeds = deriveBoundarySeeds(w);
REQUIRE(!seeds.empty());
for (const auto& s : seeds)
REQUIRE(s.partial);
// Original untouched.
REQUIRE_FALSE(w.partial);
}