Something went wrong. Try again.
A low-level, non-allocating CSV tokenizer for Zig that reads any std.Io.Reader and yields tokens.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A fuzzing loop for the CSV tokenizer.//!//! Zig 0.16.0 cannot build a test executable in fuzz mode, and even patched it//! populates no coverage table, so this drives the targets in `test/fuzz.zig`//! itself: it mutates a corpus of real CSV inputs, hands each result to a//! target, and reports what came back.//!//! Usage: fuzz [--iterations N] [--seed N] [--target NAME]
const std = @import("std");const fuzz = @import("fuzz");
const Smith = std.testing.Smith;
/// Seed inputs. Mutation works from real CSV rather than from noise, which is/// what makes up for having no coverage feedback to steer with.const corpus = [_][]const u8{ "", "\n", "\r\n", "a", "a,b", "a,b\n", "a,b\r\nc,d\r\n", "a,b\rc,d\r", "1,,3\n", ",", ",,,\n", "\"a\"", "\"a\",\"b\"\r\n", "\"a\"\"b\",c\n", "\"a\r\nb\",c\r\n", "\"\"\n", "\"unclosed", "a\"b,c\n", "\"a\"x,b\n", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,b\n", "a,b\r\n\r\nc,d\n", "\"\"\"\"\"\"\n", "a\rb\r\nc", "x;y|z\tw\n", "\xef\xbb\xbfa,b\r\n", "\xef\xbb\xbf\"q\",r\n", "\xef\xbb\xbf",};
/// Bytes a mutation is likely to reach for: the CSV metacharacters, plus a/// couple of ordinary ones so inputs are not entirely punctuation.const interesting = [_]u8{ ',', '"', '\r', '\n', ';', '\t', '|', 'a', 'b', '0', 0, 0xef, 0xbb, 0xbf };
const Stats = struct { iterations: u64 = 0, ok: u64 = 0, expected_errors: u64 = 0, empty_payloads: u64 = 0, payload_bytes: u64 = 0, max_payload: usize = 0, by_config: [fuzz.col_seps.len][fuzz.row_sep_choices]u64 = @splat(@splat(0)),};
pub fn main(init: std.process.Init.Minimal) !void { var debug_allocator: std.heap.DebugAllocator(.{}) = .init; defer if (debug_allocator.deinit() == .leak) std.process.exit(1); const gpa = debug_allocator.allocator();
// Targets cannot name `std.testing.allocator` outside a test build, so // they are given one here, and it reports any leak on the way out. fuzz.backing = gpa;
var iterations: u64 = 100_000; var seed: u64 = 0; var only: ?[]const u8 = null;
var args = try std.process.Args.Iterator.initAllocator(init.args, gpa); defer args.deinit(); _ = args.next(); while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--iterations")) { iterations = try std.fmt.parseInt(u64, args.next() orelse return error.MissingValue, 10); } else if (std.mem.eql(u8, arg, "--seed")) { seed = try std.fmt.parseInt(u64, args.next() orelse return error.MissingValue, 10); } else if (std.mem.eql(u8, arg, "--target")) { only = args.next() orelse return error.MissingValue; } else { std.debug.print("unknown argument: {s}\n", .{arg}); std.process.exit(2); } }
var prng: std.Random.DefaultPrng = .init(seed); const random = prng.random();
var payload: [fuzz.max_input]u8 = undefined; var input_buf: [fuzz.max_input + 64]u8 = undefined;
var stats: Stats = .{}; var failures: u64 = 0;
for (0..iterations) |i| { const target = fuzz.targets[random.uintLessThan(usize, fuzz.targets.len)]; if (only) |name| { if (!std.mem.eql(u8, name, target.name)) continue; }
const len = mutate(random, &payload); const col = random.uintLessThan(u8, fuzz.col_seps.len); const row = random.uintLessThan(u8, fuzz.row_sep_choices); const shift = fuzz.min_buffer_shift + random.uintLessThan(u8, fuzz.max_buffer_shift - fuzz.min_buffer_shift + 1);
const skip_bom = random.uintLessThan(u8, 2); const input = fuzz.writeInput(&input_buf, payload[0..len], col, row, shift, skip_bom);
stats.iterations += 1; stats.payload_bytes += len; stats.max_payload = @max(stats.max_payload, len); if (len == 0) stats.empty_payloads += 1; stats.by_config[col][row] += 1;
var smith: Smith = .{ .in = input }; target.run(&smith) catch |err| { switch (err) { error.ShortBuffer, error.MisplacedQuote, error.NoSeparatorAfterField, error.UnclosedQuote => { stats.expected_errors += 1; continue; }, else => {}, }
failures += 1; std.debug.print( \\ \\=== FAILURE === \\iteration : {d} \\target : {s} \\error : {t} \\col_sep : {d} row_sep: {d} buffer: {d} \\payload : "{f}" \\ , .{ i, target.name, err, col, row, @as(usize, 1) << @intCast(shift), std.zig.fmtString(payload[0..len]) });
if (failures >= 10) { std.debug.print("stopping after {d} failures\n", .{failures}); break; } continue; }; stats.ok += 1; }
report(stats, failures); if (failures > 0) std.process.exit(1);}
fn report(stats: Stats, failures: u64) void { std.debug.print( \\ \\iterations : {d} \\clean : {d} \\expected errors : {d} \\failures : {d} \\ \\-- oracle -- \\agreed (parsed) : {d} \\agreed (rejected): {d} \\RFC 4180 documents: {d} \\ \\-- generation sanity -- \\empty payloads : {d} ({d}%) \\mean payload : {d} bytes \\largest payload : {d} bytes \\ , .{ stats.iterations, stats.ok, stats.expected_errors, failures, fuzz.accepted, fuzz.rejected, fuzz.documents, stats.empty_payloads, if (stats.iterations == 0) 0 else stats.empty_payloads * 100 / stats.iterations, if (stats.iterations == 0) 0 else stats.payload_bytes / stats.iterations, stats.max_payload, });
// Silent mis-generation is the failure mode that makes a fuzzer report // millions of iterations having tested nothing, so show the spread. for (stats.by_config, 0..) |row_counts, col| { for (row_counts, 0..) |count, row| { std.debug.print("col={c} row={d}: {d}\n", .{ fuzz.col_seps[col], row, count }); } }}
/// Build the next payload: pick a corpus entry, then apply a few edits.fn mutate(random: std.Random, out: []u8) usize { const seed_input = corpus[random.uintLessThan(usize, corpus.len)]; var len = @min(seed_input.len, out.len); @memcpy(out[0..len], seed_input[0..len]);
const edits = random.uintLessThan(u8, 6); for (0..edits) |_| { switch (random.uintLessThan(u8, 6)) { // Overwrite a byte with an interesting one. 0 => if (len > 0) { out[random.uintLessThan(usize, len)] = interesting[random.uintLessThan(usize, interesting.len)]; }, // Overwrite a byte with anything at all. 1 => if (len > 0) { out[random.uintLessThan(usize, len)] = random.int(u8); }, // Insert an interesting byte. 2 => if (len < out.len) { const at = random.uintAtMost(usize, len); std.mem.copyBackwards(u8, out[at + 1 .. len + 1], out[at..len]); out[at] = interesting[random.uintLessThan(usize, interesting.len)]; len += 1; }, // Delete a byte. 3 => if (len > 0) { const at = random.uintLessThan(usize, len); std.mem.copyForwards(u8, out[at .. len - 1], out[at + 1 .. len]); len -= 1; }, // Append another corpus entry, which is how long inputs and // multi-record inputs appear. 4 => { const extra = corpus[random.uintLessThan(usize, corpus.len)]; const room = @min(extra.len, out.len - len); @memcpy(out[len..][0..room], extra[0..room]); len += room; }, // Repeat what is there, to reach past the field buffer. else => { const room = @min(len, out.len - len); @memcpy(out[len..][0..room], out[0..room]); len += room; }, } }
return len;}