diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a859e0a..65e1eb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,7 @@ absolution/ │ └── / │ ├── .c # Test input │ ├── .c.in # Optional input invariant +│ ├── .c.offsets # Optional golden input layout │ └── .c.zon # Golden output ├── scripts/ │ ├── integration.zig # Integration test runner (zig run scripts/integration.zig) @@ -82,6 +83,8 @@ C code emission: - `emitSampler`: Generates `sample_invariant()` function - `emitChecker`: Generates `check_invariant()` function - `emitEntrypoint`: Generates `LLVMFuzzerTestOneInput()` +- `computeInputLayout`: Maps each global to its byte offset in the fuzzer input, + exported as C constants and a sidecar file when `--offsets` is given ## Development Workflow @@ -130,6 +133,10 @@ zig run scripts/integration.zig -- foo # Run only tests matching "foo" zig run scripts/integration.zig ``` +To also pin the input layout, add a `.c.offsets` golden. The runner always +requests offsets but only compares that file where it exists; `gen-golden.sh` +refreshes it under the same rule. + ## Architecture Notes ### Parsing pipeline diff --git a/README.md b/README.md index 7cb3c5a..526ba51 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Absolution lets you specify an invariant for a program’s global state and fuzz 4. Emit `fuzzer.c` with sampling, invariant checking, and libFuzzer entrypoint. 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. +7. Optionally export the input layout — the byte offset of every sampled global — as constants in the generated C and as a sidecar file (see [USAGE.md](USAGE.md)). ## Requirements @@ -34,6 +35,7 @@ zig build -Doptimize=ReleaseFast --out fuzzer.c \ --redef fuzzer.redef \ --seed fuzzer.seed \ + --offsets fuzzer.offsets \ -- -I path/to/include -DMY_DEFINE=42 # Compile targets, apply objcopy, link, and run diff --git a/USAGE.md b/USAGE.md index b3dff6f..e7538ef 100644 --- a/USAGE.md +++ b/USAGE.md @@ -24,6 +24,8 @@ OPTIONS: -i, --invariant Optional invariant file (.zon). -z, --zon Optional: export parsed module to .zon format. -s, --seed Optional seed file path (default: fuzzer.seed). + -m, --offsets Optional input layout output path; also exports the + layout as constants in the generated C. -e, --entry Optional harness function name (default: AbsolutionTestOneInput). ... C compiler flags after '--' (e.g. -I path -DFOO -fshort-enums). @@ -138,6 +140,61 @@ mkdir -p corpus && cp fuzzer.seed corpus/ For CMake projects, `absolution_add_fuzzer()` handles all of this automatically. See the [example/protocol_parser/](example/protocol_parser/) directory. +## Exporting the Input Layout + +The generated sampler reads the fuzzer input from front to back, giving every +global a fixed byte offset. `--offsets` publishes those offsets so a harness or +a corpus tool can address a specific global without reading the generated +source. + +Two exports are produced together: + +**Constants in the generated C**, so code linked against the fuzzer can read the +layout it was actually built with: + +```c +const size_t absolution_globals_size = 27; /* total sampled prefix */ +const size_t absolution_offset_input_value = 0; /* per global */ +const size_t absolution_offset_handlers = 4; +``` + +Declare what you need and let the linker supply the value; keep the declaration +weak if the harness must also build without absolution: + +```c +extern const size_t absolution_globals_size __attribute__((weak)); +extern const size_t absolution_offset_input_value __attribute__((weak)); +``` + +**A sidecar file** at the requested path, for build scripts and corpus +generators: + +``` +# absolution fuzzer input layout +# global +version 1 +globals_size 27 +global 0 4 0 absolution_offset_input_value input_value targets.c +global 4 16 0 absolution_offset_handlers handlers targets.c +global 20 7 1 absolution_offset_targets_c_cache cache targets.c +``` + +`offset` is relative to the start of the input, `bytes` is how many input bytes +the global consumes, and `source_file` comes last because it may contain spaces. +A global constrained to a single value consumes zero bytes, so it shares the +offset of whatever follows it. + +A `static` global is exported under the mangled name it receives after +`objcopy` renaming (see the `.redef` file), because that is the name it is +reachable by once linked. + +Offsets describe one generation run: constraining a field changes how many bytes +it consumes and shifts everything after it. Re-read the export after each build +rather than caching values. + +For CMake projects `absolution_add_fuzzer()` always writes the sidecar and +exposes its path as `${NAME}_OFFSETS_FILE`. + ## Invariant Language Invariants constrain the domains of global fields. They are written in Zig's `.zon` format. diff --git a/cmake/AbsolutionFuzzer.cmake b/cmake/AbsolutionFuzzer.cmake index 5898c59..a2bf70e 100644 --- a/cmake/AbsolutionFuzzer.cmake +++ b/cmake/AbsolutionFuzzer.cmake @@ -32,6 +32,10 @@ # linked normally. # SANITIZERS — sanitizer list (default: fuzzer,address). # +# Generated artifacts include an input layout export (fuzzer.offsets, plus +# matching constants in the generated C) giving the byte offset of every sampled +# global; see USAGE.md. +# # Created targets: # ${NAME}_objs — OBJECT library containing compiled target sources. # ${NAME}_generate — Custom target that runs absolution CLI to produce artifacts. @@ -42,6 +46,7 @@ # ${NAME}_SEED_FILE — Path to the generated seed file. # ${NAME}_FUZZER_C — Path to the generated fuzzer.c file. # ${NAME}_REDEF_FILE — Path to the generated .redef file. +# ${NAME}_OFFSETS_FILE — Path to the generated input layout sidecar. # ${NAME}_GENERATE_TARGET — Name of the generate target. # ${NAME}_REDEF_TARGET — Name of the redef target. @@ -180,6 +185,7 @@ function(absolution_add_fuzzer) set(_FUZZER_C "${_FUZZ_DIR}/fuzzer.c") set(_REDEF_FILE "${_FUZZ_DIR}/fuzzer.redef") set(_SEED_FILE "${_FUZZ_DIR}/fuzzer.seed") + set(_OFFSETS_FILE "${_FUZZ_DIR}/fuzzer.offsets") set(_OBJ_LIST "${_FUZZ_DIR}/objfiles.txt") set(_FLAGS_FILE "${_FUZZ_DIR}/absolution_flags.rsp") set(_TARGETS_FILE "${_FUZZ_DIR}/absolution_targets.txt") @@ -275,13 +281,14 @@ $,\n> set(_GENERATE_TARGET "${FUZZ_NAME}_generate") add_custom_command( - OUTPUT "${_FUZZER_C}" "${_REDEF_FILE}" "${_SEED_FILE}" + OUTPUT "${_FUZZER_C}" "${_REDEF_FILE}" "${_SEED_FILE}" "${_OFFSETS_FILE}" COMMAND "${CMAKE_COMMAND}" "-DABSOLUTION=${ABSOLUTION_EXECUTABLE}" "-DTARGETS_FILE=${_TARGETS_FILE}" "-DOUT_C=${_FUZZER_C}" "-DREDEF=${_REDEF_FILE}" "-DSEED=${_SEED_FILE}" + "-DOFFSETS=${_OFFSETS_FILE}" "-DENTRY=${FUZZ_ENTRY}" "-DINVARIANT=${_abs_inv}" "-DFLAGS_FILE=${_FLAGS_FILE}" @@ -293,13 +300,14 @@ $,\n> VERBATIM ) add_custom_target(${_GENERATE_TARGET} - DEPENDS "${_FUZZER_C}" "${_REDEF_FILE}" "${_SEED_FILE}" + DEPENDS "${_FUZZER_C}" "${_REDEF_FILE}" "${_SEED_FILE}" "${_OFFSETS_FILE}" ) set_target_properties(${_GENERATE_TARGET} PROPERTIES ABSOLUTION_FUZZER_C "${_FUZZER_C}" ABSOLUTION_REDEF "${_REDEF_FILE}" ABSOLUTION_SEED "${_SEED_FILE}" + ABSOLUTION_OFFSETS "${_OFFSETS_FILE}" ) add_dependencies(${_GENERATE_TARGET} ${_OBJ_LIB}) @@ -383,6 +391,7 @@ $,\n> set(${FUZZ_NAME}_SEED_FILE "${_SEED_FILE}" PARENT_SCOPE) set(${FUZZ_NAME}_FUZZER_C "${_FUZZER_C}" PARENT_SCOPE) set(${FUZZ_NAME}_REDEF_FILE "${_REDEF_FILE}" PARENT_SCOPE) + set(${FUZZ_NAME}_OFFSETS_FILE "${_OFFSETS_FILE}" PARENT_SCOPE) set(${FUZZ_NAME}_GENERATE_TARGET "${_GENERATE_TARGET}" PARENT_SCOPE) set(${FUZZ_NAME}_REDEF_TARGET "${_REDEF_TARGET}" PARENT_SCOPE) endfunction() \ No newline at end of file diff --git a/cmake/RunAbsolution.cmake b/cmake/RunAbsolution.cmake index 59e8a99..6ba58b8 100644 --- a/cmake/RunAbsolution.cmake +++ b/cmake/RunAbsolution.cmake @@ -8,6 +8,7 @@ # SEED — output .seed path # ENTRY — entry function name # INVARIANT — (optional) invariant file path +# OFFSETS — (optional) input layout sidecar output path # FLAGS_FILE — one compiler flag per line (-Ipath, -DFOO, -std=c99 …) # WORK_DIR — CMAKE_SOURCE_DIR (absolution resolves targets relative to this) @@ -28,6 +29,9 @@ list(APPEND _cmd if(INVARIANT) list(APPEND _cmd -i "${INVARIANT}") endif() +if(OFFSETS) + list(APPEND _cmd -m "${OFFSETS}") +endif() list(APPEND _cmd --) foreach(_f ${_flags}) list(APPEND _cmd "${_f}") diff --git a/scripts/gen-golden.sh b/scripts/gen-golden.sh index 42dc11e..7710e3f 100755 --- a/scripts/gen-golden.sh +++ b/scripts/gen-golden.sh @@ -8,7 +8,9 @@ # ./scripts/gen-golden.sh tests/aroccbug # # This script finds all .c files in the given test directory and generates -# corresponding .zon golden files by running absolution. +# corresponding .zon golden files by running absolution. A .offsets golden is +# refreshed only when the test already ships one, matching the integration +# runner, which compares that sidecar only where it exists. set -euo pipefail @@ -47,6 +49,7 @@ trap "rm -rf $TMPDIR" EXIT for C_FILE in $C_FILES; do BASENAME=$(basename "$C_FILE") ZON_FILE="${TEST_DIR}/${BASENAME}.zon" + OFFSETS_FILE="${TEST_DIR}/${BASENAME}.offsets" FLAGS_FILE="${TEST_DIR}/${BASENAME}.flags" echo "Generating golden file for: $C_FILE" @@ -75,6 +78,7 @@ for C_FILE in $C_FILES; do --zon "$TMPDIR/${BASENAME}.zon" \ --out "$TMPDIR/${BASENAME}.fuzzer.c" \ --redef "$TMPDIR/${BASENAME}.redef.txt" \ + --offsets "$TMPDIR/${BASENAME}.offsets" \ -- "${EXTRA_ARGS[@]}" else "$ABSOLUTION" \ @@ -82,13 +86,19 @@ for C_FILE in $C_FILES; do "${INVARIANT_ARGS[@]}" \ --zon "$TMPDIR/${BASENAME}.zon" \ --out "$TMPDIR/${BASENAME}.fuzzer.c" \ - --redef "$TMPDIR/${BASENAME}.redef.txt" + --redef "$TMPDIR/${BASENAME}.redef.txt" \ + --offsets "$TMPDIR/${BASENAME}.offsets" fi # Copy the generated .zon to the test directory cp "$TMPDIR/${BASENAME}.zon" "$ZON_FILE" echo " -> Created: $ZON_FILE" + + if [ -f "$OFFSETS_FILE" ]; then + cp "$TMPDIR/${BASENAME}.offsets" "$OFFSETS_FILE" + echo " -> Updated: $OFFSETS_FILE" + fi done echo "Done!" diff --git a/scripts/integration.zig b/scripts/integration.zig index d34b109..55e9301 100644 --- a/scripts/integration.zig +++ b/scripts/integration.zig @@ -1,9 +1,10 @@ //! Integration tests for absolution. //! //! Finds .c test files under tests/, builds absolution once, then for each test: -//! 1. Runs absolution to produce .zon and fuzzer.c +//! 1. Runs absolution to produce .zon, fuzzer.c and the input offsets sidecar //! 2. Compiles the generated fuzzer.c with `zig cc` //! 3. Compares the .zon output against a golden file +//! 4. Compares the offsets sidecar against a golden file, when one exists //! //! Run with: zig run scripts/integration.zig //! @@ -107,6 +108,8 @@ const TestCase = struct { flags: []const []const u8 = &.{}, targets: []const []const u8 = &.{}, invariant_path: ?[]const u8 = null, + /// Golden input-layout sidecar; only compared when the fixture ships one. + offsets_golden_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; + // .offsets sidecar (golden input layout) + const offsets_path = try std.fmt.allocPrint(arena, "{s}.offsets", .{c_path}); + const offsets_golden_path: ?[]const u8 = if (fileExists(cwd, io, offsets_path)) offsets_path 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, + .offsets_golden_path = offsets_golden_path, }); } } @@ -234,6 +242,7 @@ fn runOneTest( const out_zon = try std.fmt.allocPrint(arena, "{s}/out.zon", .{test_dir}); 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_offsets = try std.fmt.allocPrint(arena, "{s}/fuzzer.offsets", .{test_dir}); const out_obj = try std.fmt.allocPrint(arena, "{s}/fuzzer.o", .{test_dir}); // -- Build absolution argv -- @@ -245,7 +254,9 @@ fn runOneTest( if (tc.invariant_path) |inv| { try argv.appendSlice(arena, &.{ "-i", inv }); } - try argv.appendSlice(arena, &.{ "--zon", out_zon, "--out", out_fuzzer, "--redef", out_redef }); + // Offsets are always requested so every fixture also checks that the + // exported layout constants compile. + try argv.appendSlice(arena, &.{ "--zon", out_zon, "--out", out_fuzzer, "--redef", out_redef, "--offsets", out_offsets }); if (tc.flags.len > 0) { try argv.append(arena, "--"); try argv.appendSlice(arena, tc.flags); @@ -268,6 +279,20 @@ fn runOneTest( std.debug.print(" Actual output: {s}\n", .{out_zon}); return error.GoldenMismatch; } + + // 4. Offsets golden comparison + if (tc.offsets_golden_path) |offsets_golden| { + const actual_offsets = try std.Io.Dir.cwd().readFileAlloc(io, out_offsets, gpa, .limited(10 * 1024 * 1024)); + defer gpa.free(actual_offsets); + const expected_offsets = try std.Io.Dir.cwd().readFileAlloc(io, offsets_golden, gpa, .limited(10 * 1024 * 1024)); + defer gpa.free(expected_offsets); + + if (!std.mem.eql(u8, actual_offsets, expected_offsets)) { + std.debug.print(" Offsets mismatch: expected {s}\n", .{offsets_golden}); + std.debug.print(" Actual output: {s}\n", .{out_offsets}); + return error.OffsetsMismatch; + } + } } // ----------------------------------------------------------------------- diff --git a/src/cgen/emit.zig b/src/cgen/emit.zig index a542da4..9e4d9f8 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 seed = @import("../seed.zig"); fn writeIndent(io: std.Io, file: *std.Io.File, depth: usize) !void { for (0..depth) |_| try file.writeStreamingAll(io, " "); @@ -92,6 +93,168 @@ fn mangleName(allocator: std.mem.Allocator, path: []const u8, symbol: []const u8 return std.fmt.allocPrint(allocator, "{s}_{s}", .{ sanitized, symbol }); } +/// Name a global is reachable by after linking: mangled for `static` storage +/// (which `objcopy` renames via the redefinition file), unchanged otherwise. +fn linkedName(allocator: std.mem.Allocator, g: Parser.Global) ![]const u8 { + return if (g.is_static) + mangleName(allocator, g.source_file, g.name) + else + allocator.dupe(u8, g.name); +} + +/// Prefix of the emitted per-global offset constants. +pub const offset_symbol_prefix = "absolution_offset_"; + +/// Name of the emitted constant holding the total sampled prefix size. +pub const globals_size_symbol = "absolution_globals_size"; + +/// Version of the `--offsets` sidecar format. +pub const offsets_format_version = 1; + +/// Where one global's bytes start within the fuzzer input. +pub const GlobalInputOffset = struct { + /// Name as written in the source. + name: []const u8, + /// Translation unit the global was parsed from. + source_file: []const u8, + is_static: bool, + /// Linker-visible name; also the suffix of the emitted offset constant. + linked_name: []const u8, + /// Byte offset of the global's first input byte, relative to input start. + input_offset: usize, + /// Input bytes the global consumes (0 when fully padding or fully constrained). + input_bytes: usize, +}; + +/// Map every global to its byte offset in the fuzzer input, in sampling order. +/// +/// Offsets are relative to the start of the input (base 0) and match the `off` +/// counter the emitted `sample_invariant()` walks, so the sum of `input_bytes` +/// equals @ref seed.neededBytesFromGlobals. Caller owns the result; free it +/// with @ref freeInputLayout. +pub fn computeInputLayout( + allocator: std.mem.Allocator, + globals: []const Parser.Global, +) ![]GlobalInputOffset { + var layout: std.ArrayList(GlobalInputOffset) = .empty; + errdefer { + for (layout.items) |entry| allocator.free(entry.linked_name); + layout.deinit(allocator); + } + + var off: usize = 0; + for (globals) |g| { + const linked = try linkedName(allocator, g); + errdefer allocator.free(linked); + + const bytes = ir.globalInputBytes(g); + try layout.append(allocator, .{ + .name = g.name, + .source_file = g.source_file, + .is_static = g.is_static, + .linked_name = linked, + .input_offset = off, + .input_bytes = bytes, + }); + off += bytes; + } + + return layout.toOwnedSlice(allocator); +} + +pub fn freeInputLayout(allocator: std.mem.Allocator, layout: []const GlobalInputOffset) void { + for (layout) |entry| allocator.free(entry.linked_name); + allocator.free(layout); +} + +/// Emit the total prefix size and one offset constant per global. +/// +/// These have external linkage so a harness can read the layout it was built +/// against instead of re-deriving it from the generated source. A repeated +/// linked name is emitted once: C rejects a duplicate definition even when the +/// two values agree. +fn emitOffsetSymbols( + allocator: std.mem.Allocator, + io: std.Io, + file: *std.Io.File, + layout: []const GlobalInputOffset, + needed_bytes: usize, +) !void { + const size_def = try std.fmt.allocPrint( + allocator, + "const size_t {s} = {d};\n", + .{ globals_size_symbol, needed_bytes }, + ); + defer allocator.free(size_def); + try file.writeStreamingAll(io, size_def); + + var seen: std.StringHashMapUnmanaged(void) = .empty; + defer seen.deinit(allocator); + + for (layout) |entry| { + if ((try seen.getOrPut(allocator, entry.linked_name)).found_existing) continue; + + const line = try std.fmt.allocPrint( + allocator, + "const size_t {s}{s} = {d};\n", + .{ offset_symbol_prefix, entry.linked_name, entry.input_offset }, + ); + defer allocator.free(line); + try file.writeStreamingAll(io, line); + } + + try file.writeStreamingAll(io, "\n"); +} + +/// Write the machine-readable offset sidecar. +/// +/// Line-oriented like the redefinition file so tooling can read it without a +/// parser: `version` and `globals_size` directives, then one `global` record +/// per entry. `source_file` comes last because it may contain spaces. +fn writeOffsetsFile( + allocator: std.mem.Allocator, + io: std.Io, + path: []const u8, + layout: []const GlobalInputOffset, + needed_bytes: usize, +) !void { + var file = try std.Io.Dir.cwd().createFile(io, path, .{ .truncate = true }); + defer file.close(io); + + try file.writeStreamingAll(io, + \\# absolution fuzzer input layout + \\# global + \\ + ); + + const header = try std.fmt.allocPrint( + allocator, + "version {d}\nglobals_size {d}\n", + .{ offsets_format_version, needed_bytes }, + ); + defer allocator.free(header); + try file.writeStreamingAll(io, header); + + for (layout) |entry| { + const source_file = if (entry.source_file.len == 0) "-" else entry.source_file; + const line = try std.fmt.allocPrint( + allocator, + "global {d} {d} {d} {s}{s} {s} {s}\n", + .{ + entry.input_offset, + entry.input_bytes, + @intFromBool(entry.is_static), + offset_symbol_prefix, + entry.linked_name, + entry.name, + source_file, + }, + ); + defer allocator.free(line); + try file.writeStreamingAll(io, line); + } +} + /// Build a C expression that indexes into a dense whole_values blob using the /// loop variables from the field dimensions. The blob stores elements /// sequentially (no stride gaps), so the offset for element (i_k, i_k+1, ...) @@ -240,6 +403,9 @@ fn emitDomainTables(io: std.Io, globals: []const Parser.Global, file: *std.Io.Fi /// Generate the complete fuzzer C file and `objcopy` redefinition file. /// Writes includes, extern/weak declarations, sampler, checker, and libFuzzer entrypoint. +/// +/// When `offsets_path` is set, the input layout is additionally exported: as +/// linkable constants in the generated source and as a sidecar file at that path. pub fn writeFuzzerC( allocator: std.mem.Allocator, io: std.Io, @@ -249,6 +415,7 @@ pub fn writeFuzzerC( redef_path: []const u8, entry_name: []const u8, func_symbols: []const []const u8, + offsets_path: ?[]const u8, ) !void { var file = try std.Io.Dir.cwd().createFile(io, out_path, .{ .truncate = true }); defer file.close(io); @@ -281,10 +448,7 @@ pub fn writeFuzzerC( if (func_symbols.len > 0) try file.writeStreamingAll(io, "\n"); for (globals) |g| { - const mangled = if (g.is_static) - try mangleName(allocator, g.source_file, g.name) - else - try allocator.dupe(u8, g.name); + const mangled = try linkedName(allocator, g); defer allocator.free(mangled); if (g.is_static) { @@ -300,6 +464,14 @@ pub fn writeFuzzerC( try file.writeStreamingAll(io, "\n"); + if (offsets_path) |path| { + const layout = try computeInputLayout(allocator, globals); + defer freeInputLayout(allocator, layout); + + try emitOffsetSymbols(allocator, io, &file, layout, needed_bytes); + try writeOffsetsFile(allocator, io, path, layout, needed_bytes); + } + try emitDomainTables(io, globals, &file); try emitSampler(allocator, io, globals, &file); try emitChecker(allocator, io, globals, &file); @@ -824,6 +996,331 @@ test "mangleName handles empty path" { try std.testing.expectEqualStrings("_sym", result); } +test "linkedName leaves extern globals untouched and mangles statics" { + const alloc = std.testing.allocator; + + const ext = try linkedName(alloc, .{ + .name = "shared", + .source_file = "src/mod.c", + .size_bytes = 4, + .is_static = false, + .dims = &.{}, + }); + defer alloc.free(ext); + try std.testing.expectEqualStrings("shared", ext); + + const stat = try linkedName(alloc, .{ + .name = "hidden", + .source_file = "src/mod.c", + .size_bytes = 4, + .is_static = true, + .dims = &.{}, + }); + defer alloc.free(stat); + try std.testing.expectEqualStrings("src_mod_c_hidden", stat); +} + +// --------------------------------------------------------------------------- +// Input layout tests +// --------------------------------------------------------------------------- + +/// Build a single-field global for layout tests. +fn layoutTestGlobal( + name: []const u8, + source_file: []const u8, + is_static: bool, + dims: []const ir.Dimension, + fields: []Parser.Field, +) Parser.Global { + return .{ + .name = name, + .source_file = source_file, + .size_bytes = 64, + .is_static = is_static, + .dims = dims, + .fields = fields, + }; +} + +test "computeInputLayout assigns sequential offsets across globals" { + const alloc = std.testing.allocator; + + const first: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".x", .bit_width = 32 }}); + const second: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".y", .bit_width = 8 }}); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("a", "", false, &.{}, first), + layoutTestGlobal("b", "", false, &.{}, second), + }; + + const layout = try computeInputLayout(alloc, globals); + defer freeInputLayout(alloc, layout); + + try std.testing.expectEqual(@as(usize, 2), layout.len); + try std.testing.expectEqual(@as(usize, 0), layout[0].input_offset); + try std.testing.expectEqual(@as(usize, 4), layout[0].input_bytes); + try std.testing.expectEqual(@as(usize, 4), layout[1].input_offset); + try std.testing.expectEqual(@as(usize, 1), layout[1].input_bytes); +} + +test "computeInputLayout skips padding without advancing the offset" { + const alloc = std.testing.allocator; + + const fields: []Parser.Field = @constCast(&[_]Parser.Field{ + .{ .name = ".x", .bit_width = 8 }, + .{ .name = "._pad0", .offset_bits = 8, .bit_width = 24, .is_padding = true }, + .{ .name = ".y", .offset_bits = 32, .bit_width = 32 }, + }); + const trailing: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".z", .bit_width = 8 }}); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("padded", "", false, &.{}, fields), + layoutTestGlobal("after", "", false, &.{}, trailing), + }; + + const layout = try computeInputLayout(alloc, globals); + defer freeInputLayout(alloc, layout); + + try std.testing.expectEqual(@as(usize, 5), layout[0].input_bytes); + try std.testing.expectEqual(@as(usize, 5), layout[1].input_offset); +} + +test "computeInputLayout multiplies global and field dimensions" { + const alloc = std.testing.allocator; + + const fields: []Parser.Field = @constCast(&[_]Parser.Field{.{ + .name = ".x", + .bit_width = 8, + .dims = &.{.{ .len = 4, .stride_bytes = 1 }}, + }}); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("arr", "", false, &.{.{ .len = 3, .stride_bytes = 4 }}, fields), + }; + + const layout = try computeInputLayout(alloc, globals); + defer freeInputLayout(alloc, layout); + + try std.testing.expectEqual(@as(usize, 12), layout[0].input_bytes); +} + +test "computeInputLayout spends one selector byte per constrained instance" { + const alloc = std.testing.allocator; + + const multi: []Parser.Field = @constCast(&[_]Parser.Field{ + .{ .name = ".a", .bit_width = 32, .domain = .{ .values = &.{ "0xAA", "0xBB" } } }, + .{ .name = ".b", .bit_width = 64, .domain = .{ .pointers = &.{ "f", "g" } } }, + }); + const singleton: []Parser.Field = @constCast(&[_]Parser.Field{ + .{ .name = ".a", .bit_width = 32, .domain = .{ .values = &.{"0xAA"} } }, + .{ .name = ".b", .bit_width = 64, .domain = .{ .pointers = &.{"f"} } }, + }); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("constrained", "", false, &.{}, multi), + layoutTestGlobal("pinned", "", false, &.{}, singleton), + }; + + const layout = try computeInputLayout(alloc, globals); + defer freeInputLayout(alloc, layout); + + try std.testing.expectEqual(@as(usize, 2), layout[0].input_bytes); + // A pinned global consumes nothing, so it shares its successor's offset. + try std.testing.expectEqual(@as(usize, 2), layout[1].input_offset); + try std.testing.expectEqual(@as(usize, 0), layout[1].input_bytes); +} + +test "computeInputLayout charges whole_values per global instance only" { + const alloc = std.testing.allocator; + + const fields: []Parser.Field = @constCast(&[_]Parser.Field{.{ + .name = ".buf", + .bit_width = 8, + .dims = &.{.{ .len = 2, .stride_bytes = 1 }}, + .domain = .{ .whole_values = &.{ &[_]u8{ 1, 2 }, &[_]u8{ 3, 4 } } }, + }}); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("blobs", "", false, &.{.{ .len = 3, .stride_bytes = 2 }}, fields), + }; + + const layout = try computeInputLayout(alloc, globals); + defer freeInputLayout(alloc, layout); + + try std.testing.expectEqual(@as(usize, 3), layout[0].input_bytes); +} + +test "computeInputLayout keeps same-named statics distinct" { + const alloc = std.testing.allocator; + + const first: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".x", .bit_width = 8 }}); + const second: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".x", .bit_width = 8 }}); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("state", "a/one.c", true, &.{}, first), + layoutTestGlobal("state", "b/two.c", true, &.{}, second), + }; + + const layout = try computeInputLayout(alloc, globals); + defer freeInputLayout(alloc, layout); + + try std.testing.expectEqualStrings("a_one_c_state", layout[0].linked_name); + try std.testing.expectEqualStrings("b_two_c_state", layout[1].linked_name); + try std.testing.expectEqual(@as(usize, 0), layout[0].input_offset); + try std.testing.expectEqual(@as(usize, 1), layout[1].input_offset); +} + +test "computeInputLayout handles a fully padded global" { + const alloc = std.testing.allocator; + + const fields: []Parser.Field = @constCast(&[_]Parser.Field{ + .{ .name = "._pad0", .bit_width = 32, .is_padding = true }, + }); + const globals: []const Parser.Global = &.{layoutTestGlobal("zero", "", false, &.{}, fields)}; + + const layout = try computeInputLayout(alloc, globals); + defer freeInputLayout(alloc, layout); + + try std.testing.expectEqual(@as(usize, 0), layout[0].input_offset); + try std.testing.expectEqual(@as(usize, 0), layout[0].input_bytes); +} + +test "computeInputLayout is empty for no globals" { + const alloc = std.testing.allocator; + const layout = try computeInputLayout(alloc, &.{}); + defer freeInputLayout(alloc, layout); + try std.testing.expectEqual(@as(usize, 0), layout.len); +} + +test "computeInputLayout total agrees with neededBytesFromGlobals" { + const alloc = std.testing.allocator; + + const mixed: []Parser.Field = @constCast(&[_]Parser.Field{ + .{ .name = ".x", .bit_width = 16 }, + .{ .name = "._pad0", .offset_bits = 16, .bit_width = 16, .is_padding = true }, + .{ .name = ".sel", .offset_bits = 32, .bit_width = 32, .domain = .{ .values = &.{ "1", "2", "3" } } }, + }); + const arrayed: []Parser.Field = @constCast(&[_]Parser.Field{.{ + .name = ".b", + .bit_width = 8, + .dims = &.{.{ .len = 5, .stride_bytes = 1 }}, + }}); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("mixed", "src/a.c", true, &.{}, mixed), + layoutTestGlobal("arrayed", "src/b.c", false, &.{.{ .len = 2, .stride_bytes = 5 }}, arrayed), + }; + + const layout = try computeInputLayout(alloc, globals); + defer freeInputLayout(alloc, layout); + + var total: usize = 0; + for (layout) |entry| total += entry.input_bytes; + + try std.testing.expectEqual(seed.neededBytesFromGlobals(globals), total); + const last = layout[layout.len - 1]; + try std.testing.expectEqual(total, last.input_offset + last.input_bytes); +} + +test "writeFuzzerC without offsets path emits no layout constants" { + const io = std.testing.io; + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try std.fs.path.join(alloc, &.{ ".zig-cache", "tmp", &tmp.sub_path }); + defer alloc.free(dir_path); + const out_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.c" }); + defer alloc.free(out_path); + const redef_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.redef" }); + defer alloc.free(redef_path); + + const fields: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".x", .bit_width = 32 }}); + const globals: []const Parser.Global = &.{layoutTestGlobal("g", "", false, &.{}, fields)}; + + try writeFuzzerC(alloc, io, globals, 4, out_path, redef_path, "Entry", &.{}, null); + + var buf: [16384]u8 = undefined; + const out = try readTmpFile(&tmp, "fuzzer.c", &buf); + try std.testing.expect(std.mem.indexOf(u8, out, globals_size_symbol) == null); + try std.testing.expect(std.mem.indexOf(u8, out, offset_symbol_prefix) == null); +} + +test "writeFuzzerC with offsets path emits constants and sidecar" { + const io = std.testing.io; + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try std.fs.path.join(alloc, &.{ ".zig-cache", "tmp", &tmp.sub_path }); + defer alloc.free(dir_path); + const out_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.c" }); + defer alloc.free(out_path); + const redef_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.redef" }); + defer alloc.free(redef_path); + const offsets_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.offsets" }); + defer alloc.free(offsets_path); + + const ctrl: []Parser.Field = @constCast(&[_]Parser.Field{.{ + .name = ".", + .bit_width = 8, + .dims = &.{.{ .len = 16, .stride_bytes = 1 }}, + }}); + const hidden: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".x", .bit_width = 32 }}); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("fuzz_ctrl", "mock/mocks.c", false, &.{}, ctrl), + layoutTestGlobal("state", "src/mod.c", true, &.{}, hidden), + }; + + try writeFuzzerC(alloc, io, globals, 20, out_path, redef_path, "Entry", &.{}, offsets_path); + + var buf: [16384]u8 = undefined; + const c_out = try readTmpFile(&tmp, "fuzzer.c", &buf); + try std.testing.expect(std.mem.indexOf(u8, c_out, "const size_t absolution_globals_size = 20;") != null); + try std.testing.expect(std.mem.indexOf(u8, c_out, "const size_t absolution_offset_fuzz_ctrl = 0;") != null); + try std.testing.expect(std.mem.indexOf(u8, c_out, "const size_t absolution_offset_src_mod_c_state = 16;") != null); + + var off_buf: [4096]u8 = undefined; + const off_out = try readTmpFile(&tmp, "fuzzer.offsets", &off_buf); + try std.testing.expect(std.mem.indexOf(u8, off_out, "version 1\n") != null); + try std.testing.expect(std.mem.indexOf(u8, off_out, "globals_size 20\n") != null); + try std.testing.expect(std.mem.indexOf( + u8, + off_out, + "global 0 16 0 absolution_offset_fuzz_ctrl fuzz_ctrl mock/mocks.c\n", + ) != null); + try std.testing.expect(std.mem.indexOf( + u8, + off_out, + "global 16 4 1 absolution_offset_src_mod_c_state state src/mod.c\n", + ) != null); +} + +test "writeOffsetsFile marks an unknown source file and repeats no constant" { + const io = std.testing.io; + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try std.fs.path.join(alloc, &.{ ".zig-cache", "tmp", &tmp.sub_path }); + defer alloc.free(dir_path); + const out_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.c" }); + defer alloc.free(out_path); + const redef_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.redef" }); + defer alloc.free(redef_path); + const offsets_path = try std.fs.path.join(alloc, &.{ dir_path, "fuzzer.offsets" }); + defer alloc.free(offsets_path); + + const first: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".x", .bit_width = 8 }}); + const second: []Parser.Field = @constCast(&[_]Parser.Field{.{ .name = ".x", .bit_width = 8 }}); + const globals: []const Parser.Global = &.{ + layoutTestGlobal("dup", "", false, &.{}, first), + layoutTestGlobal("dup", "", false, &.{}, second), + }; + + try writeFuzzerC(alloc, io, globals, 2, out_path, redef_path, "Entry", &.{}, offsets_path); + + var off_buf: [4096]u8 = undefined; + const off_out = try readTmpFile(&tmp, "fuzzer.offsets", &off_buf); + try std.testing.expect(std.mem.indexOf(u8, off_out, "global 0 1 0 absolution_offset_dup dup -\n") != null); + try std.testing.expect(std.mem.indexOf(u8, off_out, "global 1 1 0 absolution_offset_dup dup -\n") != null); + + var buf: [16384]u8 = undefined; + const c_out = try readTmpFile(&tmp, "fuzzer.c", &buf); + const first_def = std.mem.indexOf(u8, c_out, "const size_t absolution_offset_dup").?; + try std.testing.expect(std.mem.indexOfPos(u8, c_out, first_def + 1, "const size_t absolution_offset_dup") == null); +} + test "emitOffsetCalc base offset only" { const alloc = std.testing.allocator; const result = try emitOffsetCalc(alloc, &.{}, &.{}, 42); @@ -1528,7 +2025,7 @@ test "writeFuzzerC end-to-end produces valid output" { .fields = fields, }}; - try writeFuzzerC(alloc, io, globals, 4, out_path, redef_path, "TestHarness", &.{}); + try writeFuzzerC(alloc, io, globals, 4, out_path, redef_path, "TestHarness", &.{}, null); var buf: [16384]u8 = undefined; const out = try readTmpFile(&tmp, "fuzzer.c", &buf); @@ -1568,7 +2065,7 @@ test "writeFuzzerC with static global writes redef file" { .fields = fields, }}; - try writeFuzzerC(alloc, io, globals, 1, out_path, redef_path, "TestEntry", &.{}); + try writeFuzzerC(alloc, io, globals, 1, out_path, redef_path, "TestEntry", &.{}, null); var buf: [4096]u8 = undefined; const redef_out = try readTmpFile(&tmp, "fuzzer2.redef", &buf); @@ -1592,7 +2089,7 @@ test "writeFuzzerC with func_symbols emits extern declarations" { defer alloc.free(redef_path); const globals: []const Parser.Global = &.{}; - try writeFuzzerC(alloc, io, globals, 0, out_path, redef_path, "Entry", &.{"my_handler"}); + try writeFuzzerC(alloc, io, globals, 0, out_path, redef_path, "Entry", &.{"my_handler"}, null); var buf: [8192]u8 = undefined; const c_out = try readTmpFile(&tmp, "fuzzer3.c", &buf); diff --git a/src/cgen/ir.zig b/src/cgen/ir.zig index 0e6cd1a..66001d1 100644 --- a/src/cgen/ir.zig +++ b/src/cgen/ir.zig @@ -66,6 +66,30 @@ pub fn constrainedSelectorBytes(domain: Domain) usize { }; } +/// Fuzzer-input bytes one field consumes, for a global repeated `global_mult` times. +/// +/// Mirrors what the emitted sampler advances `off` by: padding is zeroed rather +/// than sampled, constrained domains spend one selector byte per instance, and +/// `whole_values` copies a static blob so it spends nothing per element. +pub fn fieldInputBytes(f: Field, global_mult: usize) usize { + if (f.is_padding) return 0; + const field_mult = dimsProduct(f.dims); + return switch (f.domain) { + .top => elementBytes(f) * global_mult * field_mult, + .values, .pointers => constrainedSelectorBytes(f.domain) * global_mult * field_mult, + // Blob bytes come from emitted domain tables, not from the fuzzer stream. + .whole_values => global_mult * constrainedSelectorBytes(f.domain), + }; +} + +/// Fuzzer-input bytes one global consumes across all of its fields. +pub fn globalInputBytes(g: Global) usize { + const global_mult = dimsProduct(g.dims); + var total: usize = 0; + for (g.fields) |f| total += fieldInputBytes(f, global_mult); + return total; +} + /// Rejects candidate lists that cannot be indexed with one byte (>256 choices). pub fn validateConstrainedDomain(domain: Domain) DomainError!void { switch (domain) { diff --git a/src/main.zig b/src/main.zig index b589fba..e724b33 100644 --- a/src/main.zig +++ b/src/main.zig @@ -56,7 +56,7 @@ pub fn main(init: std.process.Init) !void { const needed_bytes = seed.neededBytesFromGlobals(globals.items); // Code generation - try emit.writeFuzzerC(gpa, io, globals.items, needed_bytes, opts.out_c, opts.redef, opts.entry, func_symbols); + try emit.writeFuzzerC(gpa, io, globals.items, needed_bytes, opts.out_c, opts.redef, opts.entry, func_symbols, opts.offsets); // Optional: Save invariant to file if (opts.zon) |zon_path| try writeInvariant(gpa, io, globals.items, zon_path); @@ -83,6 +83,7 @@ const Options = struct { out_c: []const u8, zon: ?[]const u8, seed: ?[]const u8, + offsets: ?[]const u8, entry: []const u8, cflags: []const []const u8, }; @@ -95,6 +96,8 @@ const cli = clap.parseParamsComptime( \\-i, --invariant Optional invariant (.in or .zon). \\-z, --zon Optional zon output path. \\-s, --seed Optional seed output path (default: fuzzer.seed). + \\-m, --offsets Optional input layout output path; also exports the + \\ layout as constants in the generated C. \\-e, --entry Optional harness function name (default: AbsolutionTestOneInput). \\... C compiler flags after '--' (e.g. -I path -DFOO -fshort-enums). \\ @@ -147,6 +150,7 @@ fn parseArgs(allocator: std.mem.Allocator, io: std.Io, args: std.process.Args) ! .out_c = res.args.out orelse "fuzzer.c", .zon = res.args.zon, .seed = res.args.seed orelse "fuzzer.seed", + .offsets = res.args.offsets, .entry = res.args.entry orelse "AbsolutionTestOneInput", .cflags = cflags, }; diff --git a/src/seed.zig b/src/seed.zig index 92b3bf1..b4d6d7f 100644 --- a/src/seed.zig +++ b/src/seed.zig @@ -6,20 +6,7 @@ const ir = @import("cgen/ir.zig"); pub fn neededBytesFromGlobals(globals: []const ir.Global) usize { var total: usize = 0; - for (globals) |g| { - const global_mult = ir.dimsProduct(g.dims); - for (g.fields) |f| { - if (f.is_padding) continue; - const field_mult = ir.dimsProduct(f.dims); - const bytes: usize = switch (f.domain) { - .top => ir.elementBytes(f) * global_mult * field_mult, - .values, .pointers => ir.constrainedSelectorBytes(f.domain) * global_mult * field_mult, - // Blob bytes come from emitted domain tables, not from the fuzzer stream. - .whole_values => global_mult * ir.constrainedSelectorBytes(f.domain), - }; - total += bytes; - } - } + for (globals) |g| total += ir.globalInputBytes(g); return total; } diff --git a/tests/exported_input_offsets/target.c b/tests/exported_input_offsets/target.c new file mode 100644 index 0000000..244c00e --- /dev/null +++ b/tests/exported_input_offsets/target.c @@ -0,0 +1,30 @@ +// Covers the input-layout export (--offsets): a global whose bytes are consumed +// contiguously, one with interior padding that is zeroed rather than sampled, an +// array global, and a `static` reached through its mangled linker name. + +#include + +// 16 sampled bytes, first in the layout. +uint8_t ctrl[16]; + +// Interior padding is zeroed, not sampled, so this spends 1 + 4 bytes. +struct padded { + uint8_t flag; + uint32_t counter; +}; +struct padded state; + +// Array global: dimensions multiply the per-element cost. +uint16_t table[4]; + +// Only reachable after objcopy renames it, so the offset constant is emitted +// against the mangled name. +static uint8_t secret[8]; + +void AbsolutionTestRegression(void) +{ + ctrl[0] = 1; + state.flag = 2; + table[0] = 3; + secret[0] = 4; +} diff --git a/tests/exported_input_offsets/target.c.offsets b/tests/exported_input_offsets/target.c.offsets new file mode 100644 index 0000000..d5d3db8 --- /dev/null +++ b/tests/exported_input_offsets/target.c.offsets @@ -0,0 +1,8 @@ +# absolution fuzzer input layout +# global +version 1 +globals_size 37 +global 0 16 0 absolution_offset_ctrl ctrl tests/exported_input_offsets/target.c +global 16 5 0 absolution_offset_state state tests/exported_input_offsets/target.c +global 21 8 0 absolution_offset_table table tests/exported_input_offsets/target.c +global 29 8 1 absolution_offset_tests_exported_input_offsets_target_c_secret secret tests/exported_input_offsets/target.c diff --git a/tests/exported_input_offsets/target.c.zon b/tests/exported_input_offsets/target.c.zon new file mode 100644 index 0000000..f81e095 --- /dev/null +++ b/tests/exported_input_offsets/target.c.zon @@ -0,0 +1,80 @@ +.{ + .{ + .name = "ctrl", + .source_file = "tests/exported_input_offsets/target.c", + .size_bytes = 16, + .is_static = false, + .dims = .{.{ .len = 16, .stride_bytes = 1 }}, + .fields = .{.{ + .name = ".", + .offset_bits = 0, + .bit_width = 8, + .dims = .{}, + .is_padding = false, + .domain = .top, + }}, + }, + .{ + .name = "state", + .source_file = "tests/exported_input_offsets/target.c", + .size_bytes = 8, + .is_static = false, + .dims = .{}, + .fields = .{ + .{ + .name = ".flag", + .offset_bits = 0, + .bit_width = 8, + .dims = .{}, + .is_padding = false, + .domain = .top, + }, + .{ + .name = "._pad0", + .offset_bits = 8, + .bit_width = 24, + .dims = .{}, + .is_padding = true, + .domain = .top, + }, + .{ + .name = ".counter", + .offset_bits = 32, + .bit_width = 32, + .dims = .{}, + .is_padding = false, + .domain = .top, + }, + }, + }, + .{ + .name = "table", + .source_file = "tests/exported_input_offsets/target.c", + .size_bytes = 8, + .is_static = false, + .dims = .{.{ .len = 4, .stride_bytes = 2 }}, + .fields = .{.{ + .name = ".", + .offset_bits = 0, + .bit_width = 16, + .dims = .{}, + .is_padding = false, + .domain = .top, + }}, + }, + .{ + .name = "secret", + .source_file = "tests/exported_input_offsets/target.c", + .size_bytes = 8, + .is_static = true, + .dims = .{.{ .len = 8, .stride_bytes = 1 }}, + .fields = .{.{ + .name = ".", + .offset_bits = 0, + .bit_width = 8, + .dims = .{}, + .is_padding = false, + .domain = .top, + }}, + }, +}