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>
175 lines
5.9 KiB
C++
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);
|
|
}
|