//! Fuzz target for the blocklist compiler's streaming line loop //! (`src/filter/compiler.zig`). //! //! `blocklist_fuzz.zig` covers the line parsers; this file covers the loop that //! drives them. That loop is where the reader and the parsers meet, and it holds //! three arms no parser target can reach: `takeDelimiter` returning a line, //! `error.StreamTooLong` followed by `discardDelimiterInclusive`, and that //! discard hitting end of stream on a final over-long line with no newline. //! //! The contract: any byte string is a legal blocklist, so `compile` may classify //! it however it likes but must return — never panic, never loop forever, never //! read out of bounds. Where it returns the target then checks what the manager //! is entitled to rely on: //! //! - every per-line counter is bounded by the number of lines in the input, //! and every per-candidate counter by its length, so no line is counted //! twice and the discard arm cannot re-read bytes it already consumed; //! - the written counts never exceed `max_domains`; //! - a compiled body is a pure function of (bytes, format): the same input //! compiled twice gives the same counts and the same checksum. //! //! `compiler.zig` imports `../dns/`, so a module rooted under `src/filter/` //! fails with `ImportOutsideModulePath`. The root here is the staged-copy //! aggregator `build.zig` already builds for the bench harness, imported as //! `core`. //! //! Runner semantics: under a plain `zig build test` the target runs once per //! corpus entry plus once on empty input, which makes the corpus a regression //! suite. `zig build test --fuzz=` gives it `n` generated inputs. const std = @import("std"); const core = @import("core"); const smith_encode = @import("smith_encode.zig"); const sliceInput = smith_encode.sliceInput; const compiler = core.compiler; const Smith = std.testing.Smith; /// The aggregator exposes `compiler`, and `compiler.zig` keeps its own /// `parsers` import private, so the format enum is read off the signature of the /// function under test rather than imported. That also keeps the target honest /// if a format is ever added: `formats` grows with the enum. const Format = @typeInfo(@TypeOf(compiler.compile)).@"fn".params[2].type.?; const formats = std.enums.values(Format); /// Comfortably past `compiler.max_line_len`, so a single generated input can /// hold an over-long line and the lines around it. const max_input = 16384; /// The upper bound on the reader buffer the target hands `compile`. A buffer /// under `max_line_len` reports an over-long line as `error.StreamTooLong` and /// takes the discard arm; a buffer over it reports the line whole and takes the /// length check at compiler.zig:84. Both are reachable inside this range. const max_reader_buf = 8192; const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus }; test "fuzz compiler.compile" { try std.testing.fuzz({}, compileTarget, fuzz_options); } fn compileTarget(_: void, smith: *Smith) anyerror!void { var buf: [max_input]u8 = undefined; const bytes = buf[0..smith.slice(&buf)]; const format = formats[smith.index(formats.len)]; const reader_buf_len = smith.valueRangeAtMost(u32, 64, max_reader_buf); const first = (try compileOnce(bytes, format, reader_buf_len)) orelse return; try expectConsistent(first.counts, bytes); // Determinism is the property the whole design rests on (compiler.zig's // header comment): two runs over the same bytes agree byte for byte. const second = (try compileOnce(bytes, format, reader_buf_len)) orelse return error.TestSecondRunFailed; try std.testing.expectEqual(first.counts, second.counts); try std.testing.expectEqualSlices(u8, &first.checksum, &second.checksum); } /// One compile into discarding writers, or null when the compiler rejected the /// input. Every member of `compiler.Error` is a legitimate rejection: an /// allocator that ran out, a list past `max_domains`, and the two stream /// failures the fixed reader and the discarding writers cannot actually raise. fn compileOnce( bytes: []const u8, format: Format, reader_buf_len: u32, ) anyerror!?compiler.Result { var backing: std.Io.Reader = .fixed(bytes); var reader_buf: [max_reader_buf]u8 = undefined; var limited = backing.limited(.unlimited, reader_buf[0..reader_buf_len]); var list_sink: [0]u8 = .{}; var list_w: std.Io.Writer.Discarding = .init(&list_sink); var wild_sink: [0]u8 = .{}; var wild_w: std.Io.Writer.Discarding = .init(&wild_sink); return compiler.compile( std.testing.allocator, &limited.interface, format, &list_w.writer, &wild_w.writer, ) catch |err| switch (err) { error.OutOfMemory, error.TooManyDomains, error.ReadFailed, error.WriteFailed, => null, }; } /// The counters have to add up against the input that produced them. fn expectConsistent(counts: compiler.Counts, bytes: []const u8) !void { // Every line the loop classifies ends at a newline or at the end of the // input, so no per-line counter can exceed the number of lines. const lines = std.mem.count(u8, bytes, "\n") + 1; try std.testing.expect(counts.long_lines <= lines); try std.testing.expect(counts.skipped_regex <= lines); try std.testing.expect(counts.skipped_unsupported <= lines); // A candidate is a non-empty whitespace-separated field or a whole wildcard // line, so every candidate consumes at least one byte of the input, and a // written name is a candidate that survived. const candidates = @as(u64, counts.domains) + counts.wildcards + counts.duplicates + counts.invalid; try std.testing.expect(candidates <= bytes.len + 1); try std.testing.expect(counts.domains <= compiler.max_domains); try std.testing.expect(counts.wildcards <= compiler.max_domains); } // --------------------------------------------------------------------------- // corpus // --------------------------------------------------------------------------- // // `Smith` does not consume a corpus entry as raw input, so every entry below // goes through the `smith_encode.zig` encoder. An entry that carries only the // slice leaves the format index and the reader-buffer length at the low end of // their ranges, which is the 64-byte buffer that makes `error.StreamTooLong` the // common case. /// Past `compiler.max_line_len`, so the discard arm at compiler.zig:72 replays /// from the corpus rather than waiting on a discovery. const long_line = "a" ** 5000 ++ ".example.com"; /// The same line as the last line of the input, with nothing behind it: the /// discard then hits end of stream, which is the `break` at compiler.zig:73. const long_line_unterminated = "0.0.0.0 kept.example.com\n" ++ long_line; const long_line_terminated = long_line_unterminated ++ "\n0.0.0.0 after.example.com\n"; const corpus = [_][]const u8{ sliceInput(long_line_unterminated), sliceInput(long_line_terminated), sliceInput("# a hosts list\n0.0.0.0 ads.example.com # advertising\n"), sliceInput("||ads.example.net^\n@@||allow.example.net^\n/re[0-9]+/\n"), sliceInput("*.wild.example.org\nlocalhost\nAdS.Example.COM.\n"), }; test "the unterminated corpus entry ends on an over-long line" { try std.testing.expect(!std.mem.endsWith(u8, long_line_unterminated, "\n")); const last = std.mem.findScalarLast(u8, long_line_unterminated, '\n').? + 1; try std.testing.expect(long_line_unterminated.len - last > compiler.max_line_len); }