//! Fuzz targets for the blocklist line parsers and the wildcard matcher //! (`src/filter/parsers.zig` and its siblings). //! //! Every target holds the same contract: any byte string is a legal blocklist //! line, so a parser may classify it however it likes but may not panic, may //! not read out of bounds and may not fail to return. Where a classification //! succeeds the target then checks the invariant the compiler is entitled to //! rely on: //! //! - `Line.text` is always a slice of the caller's line, never a copy and //! never a dangling pointer into a temporary; //! - `covers_apex` is set only on a `.wildcard` line, because the compiler //! reads it only there; //! - `wildcard.matches` terminates for any pattern, validated or not, and a //! match implies the domain has at least as many labels as the pattern, //! since every pattern label consumes at least one domain label. //! //! `parsers.parseLine` documents that its line carries no `\n` and no `\r`, so //! each target cuts the fuzzer's bytes at the first one rather than handing the //! parser input the compiler could never produce. //! //! This file is the root of its own test artifact and reaches the parsers //! through the `parsers` module, which is why those five files import nothing //! outside `src/filter/`. //! //! Runner semantics: under a plain `zig build test` each target runs once per //! corpus entry plus once on empty input, which makes the corpus a regression //! suite. `zig build test --fuzz=` gives each target `n` generated inputs. const std = @import("std"); const parsers = @import("parsers"); const smith_encode = @import("smith_encode.zig"); const sliceInput = smith_encode.sliceInput; const pairInput = smith_encode.pairInput; const wildcard = parsers.wildcard; const Smith = std.testing.Smith; /// Long enough to hold a line past `compiler.max_line_len`, which is the /// longest line the compiler ever hands a parser. const max_input = 8192; /// `Smith` entity ids. The two-slice target needs stable, distinct ids for its /// pattern and its domain; the single-slice targets take the first. const pattern_hash: u32 = 1; const domain_hash: u32 = 2; const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus }; test "fuzz parser_hosts.parseLine" { try std.testing.fuzz(parsers.Format.hosts, formatTarget, fuzz_options); } test "fuzz parser_domains.parseLine" { try std.testing.fuzz(parsers.Format.domains, formatTarget, fuzz_options); } test "fuzz parser_abp.parseLine" { try std.testing.fuzz(parsers.Format.abp, formatTarget, fuzz_options); } test "fuzz parsers.detectFormat" { try std.testing.fuzz({}, detectTarget, fuzz_options); } test "fuzz wildcard.validate and wildcard.matches" { try std.testing.fuzz({}, wildcardTarget, fuzz_options); } /// One format's parser, reached through the dispatcher the compiler uses. fn formatTarget(format: parsers.Format, smith: *Smith) anyerror!void { var buf: [max_input]u8 = undefined; const input = buf[0..smith.sliceWithHash(&buf, pattern_hash)]; const line = upToNewline(input); const parsed = parsers.parseLine(format, line); try expectBorrowed(parsed, line); } /// The sniffer reads whole files, so this one keeps the line breaks. fn detectTarget(_: void, smith: *Smith) anyerror!void { var buf: [max_input]u8 = undefined; const input = buf[0..smith.sliceWithHash(&buf, pattern_hash)]; const format = parsers.detectFormat(input); // Whatever the sniffer decides, every line of the same bytes must survive // that format's parser: this is the pairing the manager performs. var it = std.mem.splitScalar(u8, input, '\n'); while (it.next()) |raw| { const line = upToNewline(raw); try expectBorrowed(parsers.parseLine(format, line), line); } } fn wildcardTarget(_: void, smith: *Smith) anyerror!void { var pattern_buf: [max_input]u8 = undefined; var domain_buf: [max_input]u8 = undefined; const pattern = pattern_buf[0..smith.sliceWithHash(&pattern_buf, pattern_hash)]; const domain = domain_buf[0..smith.sliceWithHash(&domain_buf, domain_hash)]; // `matches` is total on unvalidated input by design, so both the accepted // and the rejected pattern are fed in. Production only ever reaches it with // an accepted one, which is why the accepted case carries the invariant. const accepted = if (wildcard.validate(pattern)) |_| true else |_| false; const matched = wildcard.matches(pattern, domain); if (accepted and matched) { try std.testing.expect(labelCount(domain) >= labelCount(pattern)); } } /// The parser contract: `text` is a window into the caller's line, so the /// compiler may keep it for the length of that line and no longer. fn expectBorrowed(parsed: parsers.Line, line: []const u8) !void { if (parsed.covers_apex) try std.testing.expectEqual(parsers.Kind.wildcard, parsed.kind); if (parsed.text.len == 0) return; const start = @intFromPtr(parsed.text.ptr); const line_start = @intFromPtr(line.ptr); try std.testing.expect(start >= line_start); try std.testing.expect(start + parsed.text.len <= line_start + line.len); } fn upToNewline(input: []const u8) []const u8 { const end = std.mem.findAny(u8, input, "\r\n") orelse input.len; return input[0..end]; } fn labelCount(text: []const u8) usize { return std.mem.count(u8, text, ".") + 1; } // --------------------------------------------------------------------------- // corpus // --------------------------------------------------------------------------- // // `Smith` does not consume a corpus entry as raw parser input, so every entry // below goes through the `smith_encode.zig` encoders. The five targets share one // corpus: each starts with a slice, and the wildcard target reads a second one // that falls back to empty when an entry carries only the first. /// A hosts line with a sink address, two names and a trailing comment. const hosts_line = "0.0.0.0 ads.example.com tracker.example.com # advertising"; /// An ABP domain rule, which covers the apex as well as the subdomains. const abp_line = "||ads.example.net^"; /// A regex rule, which every parser counts and skips (PLAN §2.2). const regex_line = "/^ads[0-9]+\\.example\\.org$/"; /// Past `compiler.max_line_len`, so the over-long path is a seed rather than a /// discovery. const long_line = "a" ** 5000 ++ ".example.com"; /// An element-hiding rule and a scheme anchor: the two `.unsupported` shapes /// that carry a domain in front of them. const element_hiding = "example.com##.ad-banner"; const scheme_anchor = "|https://ads.example.com/track"; const corpus = [_][]const u8{ sliceInput(hosts_line), sliceInput(abp_line), sliceInput(regex_line), sliceInput(long_line), sliceInput(element_hiding), sliceInput(scheme_anchor), // A whole small file, so `detectFormat` sees more than one line. sliceInput("# a hosts list\n" ++ hosts_line ++ "\n" ++ regex_line ++ "\n"), // The two wildcard shapes PLAN §3.9 names, each with a name that matches. pairInput("*.doubleclick.net", "a.b.doubleclick.net"), pairInput("ads.*.example.com", "ads.eu.west.example.com"), // A pattern that no name matches, and one `validate` rejects. pairInput("*.example.com", "example.com.evil.net"), pairInput("ad*.example.com", "ads.example.com"), };