// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Run the fuzz targets in `src/tests/fuzz.zig` against input this makes up. //! //! Zig has a fuzzer of its own and those targets are written for it: //! `zig build fuzz --fuzz` runs them with coverage feedback, which is what //! turns a random byte string into one that reaches a branch nobody has //! reached before. //! //! This is the loop around those same targets without it: it makes an input, //! hands it to one of them, and says so when one comes back with an error. It //! makes up for the missing coverage with the corpus: most of what it runs is a //! mutation of a seed that is already a FLAC stream, so the inputs arrive //! looking like the thing under test rather than like noise. It is also the //! easy way to replay one input and watch it fail, and a bounded run of it is //! cheap enough for every push. //! //! ```console //! $ zig build fuzz-run # thirty seconds of each //! $ zig build fuzz-run -- --seconds 300 --target decode //! $ zig build fuzz-run -- --seed 12345 # run it again exactly //! $ zig build fuzz-run -- --input fuzz-findings/decode-1.bin //! ``` //! //! An input is a byte string that `std.testing.Smith` reads as a stream of //! decisions, so a failure is reported as those bytes and written to a file //! under `fuzz-findings/`. Feeding one back is what `--input` is for, and a //! shape worth keeping belongs in the corpus in `src/tests/fuzz.zig`, where //! `zig build test` will run it every time. const std = @import("std"); const targets = @import("fuzz_targets"); const Smith = std.testing.Smith; const usage = \\Usage: zig build fuzz-run -- [options] \\ \\ --seconds How long to run each target. Default 30. \\ --target Only this target. One of: decode, metadata, roundtrip. \\ --seed The seed for the input generator. Default: the clock. \\ --input Run one input from a file and stop. \\ --quiet Only report failures. \\ ; /// The longest input made up from scratch or grown to by mutation. const max_input = 96 * 1024; pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const io = init.io; var seconds: u64 = 30; var only: ?[]const u8 = null; var seed: ?u64 = null; var input_path: ?[]const u8 = null; var quiet = false; var args = init.minimal.args.iterate(); defer args.deinit(); _ = args.skip(); while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--seconds")) { seconds = try std.fmt.parseInt(u64, args.next() orelse return fail("--seconds wants a number"), 10); } else if (std.mem.eql(u8, arg, "--target")) { only = args.next() orelse return fail("--target wants a name"); } else if (std.mem.eql(u8, arg, "--seed")) { seed = try std.fmt.parseInt(u64, args.next() orelse return fail("--seed wants a number"), 10); } else if (std.mem.eql(u8, arg, "--input")) { input_path = args.next() orelse return fail("--input wants a path"); } else if (std.mem.eql(u8, arg, "--quiet")) { quiet = true; } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { std.debug.print("{s}", .{usage}); return; } else { std.debug.print("{s}", .{usage}); return fail("unexpected argument"); } } targets.backing = gpa; if (input_path) |path| { const file = try std.Io.Dir.cwd().openFile(io, path, .{}); defer file.close(io); var buffer: [max_input]u8 = undefined; var reader: std.Io.File.Reader = .init(file, io, &buffer); const bytes = try reader.interface.allocRemaining(gpa, .limited(max_input)); defer gpa.free(bytes); var failures: usize = 0; for (targets.targets) |target| { if (only) |name| if (!std.mem.eql(u8, name, target.name)) continue; var smith: Smith = .{ .in = bytes }; target.run({}, &smith) catch |err| { std.debug.print("{s}: {t}\n", .{ target.name, err }); failures += 1; }; } if (failures != 0) std.process.exit(1); std.debug.print("no target objected to {s}\n", .{path}); return; } const chosen_seed = seed orelse @as(u64, @bitCast(@as(i64, @truncate(std.Io.Timestamp.now(io, .awake).nanoseconds)))); var prng: std.Random.DefaultPrng = .init(chosen_seed); const random = prng.random(); std.debug.print("seed {d}\n", .{chosen_seed}); const buffer = try gpa.alloc(u8, max_input); defer gpa.free(buffer); var failures: usize = 0; for (targets.targets) |target| { if (only) |name| if (!std.mem.eql(u8, name, target.name)) continue; const deadline = std.Io.Timestamp.now(io, .awake).nanoseconds + @as(i128, seconds) * std.time.ns_per_s; var runs: u64 = 0; var refusals: u64 = 0; while (std.Io.Timestamp.now(io, .awake).nanoseconds < deadline) { const input = makeInput(random, buffer, target.corpus); var smith: Smith = .{ .in = input }; runs += 1; target.run({}, &smith) catch |err| { failures += 1; std.debug.print("\n{s}: {t} on {d} bytes\n", .{ target.name, err, input.len }); try save(io, target.name, failures, input); refusals += 1; if (refusals > 16) { std.debug.print("{s}: too many failures, moving on\n", .{target.name}); break; } }; } if (!quiet) std.debug.print("{s}: {d} runs in {d} s\n", .{ target.name, runs, seconds }); } if (failures != 0) { std.debug.print("{d} failing inputs, saved under fuzz-findings/\n", .{failures}); std.process.exit(1); } } fn fail(message: []const u8) error{BadUsage} { std.debug.print("zig-flac fuzz: {s}\n", .{message}); return error.BadUsage; } /// Build one input: usually a mutated seed, sometimes a splice of two, and now /// and then a byte string owing nothing to any of them. fn makeInput(random: std.Random, buffer: []u8, corpus: []const []const u8) []u8 { if (corpus.len == 0 or random.uintLessThan(u8, 16) == 0) { const length = random.uintAtMost(usize, @min(buffer.len, 4096)); random.bytes(buffer[0..length]); return buffer[0..length]; } const seed_bytes = corpus[random.uintLessThan(usize, corpus.len)]; var length = @min(seed_bytes.len, buffer.len); @memcpy(buffer[0..length], seed_bytes[0..length]); // Now and then, staple a second seed on the end, which is how a stream // grows frames it did not start with. if (random.uintLessThan(u8, 4) == 0) { const other = corpus[random.uintLessThan(usize, corpus.len)]; const room = @min(other.len, buffer.len - length); @memcpy(buffer[length..][0..room], other[0..room]); length += room; } const edits = random.uintAtMost(usize, 24) + 1; for (0..edits) |_| { if (length == 0) break; switch (random.uintLessThan(u8, 8)) { // Flip one bit, which is the edit most likely to leave a stream // that still parses far enough to be interesting. 0, 1, 2 => { const at = random.uintLessThan(usize, length); buffer[at] ^= @as(u8, 1) << random.int(u3); }, 3, 4 => buffer[random.uintLessThan(usize, length)] = random.int(u8), // A byte from the ends of the range, which is where the format's // reserved and forbidden values live. 5 => { const at = random.uintLessThan(usize, length); buffer[at] = ([_]u8{ 0x00, 0x01, 0x7f, 0x80, 0xfe, 0xff })[random.uintLessThan(usize, 6)]; }, 6 => { // Cut the input short, which is what a truncated file is. length = random.uintAtMost(usize, length); }, else => { // Copy a run from one place to another, so that a structure // the format expects once turns up twice. if (length < 4) continue; const run = random.uintAtMost(usize, @min(64, length)); if (run == 0) continue; const from = random.uintAtMost(usize, length - run); const to = random.uintAtMost(usize, length - run); std.mem.copyForwards(u8, buffer[to..][0..run], buffer[from..][0..run]); }, } } return buffer[0..length]; } fn save(io: std.Io, name: []const u8, number: usize, input: []const u8) !void { const directory = std.Io.Dir.cwd().createDirPathOpen(io, "fuzz-findings", .{}) catch return; defer directory.close(io); var path_buffer: [64]u8 = undefined; const path = std.fmt.bufPrint(&path_buffer, "{s}-{d}.bin", .{ name, number }) catch return; const file = directory.createFile(io, path, .{}) catch return; defer file.close(io); var write_buffer: [4096]u8 = undefined; var writer: std.Io.File.Writer = .init(file, io, &write_buffer); try writer.interface.writeAll(input); try writer.interface.flush(); std.debug.print(" saved to fuzz-findings/{s}\n", .{path}); }