From a25625287aa9a5af38c47ec1cb513a17b3c261e4 Mon Sep 17 00:00:00 2001 From: Francisco Freitas Date: Thu, 30 Jul 2026 15:08:10 +0200 Subject: [PATCH 1/4] feat: generate public state-prefix header Signed-off-by: Francisco Freitas --- src/cgen/emit.zig | 28 ++++- src/cgen/prefixheader.zig | 237 ++++++++++++++++++++++++++++++++++++++ src/root.zig | 2 + 3 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 src/cgen/prefixheader.zig diff --git a/src/cgen/emit.zig b/src/cgen/emit.zig index a542da4..56acb94 100644 --- a/src/cgen/emit.zig +++ b/src/cgen/emit.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Parser = @import("../Parser.zig"); const ir = @import("ir.zig"); +const prefixheader = @import("prefixheader.zig"); fn writeIndent(io: std.Io, file: *std.Io.File, depth: usize) !void { for (0..depth) |_| try file.writeStreamingAll(io, " "); @@ -250,12 +251,23 @@ pub fn writeFuzzerC( entry_name: []const u8, func_symbols: []const []const u8, ) !void { + const header_path = try prefixheader.path(allocator, out_path); + defer allocator.free(header_path); + try prefixheader.write(allocator, io, globals, needed_bytes, header_path); + var file = try std.Io.Dir.cwd().createFile(io, out_path, .{ .truncate = true }); defer file.close(io); var redef_file = try std.Io.Dir.cwd().createFile(io, redef_path, .{ .truncate = true }); defer redef_file.close(io); + const header_include = try std.fmt.allocPrint( + allocator, + "#include \"{s}\"\n", + .{std.fs.path.basename(header_path)}, + ); + defer allocator.free(header_include); + try file.writeStreamingAll(io, header_include); try file.writeStreamingAll(io, \\#include \\#include @@ -269,10 +281,6 @@ pub fn writeFuzzerC( defer allocator.free(fwd_decl); try file.writeStreamingAll(io, fwd_decl); - const globals_size_define = try std.fmt.allocPrint(allocator, "#define ABSOLUTION_GLOBALS_SIZE {d}\n\n", .{needed_bytes}); - defer allocator.free(globals_size_define); - try file.writeStreamingAll(io, globals_size_define); - for (func_symbols) |sym| { const func_decl = try std.fmt.allocPrint(allocator, "extern void {s}(void);\n", .{sym}); defer allocator.free(func_decl); @@ -317,7 +325,7 @@ fn emitSampler(allocator: std.mem.Allocator, io: std.Io, globals: []const Parser var ptr_idx: usize = 0; try file.writeStreamingAll(io, "ptrdiff_t sample_invariant(const uint8_t *data, size_t size) {\n"); try file.writeStreamingAll(io, " size_t off = 0;\n"); - try file.writeStreamingAll(io, " const size_t needed = ABSOLUTION_GLOBALS_SIZE ;\n"); + try file.writeStreamingAll(io, " const size_t needed = ABSOLUTION_STATE_PREFIX_SIZE;\n"); try file.writeStreamingAll(io, " if (size < needed) return -1;\n"); for (globals) |g| { @@ -1501,6 +1509,8 @@ test "writeFuzzerC end-to-end produces valid output" { defer alloc.free(dir_path); const out_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.c" }); defer alloc.free(out_path); + const header_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.h" }); + defer alloc.free(header_path); const redef_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.redef" }); defer alloc.free(redef_path); @@ -1532,9 +1542,15 @@ test "writeFuzzerC end-to-end produces valid output" { var buf: [16384]u8 = undefined; const out = try readTmpFile(&tmp, "fuzzer.c", &buf); + var header_buf: [16384]u8 = undefined; + const header = try readTmpFile(&tmp, "fuzzer.h", &header_buf); try std.testing.expect(std.mem.indexOf(u8, out, "#include ") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "#include \"fuzzer.h\"") != null); try std.testing.expect(std.mem.indexOf(u8, out, "int TestHarness(const uint8_t *data, size_t size)") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "#define ABSOLUTION_GLOBALS_SIZE 4") != null); + try std.testing.expect(std.mem.indexOf(u8, header, "#define ABSOLUTION_STATE_PREFIX_SIZE 4u") != null); + try std.testing.expect(std.mem.indexOf(u8, header, "} AbsolutionStatePrefix;") != null); + try std.testing.expect(std.mem.indexOf(u8, header, "global_0_g") != null); + try std.testing.expect(std.mem.indexOf(u8, header, "field_0_x_bytes[4]") != null); try std.testing.expect(std.mem.indexOf(u8, out, "uint8_t __attribute__((weak)) g[8]") != null); try std.testing.expect(std.mem.indexOf(u8, out, "sample_invariant") != null); try std.testing.expect(std.mem.indexOf(u8, out, "check_invariant") != null); diff --git a/src/cgen/prefixheader.zig b/src/cgen/prefixheader.zig new file mode 100644 index 0000000..9b55fee --- /dev/null +++ b/src/cgen/prefixheader.zig @@ -0,0 +1,237 @@ +const std = @import("std"); +const Parser = @import("../Parser.zig"); +const ir = @import("ir.zig"); + +pub fn path(allocator: std.mem.Allocator, out_path: []const u8) ![]const u8 { + const extension = std.fs.path.extension(out_path); + const stem = out_path[0 .. out_path.len - extension.len]; + return std.fmt.allocPrint(allocator, "{s}.h", .{stem}); +} + +fn sanitizedIdentifier(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + const result = try allocator.dupe(u8, name); + for (result) |*c| { + if (!std.ascii.isAlphanumeric(c.*) and c.* != '_') c.* = '_'; + } + return result; +} + +fn fieldStatePrefixBytes(field: Parser.Field) usize { + if (field.is_padding) return 0; + return switch (field.domain) { + .top => ir.elementBytes(field) * ir.dimsProduct(field.dims), + .values, .pointers => ir.constrainedSelectorBytes(field.domain) * ir.dimsProduct(field.dims), + .whole_values => ir.constrainedSelectorBytes(field.domain), + }; +} + +fn emitArrayDimensions(io: std.Io, file: *std.Io.File, dims: []const ir.Dimension) !void { + var buf: [64]u8 = undefined; + for (dims) |dim| { + const suffix = try std.fmt.bufPrint(&buf, "[{d}]", .{dim.len}); + try file.writeStreamingAll(io, suffix); + } +} + +/// Emit the public description of the bytes consumed by `sample_invariant`. +/// +/// Each global is represented by a packed nested structure. This preserves the +/// sampler's ordering: global dimensions, then fields, then field dimensions. +pub fn write( + allocator: std.mem.Allocator, + io: std.Io, + globals: []const Parser.Global, + needed_bytes: usize, + header_path: []const u8, +) !void { + var file = try std.Io.Dir.cwd().createFile(io, header_path, .{ .truncate = true }); + defer file.close(io); + + try file.writeStreamingAll(io, + \\#ifndef ABSOLUTION_STATE_PREFIX_H + \\#define ABSOLUTION_STATE_PREFIX_H + \\ + \\#include + \\ + \\#if defined(__GNUC__) || defined(__clang__) + \\#define ABSOLUTION_PACKED __attribute__((__packed__)) + \\#else + \\#define ABSOLUTION_PACKED + \\#endif + \\ + ); + + var size_buf: [96]u8 = undefined; + const size_define = try std.fmt.bufPrint( + &size_buf, + "#define ABSOLUTION_STATE_PREFIX_SIZE {d}u\n\n", + .{needed_bytes}, + ); + try file.writeStreamingAll(io, size_define); + try file.writeStreamingAll(io, "typedef struct ABSOLUTION_PACKED {\n"); + + var emitted_global = false; + for (globals, 0..) |global, global_idx| { + var has_encoded_fields = false; + for (global.fields) |field| { + if (fieldStatePrefixBytes(field) > 0) { + has_encoded_fields = true; + break; + } + } + if (!has_encoded_fields) continue; + emitted_global = true; + + const global_name = try sanitizedIdentifier(allocator, global.name); + defer allocator.free(global_name); + + try file.writeStreamingAll(io, " struct ABSOLUTION_PACKED {\n"); + for (global.fields, 0..) |field, field_idx| { + if (fieldStatePrefixBytes(field) == 0) continue; + + const field_name = try sanitizedIdentifier(allocator, field.name); + defer allocator.free(field_name); + var first_name_char: usize = 0; + while (first_name_char < field_name.len and field_name[first_name_char] == '_') { + first_name_char += 1; + } + const trimmed_field_name = field_name[first_name_char..]; + const member_name = if (trimmed_field_name.len > 0) trimmed_field_name else "unnamed"; + + var member_buf: [512]u8 = undefined; + const member = try std.fmt.bufPrint( + &member_buf, + " uint8_t field_{d}_{s}", + .{ field_idx, member_name }, + ); + try file.writeStreamingAll(io, member); + + switch (field.domain) { + .top => { + try file.writeStreamingAll(io, "_bytes"); + try emitArrayDimensions(io, &file, field.dims); + var bytes_buf: [64]u8 = undefined; + const bytes = try std.fmt.bufPrint(&bytes_buf, "[{d}]", .{ir.elementBytes(field)}); + try file.writeStreamingAll(io, bytes); + }, + .values, .pointers => { + try file.writeStreamingAll(io, "_selector"); + try emitArrayDimensions(io, &file, field.dims); + }, + .whole_values => try file.writeStreamingAll(io, "_selector"), + } + try file.writeStreamingAll(io, ";\n"); + } + + var global_member_buf: [512]u8 = undefined; + const global_member = try std.fmt.bufPrint( + &global_member_buf, + " }} global_{d}_{s}", + .{ global_idx, global_name }, + ); + try file.writeStreamingAll(io, global_member); + try emitArrayDimensions(io, &file, global.dims); + try file.writeStreamingAll(io, ";\n"); + } + + // GCC and Clang support zero-length arrays. This keeps the public type's + // size equal to zero when every state field is fixed by its invariant. + if (!emitted_global) try file.writeStreamingAll(io, " uint8_t _empty[0];\n"); + + try file.writeStreamingAll(io, + \\} AbsolutionStatePrefix; + \\ + \\#if defined(__cplusplus) + \\static_assert(sizeof(AbsolutionStatePrefix) == ABSOLUTION_STATE_PREFIX_SIZE, + \\ "AbsolutionStatePrefix layout mismatch"); + \\#else + \\_Static_assert(sizeof(AbsolutionStatePrefix) == ABSOLUTION_STATE_PREFIX_SIZE, + \\ "AbsolutionStatePrefix layout mismatch"); + \\#endif + \\ + \\#undef ABSOLUTION_PACKED + \\ + \\#endif + \\ + ); +} + +fn readTmpFile(tmp: *std.testing.TmpDir, name: []const u8, buf: []u8) ![]const u8 { + const io = std.testing.io; + var file = try tmp.dir.openFile(io, name, .{}); + defer file.close(io); + const n = try file.readPositionalAll(io, buf, 0); + return buf[0..n]; +} + +test "path replaces the C output extension" { + const alloc = std.testing.allocator; + const result = try path(alloc, "generated/fuzzer.c"); + defer alloc.free(result); + try std.testing.expectEqualStrings("generated/fuzzer.h", result); +} + +test "write mirrors sampler ordering and constrained selectors" { + const io = std.testing.io; + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const fields: []Parser.Field = @constCast(&[_]Parser.Field{ + .{ + .name = ".raw", + .bit_width = 16, + .dims = &.{.{ .len = 3, .stride_bytes = 2 }}, + .is_padding = false, + .domain = .top, + }, + .{ + .name = ".mode", + .bit_width = 32, + .is_padding = false, + .domain = .{ .values = &.{ &[_]u8{0}, &[_]u8{1} } }, + }, + .{ + .name = ".fixed", + .bit_width = 8, + .is_padding = false, + .domain = .{ .values = &.{&[_]u8{7}} }, + }, + }); + const globals: []const Parser.Global = &.{.{ + .name = "machine-state", + .source_file = "", + .size_bytes = 10, + .is_static = false, + .dims = &.{.{ .len = 2, .stride_bytes = 10 }}, + .fields = fields, + }}; + + const header_path = try std.fs.path.join(alloc, &.{ ".zig-cache", "tmp", &tmp.sub_path, "state.h" }); + defer alloc.free(header_path); + try write(alloc, io, globals, 14, header_path); + + var buf: [8192]u8 = undefined; + const header = try readTmpFile(&tmp, "state.h", &buf); + try std.testing.expect(std.mem.indexOf(u8, header, "#define ABSOLUTION_STATE_PREFIX_SIZE 14u") != null); + try std.testing.expect(std.mem.indexOf(u8, header, "field_0_raw_bytes[3][2]") != null); + try std.testing.expect(std.mem.indexOf(u8, header, "field_1_mode_selector;") != null); + try std.testing.expect(std.mem.indexOf(u8, header, "field_2_fixed") == null); + try std.testing.expect(std.mem.indexOf(u8, header, "global_0_machine_state[2]") != null); +} + +test "write supports an empty prefix" { + const io = std.testing.io; + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const header_path = try std.fs.path.join(alloc, &.{ ".zig-cache", "tmp", &tmp.sub_path, "empty.h" }); + defer alloc.free(header_path); + try write(alloc, io, &.{}, 0, header_path); + + var buf: [4096]u8 = undefined; + const header = try readTmpFile(&tmp, "empty.h", &buf); + try std.testing.expect(std.mem.indexOf(u8, header, "#define ABSOLUTION_STATE_PREFIX_SIZE 0u") != null); + try std.testing.expect(std.mem.indexOf(u8, header, "uint8_t _empty[0]") != null); +} diff --git a/src/root.zig b/src/root.zig index d8c4ff6..c10310e 100644 --- a/src/root.zig +++ b/src/root.zig @@ -10,11 +10,13 @@ pub const seed = @import("seed.zig"); pub const Invariant = @import("Invariant.zig"); pub const emit = @import("cgen/emit.zig"); pub const ir = @import("cgen/ir.zig"); +pub const prefixheader = @import("cgen/prefixheader.zig"); test { _ = @import("Invariant.zig"); _ = @import("cgen/ir.zig"); _ = @import("cgen/emit.zig"); + _ = @import("cgen/prefixheader.zig"); _ = @import("seed.zig"); _ = @import("type_flatten.zig"); _ = @import("include_paths.zig"); From b8790bd68ca6885eb3b9f5a66c417f744aec7bf6 Mon Sep 17 00:00:00 2001 From: Francisco Freitas Date: Thu, 30 Jul 2026 15:08:36 +0200 Subject: [PATCH 2/4] build: expose state-prefix header to custom mutators Signed-off-by: Francisco Freitas --- cmake/AbsolutionFuzzer.cmake | 9 +++++++-- scripts/integration.zig | 31 ++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/cmake/AbsolutionFuzzer.cmake b/cmake/AbsolutionFuzzer.cmake index 5898c59..781d880 100644 --- a/cmake/AbsolutionFuzzer.cmake +++ b/cmake/AbsolutionFuzzer.cmake @@ -41,6 +41,7 @@ # Exported variables (PARENT_SCOPE): # ${NAME}_SEED_FILE — Path to the generated seed file. # ${NAME}_FUZZER_C — Path to the generated fuzzer.c file. +# ${NAME}_STATE_HEADER — Path to the generated state-prefix header. # ${NAME}_REDEF_FILE — Path to the generated .redef file. # ${NAME}_GENERATE_TARGET — Name of the generate target. # ${NAME}_REDEF_TARGET — Name of the redef target. @@ -178,6 +179,7 @@ function(absolution_add_fuzzer) file(MAKE_DIRECTORY "${_FUZZ_DIR}") set(_FUZZER_C "${_FUZZ_DIR}/fuzzer.c") + set(_STATE_HEADER "${_FUZZ_DIR}/fuzzer.h") set(_REDEF_FILE "${_FUZZ_DIR}/fuzzer.redef") set(_SEED_FILE "${_FUZZ_DIR}/fuzzer.seed") set(_OBJ_LIST "${_FUZZ_DIR}/objfiles.txt") @@ -275,7 +277,7 @@ $,\n> set(_GENERATE_TARGET "${FUZZ_NAME}_generate") add_custom_command( - OUTPUT "${_FUZZER_C}" "${_REDEF_FILE}" "${_SEED_FILE}" + OUTPUT "${_FUZZER_C}" "${_STATE_HEADER}" "${_REDEF_FILE}" "${_SEED_FILE}" COMMAND "${CMAKE_COMMAND}" "-DABSOLUTION=${ABSOLUTION_EXECUTABLE}" "-DTARGETS_FILE=${_TARGETS_FILE}" @@ -293,11 +295,12 @@ $,\n> VERBATIM ) add_custom_target(${_GENERATE_TARGET} - DEPENDS "${_FUZZER_C}" "${_REDEF_FILE}" "${_SEED_FILE}" + DEPENDS "${_FUZZER_C}" "${_STATE_HEADER}" "${_REDEF_FILE}" "${_SEED_FILE}" ) set_target_properties(${_GENERATE_TARGET} PROPERTIES ABSOLUTION_FUZZER_C "${_FUZZER_C}" + ABSOLUTION_STATE_HEADER "${_STATE_HEADER}" ABSOLUTION_REDEF "${_REDEF_FILE}" ABSOLUTION_SEED "${_SEED_FILE}" ) @@ -340,6 +343,7 @@ $,\n> # ── Step 4: Link into the fuzzer executable ─────────────────────────────── add_executable(${FUZZ_NAME} "${_FUZZER_C}") + target_include_directories(${FUZZ_NAME} PRIVATE "${_FUZZ_DIR}") if(FUZZ_HARNESS) target_sources(${FUZZ_NAME} PRIVATE "${FUZZ_HARNESS}") @@ -382,6 +386,7 @@ $,\n> # ── Export ──────────────────────────────────────────────────────────────── set(${FUZZ_NAME}_SEED_FILE "${_SEED_FILE}" PARENT_SCOPE) set(${FUZZ_NAME}_FUZZER_C "${_FUZZER_C}" PARENT_SCOPE) + set(${FUZZ_NAME}_STATE_HEADER "${_STATE_HEADER}" PARENT_SCOPE) set(${FUZZ_NAME}_REDEF_FILE "${_REDEF_FILE}" PARENT_SCOPE) set(${FUZZ_NAME}_GENERATE_TARGET "${_GENERATE_TARGET}" PARENT_SCOPE) set(${FUZZ_NAME}_REDEF_TARGET "${_REDEF_TARGET}" PARENT_SCOPE) diff --git a/scripts/integration.zig b/scripts/integration.zig index d34b109..2e90d5f 100644 --- a/scripts/integration.zig +++ b/scripts/integration.zig @@ -235,6 +235,8 @@ fn runOneTest( const out_fuzzer = try std.fmt.allocPrint(arena, "{s}/fuzzer.c", .{test_dir}); const out_redef = try std.fmt.allocPrint(arena, "{s}/redef.txt", .{test_dir}); const out_obj = try std.fmt.allocPrint(arena, "{s}/fuzzer.o", .{test_dir}); + const mutator_c = try std.fmt.allocPrint(arena, "{s}/custom_mutator.c", .{test_dir}); + const mutator_obj = try std.fmt.allocPrint(arena, "{s}/custom_mutator.o", .{test_dir}); // -- Build absolution argv -- var argv: std.ArrayList([]const u8) = .empty; @@ -257,7 +259,34 @@ fn runOneTest( // 2. Compile generated fuzzer.c try execCapture(gpa, io, &.{ "zig", "cc", "-c", out_fuzzer, "-o", out_obj, "-I", tc.dir_path }); - // 3. Golden-file comparison + // 3. Compile a custom-mutator translation unit against the generated + // state-prefix API. + var mutator_file = try std.Io.Dir.cwd().createFile(io, mutator_c, .{ .truncate = true }); + defer mutator_file.close(io); + try mutator_file.writeStreamingAll(io, + \\#include "fuzzer.h" + \\#include + \\#include + \\#include + \\ + \\size_t LLVMFuzzerCustomMutator( + \\ uint8_t *data, size_t size, size_t max_size, unsigned seed + \\) { + \\ (void)seed; + \\ if (max_size < ABSOLUTION_STATE_PREFIX_SIZE) return 0; + \\ if (size < ABSOLUTION_STATE_PREFIX_SIZE) { + \\ memset(data + size, 0, ABSOLUTION_STATE_PREFIX_SIZE - size); + \\ size = ABSOLUTION_STATE_PREFIX_SIZE; + \\ } + \\ AbsolutionStatePrefix *state = (AbsolutionStatePrefix *)data; + \\ (void)state; + \\ return size; + \\} + \\ + ); + try execCapture(gpa, io, &.{ "zig", "cc", "-c", mutator_c, "-o", mutator_obj, "-I", test_dir }); + + // 4. Golden-file comparison const actual = try std.Io.Dir.cwd().readFileAlloc(io, out_zon, gpa, .limited(10 * 1024 * 1024)); defer gpa.free(actual); const expected = try std.Io.Dir.cwd().readFileAlloc(io, tc.golden_path, gpa, .limited(10 * 1024 * 1024)); From 0668dac7065e8d84ad88292ed71f99f97c29ce66 Mon Sep 17 00:00:00 2001 From: Francisco Freitas Date: Thu, 30 Jul 2026 15:08:55 +0200 Subject: [PATCH 3/4] docs: document custom mutator state-prefix API Signed-off-by: Francisco Freitas --- CONTRIBUTING.md | 14 ++++++++++--- README.md | 3 ++- USAGE.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a859e0a..d021b0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,9 +15,10 @@ absolution/ │ ├── include_paths.zig # Include path discovery (zig cc compatibility) │ ├── seed.zig # initial seed generation │ └── cgen/ -│ ├── ir.zig # Core data structures (Domain, Field, Global) +│ ├── ir.zig # Core data structures (Domain, Field, Global) │ ├── builder.zig # File writing utilities and type re-exports -│ └── emit.zig # C code emission (sampler, checker, entrypoint) +│ ├── emit.zig # C code emission (sampler, checker, entrypoint) +│ └── prefixheader.zig # Public state-prefix header generation ├── tests/ # Integration test cases │ └── / │ ├── .c # Test input @@ -78,11 +79,18 @@ Core data structures: ### `cgen/emit.zig` C code emission: -- `writeFuzzerC`: Writes includes, extern declarations, redef file, sampler, checker, and entrypoint +- `writeFuzzerC`: Coordinates header generation and writes includes, extern declarations, redef file, sampler, checker, and entrypoint - `emitSampler`: Generates `sample_invariant()` function - `emitChecker`: Generates `check_invariant()` function - `emitEntrypoint`: Generates `LLVMFuzzerTestOneInput()` +### `cgen/prefixheader.zig` + +State-prefix header emission: +- Derives the header path from the generated C output path +- Emits `AbsolutionStatePrefix` and `ABSOLUTION_STATE_PREFIX_SIZE` +- Preserves sampler ordering for global and field dimensions + ## Development Workflow ### Building diff --git a/README.md b/README.md index 7cb3c5a..4278cc4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Absolution lets you specify an invariant for a program’s global state and fuzz 1. Parse globals from your C translation unit(s) using [aro](https://github.com/Vexu/aro). 2. Build flattened globals containing fields, padding, and domains. 3. Optionally apply a `.zon` invariant to constrain field values (per-element `.values` / `.pointers`, or whole-field blobs with `.whole_values` on array-shaped fields; see [USAGE.md](USAGE.md)). -4. Emit `fuzzer.c` with sampling, invariant checking, and libFuzzer entrypoint. +4. Emit `fuzzer.c` with sampling, invariant checking, and libFuzzer entrypoint, + plus `fuzzer.h` describing the encoded state prefix for custom mutators. 5. Emit a symbol redefinition file for `objcopy` (handles `static` globals across translation units). 6. Write an optional seed file sized to the required random bytes. diff --git a/USAGE.md b/USAGE.md index b3dff6f..3f1d8ca 100644 --- a/USAGE.md +++ b/USAGE.md @@ -291,9 +291,60 @@ Example for `Config configs[10]` with `int values[5]` (struct size 8 bytes): }} ``` -## Generated Code +## Generated Output -The generated `fuzzer.c` contains: +Absolution generates the following C interface: + +### State-prefix header (`fuzzer.h` by default) + +Absolution also generates a header beside the requested C output, using the +same basename (`--out path/foo.c` produces `path/foo.h`). It exposes: + +- `ABSOLUTION_STATE_PREFIX_SIZE`, the number of bytes reserved for state. +- The packed `AbsolutionStatePrefix` type describing those bytes. + +The type contains one nested member per sampled global. Unconstrained fields +use a `_bytes` member containing their object representation. Constrained +value, whole-value, and pointer domains use one-byte `_selector` members. +Fields fixed to a single value and padding consume no input bytes and are +omitted. Selectors are reduced modulo the number of allowed candidates by the +generated sampler. + +This allows a libFuzzer custom mutator to handle state and entrypoint input +separately: + +```c +#include "fuzzer.h" +#include + +extern size_t LLVMFuzzerMutate( + uint8_t *data, size_t size, size_t max_size +); + +size_t LLVMFuzzerCustomMutator( + uint8_t *data, size_t size, size_t max_size, unsigned seed +) { + if (max_size < ABSOLUTION_STATE_PREFIX_SIZE) + return 0; + if (size < ABSOLUTION_STATE_PREFIX_SIZE) { + memset(data + size, 0, ABSOLUTION_STATE_PREFIX_SIZE - size); + size = ABSOLUTION_STATE_PREFIX_SIZE; + } + + AbsolutionStatePrefix *state = (AbsolutionStatePrefix *)data; + /* Mutate state->global__... */ + + uint8_t *input = data + ABSOLUTION_STATE_PREFIX_SIZE; + size_t input_size = size - ABSOLUTION_STATE_PREFIX_SIZE; + input_size = LLVMFuzzerMutate( + input, + input_size, + max_size - ABSOLUTION_STATE_PREFIX_SIZE + ); + + return ABSOLUTION_STATE_PREFIX_SIZE + input_size; +} +``` ### `sample_invariant(data, size)` From 8365fd19acadcd402d052cc0b599191c6ed7ddef Mon Sep 17 00:00:00 2001 From: Francisco Freitas Date: Thu, 30 Jul 2026 15:19:48 +0200 Subject: [PATCH 4/4] test: add custom mutators support in integ tests Signed-off-by: Francisco Freitas --- scripts/integration.zig | 26 ++++++++++++++-- tests/custom_mutator_applies_state/target.c | 17 ++++++++++ .../target.c.runtime.c | 31 +++++++++++++++++++ .../custom_mutator_applies_state/target.c.zon | 22 +++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/custom_mutator_applies_state/target.c create mode 100644 tests/custom_mutator_applies_state/target.c.runtime.c create mode 100644 tests/custom_mutator_applies_state/target.c.zon diff --git a/scripts/integration.zig b/scripts/integration.zig index 2e90d5f..4263bb9 100644 --- a/scripts/integration.zig +++ b/scripts/integration.zig @@ -3,7 +3,9 @@ //! Finds .c test files under tests/, builds absolution once, then for each test: //! 1. Runs absolution to produce .zon and fuzzer.c //! 2. Compiles the generated fuzzer.c with `zig cc` -//! 3. Compares the .zon output against a golden file +//! 3. Compiles a custom mutator against the generated state-prefix header +//! 4. Runs an optional runtime sidecar (`.runtime.c`) +//! 5. Compares the .zon output against a golden file //! //! Run with: zig run scripts/integration.zig //! @@ -107,6 +109,7 @@ const TestCase = struct { flags: []const []const u8 = &.{}, targets: []const []const u8 = &.{}, invariant_path: ?[]const u8 = null, + runtime_path: ?[]const u8 = null, }; // ----------------------------------------------------------------------- @@ -205,6 +208,10 @@ fn discoverTests(arena: std.mem.Allocator, io: std.Io, cases: *std.ArrayList(Tes const inv_path = try std.fmt.allocPrint(arena, "{s}.in", .{c_path}); const invariant_path: ?[]const u8 = if (fileExists(cwd, io, inv_path)) inv_path else null; + // .runtime.c sidecar (compiled and run with the generated fuzzer) + const runtime_candidate = try std.fmt.allocPrint(arena, "{s}.runtime.c", .{c_path}); + const runtime_path: ?[]const u8 = if (fileExists(cwd, io, runtime_candidate)) runtime_candidate else null; + try cases.append(arena, .{ .c_path = c_path, .golden_path = golden_path, @@ -213,6 +220,7 @@ fn discoverTests(arena: std.mem.Allocator, io: std.Io, cases: *std.ArrayList(Tes .flags = flags, .targets = targets, .invariant_path = invariant_path, + .runtime_path = runtime_path, }); } } @@ -237,6 +245,7 @@ fn runOneTest( const out_obj = try std.fmt.allocPrint(arena, "{s}/fuzzer.o", .{test_dir}); const mutator_c = try std.fmt.allocPrint(arena, "{s}/custom_mutator.c", .{test_dir}); const mutator_obj = try std.fmt.allocPrint(arena, "{s}/custom_mutator.o", .{test_dir}); + const runtime_exe = try std.fmt.allocPrint(arena, "{s}/runtime_test", .{test_dir}); // -- Build absolution argv -- var argv: std.ArrayList([]const u8) = .empty; @@ -286,7 +295,20 @@ fn runOneTest( ); try execCapture(gpa, io, &.{ "zig", "cc", "-c", mutator_c, "-o", mutator_obj, "-I", test_dir }); - // 4. Golden-file comparison + // 4. Compile and run a dedicated runtime sidecar, when present. + if (tc.runtime_path) |runtime_path| { + try execCapture(gpa, io, &.{ + "zig", "cc", + out_fuzzer, tc.c_path, + runtime_path, "-o", + runtime_exe, "-I", + test_dir, "-I", + tc.dir_path, + }); + try execCapture(gpa, io, &.{runtime_exe}); + } + + // 5. Golden-file comparison const actual = try std.Io.Dir.cwd().readFileAlloc(io, out_zon, gpa, .limited(10 * 1024 * 1024)); defer gpa.free(actual); const expected = try std.Io.Dir.cwd().readFileAlloc(io, tc.golden_path, gpa, .limited(10 * 1024 * 1024)); diff --git a/tests/custom_mutator_applies_state/target.c b/tests/custom_mutator_applies_state/target.c new file mode 100644 index 0000000..e73d7b7 --- /dev/null +++ b/tests/custom_mutator_applies_state/target.c @@ -0,0 +1,17 @@ +#include +#include + +struct { + uint8_t mode; + uint8_t counter; +} app_state; + +int AbsolutionTestOneInput(const uint8_t *data, size_t size) { + (void)data; + (void)size; + + if (app_state.mode != 0xA5 || app_state.counter != 0x5A) + __builtin_trap(); + + return 0; +} diff --git a/tests/custom_mutator_applies_state/target.c.runtime.c b/tests/custom_mutator_applies_state/target.c.runtime.c new file mode 100644 index 0000000..3757a56 --- /dev/null +++ b/tests/custom_mutator_applies_state/target.c.runtime.c @@ -0,0 +1,31 @@ +#include "fuzzer.h" + +#include +#include +#include + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); + +size_t LLVMFuzzerCustomMutator( + uint8_t *data, size_t size, size_t max_size, unsigned seed) { + (void)size; + (void)seed; + + if (max_size < ABSOLUTION_STATE_PREFIX_SIZE) + return 0; + + memset(data, 0, ABSOLUTION_STATE_PREFIX_SIZE); + AbsolutionStatePrefix *state = (AbsolutionStatePrefix *)data; + state->global_0_app_state.field_0_mode_bytes[0] = 0xA5; + state->global_0_app_state.field_1_counter_bytes[0] = 0x5A; + return ABSOLUTION_STATE_PREFIX_SIZE; +} + +int main(void) { + uint8_t data[64] = {0}; + size_t size = LLVMFuzzerCustomMutator(data, 0, sizeof(data), 1); + if (size != ABSOLUTION_STATE_PREFIX_SIZE) + return 1; + + return LLVMFuzzerTestOneInput(data, size); +} diff --git a/tests/custom_mutator_applies_state/target.c.zon b/tests/custom_mutator_applies_state/target.c.zon new file mode 100644 index 0000000..c57da8b --- /dev/null +++ b/tests/custom_mutator_applies_state/target.c.zon @@ -0,0 +1,22 @@ +.{.{ + .name = "app_state", + .source_file = "tests/custom_mutator_applies_state/target.c", + .size_bytes = 2, + .is_static = false, + .dims = .{}, + .fields = .{ .{ + .name = ".mode", + .offset_bits = 0, + .bit_width = 8, + .dims = .{}, + .is_padding = false, + .domain = .top, + }, .{ + .name = ".counter", + .offset_bits = 8, + .bit_width = 8, + .dims = .{}, + .is_padding = false, + .domain = .top, + } }, +}}