Encode inline fuzz seeds the way the tests read them

A fuzz test reads its input through std.testing.Smith, which reads a slice
as a little-endian u32 length and then that many bytes. The inline seeds of
the older fuzz tests were plain strings, so each lost its first four bytes
to the length: "one" replayed as an empty input, and "split every field"
read "spli" as a length. The fx tests now wrap their seeds in
fuzz_seed.slices, which writes that length first. The session manager
imports nothing from fx, so its two targets keep their seeds as saved
inputs in tests/fuzz/corpus.
This commit is contained in:
Pranit committed 2026-10-22 18:44:46 -04:00
1 parent 5a5c14cb2d
commit a7ad972621
23 files changed
+78 -38

No files matched your search

+2 -2
View File
@@ -111,9 +111,9 @@ When the search finds a failure, Zig 0.17 prints `error: test '...'` but still e
The Fuzz workflow fuzzes each target on `main` every night for about 20 minutes, and for two minutes on a pull request that changes the fuzzing setup. When the nightly run finds a failure, it uploads the failing input as an artifact and opens an issue titled `Nightly fuzz failure: <target>`, or comments on that issue if it is already open. To fix the failure, copy the input into `tests/fuzz/corpus/<target>/` and run `zig build fuzz -Dfuzz-target=<target>` to reproduce it. That build uses Debug by default, which adds allocation stack traces to leak reports. Keep the input file in the fix's pull request, so `zig build test` replays it on every pull request after that.
A saved input holds the values the test reads from its `std.testing.Smith`, in order. For example, a test that calls `smith.slice` reads a 4-byte little-endian length and then that many bytes. Inputs that the fuzzer saves are already in this form.
A saved input holds the values the test reads from its `std.testing.Smith`, in order. For example, a test that calls `smith.slice` reads a 4-byte little-endian length and then that many bytes. Inputs that the fuzzer saves are already in this form. Inline seeds go through `@import("fuzz_seed").slices`, which writes each one in this form for a test whose first read is `smith.slice`. A seed written as a plain string would lose its first four bytes to the length.
To add a target, write the fuzz test in the file it covers and add an entry to `tests/fuzz/targets.json`. For a target outside the session manager, also import its file in `src/fuzz_targets.zig`. Pass the saved inputs to the test with `.corpus = &(.{ <seeds> } ++ @import("fuzz_corpus").<target>.*)`, or with `@import("build_options").fuzz_corpus_<target>` in the session manager. Set `iterations` so that one round of `--fuzz` takes about 30 seconds on a developer machine. `scripts/tests/test_fuzz_nightly.py` fails when an entry names a test that doesn't exist or that `zig build fuzz` can't reach. The MCP engine in `src/mcp/` imports only the standard library, so its targets live in `src/core/mcp_host/wire_tests.zig`. A file that holds only tests, like that one, is also imported from the test block in `src/main.zig`.
To add a target, write the fuzz test in the file it covers and add an entry to `tests/fuzz/targets.json`. For a target outside the session manager, also import its file in `src/fuzz_targets.zig`. Pass the seeds and saved inputs to the test with `.corpus = &(@import("fuzz_seed").slices(.{ <seeds> }) ++ @import("fuzz_corpus").<target>.*)`. The session manager imports nothing from fx, so its targets keep their seeds as files in `tests/fuzz/corpus/<target>/` and pass `.corpus = &(.{""} ++ @import("build_options").fuzz_corpus_<target>.*)`. Set `iterations` so that one round of `--fuzz` takes about 30 seconds on a developer machine. `scripts/tests/test_fuzz_nightly.py` fails when an entry names a test that doesn't exist or that `zig build fuzz` can't reach. The MCP engine in `src/mcp/` imports only the standard library, so its targets live in `src/core/mcp_host/wire_tests.zig`. A file that holds only tests, like that one, is also imported from the test block in `src/main.zig`.
### Find data races
+9
View File
@@ -60,6 +60,12 @@ pub fn build(b: *std.Build) void {
const fuzz_corpora = readFuzzCorpora(b);
const fuzz_corpus = addFuzzCorpusModule(b, fuzz_corpora);
// Encodes the fuzz tests' inline seeds the way they read them.
const fuzz_seed = b.createModule(.{
.root_source_file = b.path("src/fuzz_seed.zig"),
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "fx",
@@ -81,6 +87,7 @@ pub fn build(b: *std.Build) void {
});
exe.root_module.addImport("build_options", build_options.createModule());
exe.root_module.addImport("fuzz_corpus", fuzz_corpus);
exe.root_module.addImport("fuzz_seed", fuzz_seed);
const session_manager = addSessionManager(b, exe.root_module, target, optimize, null, fuzz_corpora);
session_manager.sanitize_thread = sanitize_thread;
@@ -124,6 +131,7 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&run_exe_tests.step);
test_step.dependOn(&run_session_manager_tests.step);
test_step.dependOn(&run_session_manager_hook_tests.step);
test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = fuzz_seed })).step);
const fuzz_step = b.step(
"fuzz",
@@ -142,6 +150,7 @@ pub fn build(b: *std.Build) void {
});
fuzz_root.addImport("build_options", build_options.createModule());
fuzz_root.addImport("fuzz_corpus", fuzz_corpus);
fuzz_root.addImport("fuzz_seed", fuzz_seed);
fuzz_root.addImport("session_manager", session_manager);
fuzz_root.addImport("test_paths", test_paths_module);
addFuzzStep(b, fuzz_step, fuzz_corpora, fuzz_target, .{
+2 -2
View File
@@ -560,12 +560,12 @@ fn fuzzParse(_: void, smith: *std.testing.Smith) !void {
test "ACP MCP parser handles fuzzed request bytes" {
try std.testing.fuzz({}, fuzzParse, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"",
"{}",
"{\"mcpServers\":[]}",
"{\"mcpServers\":[{\"name\":\"fixture\",\"command\":\"/bin/echo\",\"args\":[],\"env\":[]}]}",
"{\"mcpServers\":[{\"type\":\"http\",\"name\":\"fixture\",\"url\":\"http://localhost:4321/mcp\",\"headers\":[]}]}",
} ++ @import("fuzz_corpus").acp_mcp_servers.*),
}) ++ @import("fuzz_corpus").acp_mcp_servers.*),
});
}
+2 -2
View File
@@ -1017,11 +1017,11 @@ fn fuzz_vision_json(_: void, smith: *std.testing.Smith) !void {
test "Vision JSON parsers tolerate arbitrary bounded bytes" {
try std.testing.fuzz({}, fuzz_vision_json, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"{\"image_ids\":[1],\"focus\":\"inspect\"}",
"{\"images\":[{\"image_id\":1,\"status\":\"failed\",\"error\":\"vision_unavailable\"}]}",
"{",
} ++ @import("fuzz_corpus").vision_json.*),
}) ++ @import("fuzz_corpus").vision_json.*),
});
}
+2 -2
View File
@@ -544,7 +544,7 @@ test "encoded image dimensions agree with raw headers at every truncation" {
test "image dimension parsing stays bounded on arbitrary bytes" {
try std.testing.fuzz({}, fuzzImageDimensions, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
&testPngHeader(3420, 2224),
&testJpeg(2001, 1),
&testGif(1, 1),
@@ -556,7 +556,7 @@ test "image dimension parsing stays bounded on arbitrary bytes" {
"\x89PNG\r\n\x1a\n\x00\x00\x00\x00IHDR\xff\xff\xff\xff\xff\xff\xff\xff",
// RIFF header naming WebP with no chunk.
"RIFF\x00\x00\x00\x00WEBP",
} ++ @import("fuzz_corpus").image_dimensions.*),
}) ++ @import("fuzz_corpus").image_dimensions.*),
});
}
+2 -2
View File
@@ -2143,12 +2143,12 @@ fn fuzzElicitationJson(_: void, smith: *std.testing.Smith) !void {
test "elicitation request and response parsers stay bounded under fuzzed bytes" {
try std.testing.fuzz({}, fuzzElicitationJson, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"{}",
"{\"mode\":\"form\",\"requestedSchema\":{}}",
"{\"action\":\"accept\",\"content\":{}}",
"{\"mode\":\"url\",\"url\":\"https://example.test\"}",
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/elicitation/complete\",\"params\":{\"elicitationId\":\"id\"}}",
} ++ @import("fuzz_corpus").mcp_elicitation.*),
}) ++ @import("fuzz_corpus").mcp_elicitation.*),
});
}
+4 -4
View File
@@ -5951,7 +5951,7 @@ test "an old session's usage field is skipped in its place, and the rest loads"
}
test "durable session optional fields handle fuzzed ownership paths" {
try std.testing.fuzz({}, fuzzDurableSessionOptionalFields, .{ .corpus = &(.{
try std.testing.fuzz({}, fuzzDurableSessionOptionalFields, .{ .corpus = &(@import("fuzz_seed").slices(.{
"{\"id\":\"session\",\"origin_workspace_root\":\"/tmp/origin\",\"workspace_root\":\"/tmp/current\",\"created_at_ms\":1,\"updated_at_ms\":1,\"conversation_language\":\"en\",\"preferences\":{\"model\":\"test/model\",\"effort\":\"auto\",\"fast_mode\":false},\"history\":[],\"total_input_tokens\":0,\"total_output_tokens\":0}",
"{\"id\":\"session\",\"origin_workspace_root\":\"/tmp/origin\",\"workspace_root\":\"/tmp/current\",\"created_at_ms\":1,\"updated_at_ms\":1,\"conversation_language\":\"en\",\"preferences\":{\"model\":\"test/model\",\"effort\":\"auto\",\"fast_mode\":false},\"history\":[],\"total_input_tokens\":0,\"total_output_tokens\":0,\"context_history_start\":0,\"last_subagent_work_id\":\"work\"}",
"{\"id\":\"session\",\"origin_workspace_root\":\"/tmp/origin\",\"workspace_root\":\"/tmp/current\",\"created_at_ms\":1,\"updated_at_ms\":1,\"conversation_language\":\"en\",\"preferences\":{\"model\":\"test/model\",\"effort\":\"auto\",\"fast_mode\":false},\"history\":[],\"total_input_tokens\":0,\"total_output_tokens\":0,\"last_subagent_work_id\":\"work\",\"context_history_start\":0}",
@@ -5960,17 +5960,17 @@ test "durable session optional fields handle fuzzed ownership paths" {
"{\"last_subagent_work_id\":null}",
"{\"usage\":null,\"last_subagent_work_id\":\"work\"}",
"{\"last_subagent_work_id\":\"work\",\"usage\":null}",
} ++ @import("fuzz_corpus").session_optional_fields.*) });
}) ++ @import("fuzz_corpus").session_optional_fields.*) });
}
test "per-turn work provenance parser handles fuzzed ownership paths" {
try std.testing.fuzz({}, fuzzWorkProvenanceHistoryTurn, .{ .corpus = &(.{
try std.testing.fuzz({}, fuzzWorkProvenanceHistoryTurn, .{ .corpus = &(@import("fuzz_seed").slices(.{
"{\"kind\":\"assistant\",\"user\":{\"text\":\"prompt\",\"images\":[],\"work_id\":\"work-1\"},\"assistant\":\"reply\",\"execution\":{\"schema_version\":3,\"tool_steps\":[],\"files\":[]}}",
"{\"kind\":\"assistant\",\"user\":{\"text\":\"prompt\",\"images\":[]},\"assistant\":\"reply\",\"execution\":{\"schema_version\":3,\"tool_steps\":[],\"files\":[]}}",
"{\"kind\":\"assistant\",\"user\":{\"text\":\"prompt\",\"images\":[],\"work_id\":\"\"}}",
"",
"{}",
} ++ @import("fuzz_corpus").session_work_provenance.*) });
}) ++ @import("fuzz_corpus").session_work_provenance.*) });
}
fn fuzzWorkProvenanceHistoryTurn(_: void, smith: *std.testing.Smith) !void {
+2 -2
View File
@@ -3171,12 +3171,12 @@ test "conversation frame rejects an oversized diff content handle" {
test "conversation frame decoder stays bounded under fuzzed bytes" {
try std.testing.fuzz({}, fuzzConversationFrame, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"",
"{}\n",
"{\"schema_version\":1}\n",
"{\"schema_version\":1,\"seq\":1,\"timestamp_ms\":0,\"event\":{\"assistant\":{\"text\":\"ok\"}}}\n",
} ++ @import("fuzz_corpus").session_conversation_frame.*),
}) ++ @import("fuzz_corpus").session_conversation_frame.*),
});
}
+1 -4
View File
@@ -773,10 +773,7 @@ test "any prefix of a valid log scans to its last complete line" {
}
test "scanning never panics on arbitrary bytes" {
try testing.fuzz({}, fuzzScan, .{ .corpus = &(.{
"{\"v\":1,\"seq\":1,\"ts\":0,\"kind\":\"item\",\"crc\":\"00000000\"}\n",
"\n\n\n",
} ++ @import("build_options").fuzz_corpus_session_log_scan.*) });
try testing.fuzz({}, fuzzScan, .{ .corpus = &(.{""} ++ @import("build_options").fuzz_corpus_session_log_scan.*) });
}
fn fuzzScan(_: void, smith: *testing.Smith) anyerror!void {
+1 -4
View File
@@ -806,10 +806,7 @@ test "ids follow v1's alphabet and avoid the reserved names" {
}
test "header parsing never panics on arbitrary bytes" {
try testing.fuzz({}, fuzzHeader, .{ .corpus = &(.{
"{\"v\":1,\"seq\":1,\"ts\":0,\"kind\":\"item\"",
"{\"v\":99999999999,\"seq\":1",
} ++ @import("build_options").fuzz_corpus_session_schema_header.*) });
try testing.fuzz({}, fuzzHeader, .{ .corpus = &(.{""} ++ @import("build_options").fuzz_corpus_session_schema_header.*) });
}
fn fuzzHeader(_: void, smith: *testing.Smith) anyerror!void {
+2 -2
View File
@@ -828,14 +828,14 @@ test "display unit clipping preserves sequence boundaries" {
test "fuzz display unit boundaries" {
try std.testing.fuzz({}, fuzzDisplayUnitBoundaries, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"",
"plain text",
"\xff\xf0\x28\x8c\x28",
"\u{2600}\u{FE0E}\u{2600}\u{FE0F}",
"\u{1F44D}\u{1F3FD}\u{1F1FA}\u{1F1F8}",
"#\u{FE0F}\u{20E3}\u{1F469}\u{200D}\u{1F4BB}",
} ++ @import("fuzz_corpus").display_width.*),
}) ++ @import("fuzz_corpus").display_width.*),
});
}
+2 -2
View File
@@ -3262,13 +3262,13 @@ fn fuzzToolArgumentDiagnostic(_: void, smith: *std.testing.Smith) anyerror!void
test "fuzz tool argument diagnostic" {
try std.testing.fuzz({}, fuzzToolArgumentDiagnostic, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"{\"request\":{\"task\":\"cut",
"{\"a\":1,}",
"{\"a\":1,\"a\":2}",
"{\"t\":\"\xc3\xa9\xe2\x82",
"\xff\x00{",
} ++ @import("fuzz_corpus").tool_argument_diagnostic.*),
}) ++ @import("fuzz_corpus").tool_argument_diagnostic.*),
});
}
+2 -2
View File
@@ -1061,14 +1061,14 @@ test "parseSkillFile rejects indented continuations for recognized metadata" {
test "parseSkillFile keeps fuzzed borrowed slices inside the input" {
try std.testing.fuzz({}, fuzzParseSkillFile, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"",
"plain body",
"---\nname: valid\n---\nbody",
"---\r\nname: \"valid\"\r\n---\r\nbody",
"---\nname: quoted\ndescription: 'single line'\n---\nbody",
"---\nname: block\ndescription: >-\n first line\n second line\n---\nbody",
} ++ @import("fuzz_corpus").skill_file.*),
}) ++ @import("fuzz_corpus").skill_file.*),
});
}
+2 -2
View File
@@ -113,11 +113,11 @@ test "skill display name parsing stays bounded" {
try std.testing.expect(name.len <= buffer.len);
try std.testing.expect(skill_contract.invalidSkillNameCause(name) == null);
}
}.check, .{ .corpus = &(.{
}.check, .{ .corpus = &(@import("fuzz_seed").slices(.{
content_name_prefix ++ "workflow\" location=\"/skills/workflow\">",
discovery_model_notice ++ content_name_prefix ++ "name&amp;value\" resource=\"SKILL.md\">",
content_name_prefix ++ "bad&unknown;\" location=\"/skills/workflow\">",
} ++ @import("fuzz_corpus").skill_display_name.*) });
}) ++ @import("fuzz_corpus").skill_display_name.*) });
}
/// Borrows a previously discovered skill inventory for one invocation.
+2 -2
View File
@@ -707,11 +707,11 @@ fn fuzzCommandOutputContent(_: void, smith: *std.testing.Smith) anyerror!void {
test "fuzz command output content" {
try std.testing.fuzz({}, fuzzCommandOutputContent, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"progress\roverwrite\n",
"\x1b[31mred\x1b[0m\n",
"exit_code=0\n<stdout>\none\n</stdout>\n",
"\xff\x00\xe2\x82",
} ++ @import("fuzz_corpus").command_output_content.*),
}) ++ @import("fuzz_corpus").command_output_content.*),
});
}
+2 -2
View File
@@ -4238,7 +4238,7 @@ test "deinit cancels and joins one loader while suppressing queued refresh" {
test "file index raw-list and Unicode query bytes remain bounded" {
try std.testing.fuzz({}, fuzzRawListAndQuery, .{
.corpus = &(.{
.corpus = &(@import("fuzz_seed").slices(.{
"safe.txt\x00ärger",
"escape-\x1b[2J.txt\x00\xff",
"Ärger-file.txt\x00ärger",
@@ -4247,7 +4247,7 @@ test "file index raw-list and Unicode query bytes remain bounded" {
"docs/Cafe\u{0301}.txt\x00e\u{0301}",
"src/axaya.zig\x00aaa",
"truncated.txt\x00\xc3",
} ++ @import("fuzz_corpus").file_index.*),
}) ++ @import("fuzz_corpus").file_index.*),
});
}
+37
View File
@@ -0,0 +1,37 @@
//! Inline seeds for fuzz tests, in the form the tests read them.
//!
//! A fuzz test reads its input through `std.testing.Smith`, which reads a
//! `smith.slice` as a little-endian u32 length and then that many bytes. A
//! seed written as a plain string loses its first four bytes to the length,
//! so `"one"` replays as an empty slice. `build.zig` makes this file the
//! `fuzz_seed` module for fx's tests. The session manager imports nothing
//! from fx, so its targets keep their seeds in `tests/fuzz/corpus/`.
const std = @import("std");
/// Encodes each of `seeds`, a tuple of byte strings, as the input that makes
/// a fuzz test's first `smith.slice` return it.
pub fn slices(comptime seeds: anytype) [seeds.len][]const u8 {
return comptime encoded: {
var inputs: [seeds.len][]const u8 = undefined;
for (&inputs, 0..) |*input, i| input.* = slice(seeds[i]);
break :encoded inputs;
};
}
fn slice(comptime bytes: []const u8) []const u8 {
return comptime encoded: {
var len: [4]u8 = undefined;
std.mem.writeInt(u32, &len, bytes.len, .little);
const input = len ++ bytes[0..bytes.len].*;
break :encoded &input;
};
}
test "a seed reads back through the fuzz input reader" {
const inputs = slices(.{ "", "one", "split every field" });
for (inputs, [_][]const u8{ "", "one", "split every field" }) |input, seed| {
var smith: std.testing.Smith = .{ .in = input };
var buffer: [32]u8 = undefined;
try std.testing.expectEqualStrings(seed, buffer[0..smith.slice(&buffer)]);
}
}
+1 -1
View File
@@ -2167,7 +2167,7 @@ test "Responses text finalization fuzzes chunking and capture boundaries" {
try stream.finish(buffer[0 .. len + separator.len + 4]);
}
};
try std.testing.fuzz({}, Probe.run, .{ .corpus = &(.{ "", "a", "two chunks", "capture limit is not receipt progress" } ++ @import("fuzz_corpus").responses_text.*) });
try std.testing.fuzz({}, Probe.run, .{ .corpus = &(@import("fuzz_seed").slices(.{ "", "a", "two chunks", "capture limit is not receipt progress" }) ++ @import("fuzz_corpus").responses_text.*) });
}
test "Responses captures assistant commentary phase" {
+1 -1
View File
@@ -263,7 +263,7 @@ test "provider framing fuzzes chunk-invariant event data" {
try std.testing.expectEqual(null, try reader.next(alloc, &source.interface, &cancelled));
}
};
try std.testing.fuzz({}, Probe.run, .{ .corpus = &(.{ "", "one", "split every field" } ++ @import("fuzz_corpus").sse_framing.*) });
try std.testing.fuzz({}, Probe.run, .{ .corpus = &(@import("fuzz_seed").slices(.{ "", "one", "split every field" }) ++ @import("fuzz_corpus").sse_framing.*) });
}
fn check_allocations(alloc: std.mem.Allocator) !void {