//! Blocklist line parsers: the shared vocabulary and the format sniffer. //! //! These files decide **format**, not validity. Whether a candidate is a usable //! domain name is the compiler's decision, taken through `dns.name.fromText`. //! `std` is the only import here and in every sibling parser: this file is the //! root of a separate fuzz module, and a module root cannot import across its //! own directory boundary. const std = @import("std"); pub const hosts = @import("parser_hosts.zig"); pub const domains = @import("parser_domains.zig"); pub const abp = @import("parser_abp.zig"); pub const wildcard = @import("wildcard.zig"); pub const Format = enum { hosts, domains, abp }; pub const Kind = enum { /// Nothing on the line, or only a comment. ignore, /// `text` holds one or more whitespace-separated candidate names. domain, /// `text` holds one candidate suffix; every proper subdomain of it matches. wildcard, /// A regex line in a downloaded list. Counted, skipped, never compiled: the /// engine exists for rules the operator wrote, not for lists (PLAN ยง2.2). regex, /// `text` holds one candidate name an ABP exception rule (`@@||x^`) lifts: /// the name itself and every subdomain of it. Only the ABP parser emits it. exception, /// Syntactically a rule of this format, but one nxdns cannot honour: /// an ABP modifier list, an exception form outside `@@||x^`, element /// hiding, a scheme anchor. unsupported, }; pub const Line = struct { kind: Kind, /// Borrowed from the caller's line. Not lowercased, not validated. text: []const u8 = "", /// `.wildcard` and `.exception` only: the rule covers the anchored name /// itself as well as its subdomains, which is what ABP `||x^` and `@@||x^` /// mean. The compiler acts on it for a `.wildcard` line, by emitting an /// additional `.list` entry; an `.exception` line needs no second entry, /// because the allow walk tests the full name as well as its parents. covers_apex: bool = false, }; /// Dispatches to the format's parser. The line must not contain '\n' or '\r'; /// the caller strips them. pub fn parseLine(format: Format, line: []const u8) Line { return switch (format) { .hosts => hosts.parseLine(line), .domains => domains.parseLine(line), .abp => abp.parseLine(line), }; } pub const LineEvent = union(enum) { /// One line without its delimiter, borrowed from the reader's buffer and /// valid only until the next call. A trailing '\r' is left on: whether it /// belongs to the line is the caller's decision. line: []const u8, /// A line longer than `max_len`. It has already been stepped over. long_line, }; /// One line, or `null` at end of stream. `max_len` bounds a line; anything /// longer comes back as `.long_line` with the stream positioned on the line /// after it, so a caller that keeps calling always advances. /// /// The bound is a parameter because this file may not import `compiler.zig`: /// the compiler imports this one, and this file is the root of a separate fuzz /// module. Both callers pass `compiler.max_line_len`. /// /// The two over-long paths exist because a `Reader` reports an over-long line /// two different ways. A reader whose buffer is smaller than `max_len` reports /// `error.StreamTooLong` and โ€” this is the hazard โ€” leaves the stream /// unmodified (Reader.zig:895-919), so without the discard a caller re-reads /// the same bytes forever. A reader whose buffer is larger hands the whole line /// over and the length check catches it. /// /// An over-long final line with no delimiter ends the stream inside the /// discard. That still counts as a line, so it comes back as `.long_line`; the /// discard drained the stream (Reader.zig:1042), so the next call returns /// `null`. pub fn nextBoundedLine(r: *std.Io.Reader, max_len: usize) error{ReadFailed}!?LineEvent { const raw = r.takeDelimiter('\n') catch |err| switch (err) { error.ReadFailed => return error.ReadFailed, error.StreamTooLong => { _ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) { error.EndOfStream => return .long_line, error.ReadFailed => return error.ReadFailed, }; return .long_line; }, } orelse return null; if (raw.len > max_len) return .long_line; return .{ .line = raw }; } pub const sample_lines = 64; /// Picks a format from the first `sample_lines` lines that are not blank and /// not comments: an ABP marker (`||`, `@@`, `##`, `$`) wins `.abp`; otherwise a /// majority of lines whose first field looks like an IP literal wins `.hosts`; /// otherwise `.domains`. pub fn detectFormat(sample: []const u8) Format { var considered: usize = 0; var ip_first: usize = 0; var it = std.mem.splitScalar(u8, sample, '\n'); while (it.next()) |raw| { if (considered == sample_lines) break; const line = std.mem.trim(u8, raw, &std.ascii.whitespace); if (line.len == 0) continue; if (hasAbpMarker(line)) return .abp; if (isComment(line)) continue; considered += 1; if (looksLikeIpLiteral(firstField(line))) ip_first += 1; } if (ip_first * 2 > considered) return .hosts; return .domains; } /// `!` is the ABP comment marker and `#` the hosts one; both appear in every /// format in the wild. `##`, `#@#` and `#?#` are element-hiding rules, not /// comments, so they stay visible to `hasAbpMarker`. pub fn isComment(line: []const u8) bool { if (line.len == 0) return false; if (line[0] == '!') return true; if (line[0] != '#') return false; return !isElementHiding(line); } /// The element-hiding separators, which may also follow a domain list /// (`example.com##.ad-banner`). /// /// Where the separator sits decides, because `#` is also the hosts comment /// marker and a hosts file banner is drawn out of the same two characters. A /// `##` counts only where an element-hiding rule can put one: at the start of /// the line with a selector behind it, or straight after the domain list it /// applies to. `## Title`, `####` and `see ## below` are therefore text, and a /// hosts file that opens with a banner keeps sniffing as hosts. /// /// Guarding this with `isComment` instead would decide nothing: `isComment` /// asks this function. pub fn isElementHiding(line: []const u8) bool { for ([_][]const u8{ "##", "#@#", "#?#", "#$#", "#%#" }) |marker| { var from: usize = 0; while (std.mem.find(u8, line[from..], marker)) |offset| { const at = from + offset; if (separatorStartsRule(line, at, marker.len)) return true; from = at + 1; } } return false; } /// Whether the separator of `marker_len` bytes at `at` is a rule's separator /// rather than two characters of prose. fn separatorStartsRule(line: []const u8, at: usize, marker_len: usize) bool { if (at == 0) { // A generic rule carries its selector here. A banner carries a space, // another `#`, or nothing at all. if (line.len == marker_len) return false; const after = line[marker_len]; return after != '#' and !std.ascii.isWhitespace(after); } // A domain list ends where the separator begins, with no space between. const before = line[at - 1]; return before != '#' and !std.ascii.isWhitespace(before); } fn hasAbpMarker(line: []const u8) bool { if (std.mem.startsWith(u8, line, "||")) return true; if (std.mem.startsWith(u8, line, "@@")) return true; if (isElementHiding(line)) return true; // A '$' modifier list only counts on a rule line: a hosts file whose // comments mention a price must not be sniffed as ABP. if (!isComment(line) and std.mem.findScalar(u8, line, '$') != null) return true; return false; } /// The line up to the first ASCII whitespace byte. pub fn firstField(line: []const u8) []const u8 { const end = std.mem.findAny(u8, line, &std.ascii.whitespace) orelse line.len; return line[0..end]; } /// A sniffing heuristic, not a parser: it recognizes dotted-quad IPv4 and any /// hex-and-colon IPv6 spelling. `platform/address.zig` holds the real parser and /// importing it would break this file's std-only constraint. pub fn looksLikeIpLiteral(field: []const u8) bool { if (field.len == 0) return false; if (std.mem.findScalar(u8, field, ':') != null) { for (field) |c| { if (c != ':' and c != '.' and !std.ascii.isHex(c)) return false; } return true; } var parts: usize = 0; var it = std.mem.splitScalar(u8, field, '.'); while (it.next()) |part| { parts += 1; if (part.len == 0 or part.len > 3) return false; for (part) |c| { if (!std.ascii.isDigit(c)) return false; } } return parts == 4; } const testing = std.testing; test "detectFormat recognizes a hosts file" { const sample = \\# Title: example \\0.0.0.0 ads.example.com \\0.0.0.0 track.example.net \\127.0.0.1 metrics.example.org \\ ; try testing.expectEqual(Format.hosts, detectFormat(sample)); } test "detectFormat recognizes a domains file" { const sample = \\# Title: example \\ads.example.com \\track.example.net \\metrics.example.org \\ ; try testing.expectEqual(Format.domains, detectFormat(sample)); } test "detectFormat recognizes an abp file" { const sample = \\[Adblock Plus 2.0] \\! Title: example \\||ads.example.com^ \\||track.example.net^ \\ ; try testing.expectEqual(Format.abp, detectFormat(sample)); } test "detectFormat falls back to domains on an all-comment sample" { var buffer: [64 * 16]u8 = undefined; var w: usize = 0; for (0..64) |_| { @memcpy(buffer[w..][0..14], "# a comment.\n\n"); w += 14; } try testing.expectEqual(Format.domains, detectFormat(buffer[0..w])); } test "detectFormat is not fooled by a dollar sign in a comment" { const sample = \\# donations welcome, $5 covers a month \\0.0.0.0 ads.example.com \\0.0.0.0 track.example.net \\ ; try testing.expectEqual(Format.hosts, detectFormat(sample)); } /// The banner style a hosts list published for malware URLs opens with: a rule /// of `#` characters around a titled header block. Every line of it contains /// `##`, and reading those as element hiding used to sniff the whole file as /// ABP โ€” which put `0.0.0.0` in the domain set and dropped every hosts line /// that carried an inline comment. const urlhaus_banner_sample = \\################################################################ \\# URLhaus Malicious Hosts File # \\# Last updated: 2026-08-05 06:05:04 (UTC) # \\# # \\# Terms Of Use: https://urlhaus.abuse.ch/api/ # \\################################################################ \\0.0.0.0 bad1.example.com # https://urlhaus.abuse.ch/url/1/ \\0.0.0.0 bad2.example.net # https://urlhaus.abuse.ch/url/2/ \\0.0.0.0 bad3.example.org # https://urlhaus.abuse.ch/url/3/ \\ ; test "detectFormat reads a hosts file behind a hash banner as hosts" { try testing.expectEqual(Format.hosts, detectFormat(urlhaus_banner_sample)); // The lines the banner is made of are comments, so the sample the format // is decided from is the three hosts lines alone. var it = std.mem.splitScalar(u8, urlhaus_banner_sample, '\n'); while (it.next()) |line| { if (line.len == 0) continue; if (line[0] != '#') continue; try testing.expect(isComment(line)); try testing.expect(!isElementHiding(line)); } } test "detectFormat still recognizes a generic element-hiding rule" { const sample = \\##.ad-banner \\example.com \\ ; try testing.expectEqual(Format.abp, detectFormat(sample)); try testing.expect(isElementHiding("##.ad-banner")); } test "detectFormat recognizes element hiding after a domain list" { const sample = \\example.com##.ad \\other.example.net \\ ; try testing.expectEqual(Format.abp, detectFormat(sample)); try testing.expect(isElementHiding("example.com##.ad")); } test "detectFormat recognizes an exception separator after a domain" { const sample = \\example.com#@#.sponsored \\other.example.net \\ ; try testing.expectEqual(Format.abp, detectFormat(sample)); try testing.expect(isElementHiding("example.com#@#.sponsored")); } test "a comment line that mentions a separator stays a comment" { const line = "# the ##.ad rules live in the other list"; try testing.expect(isComment(line)); try testing.expect(!isElementHiding(line)); // A bare separator and a rule of hashes are text as well. try testing.expect(!isElementHiding("##")); try testing.expect(!isElementHiding("####")); try testing.expect(!isElementHiding("## Title")); } test "parseLine dispatches to the hosts parser" { const line = parseLine(.hosts, "0.0.0.0 ads.example.com"); try testing.expectEqual(Kind.domain, line.kind); try testing.expectEqualStrings("ads.example.com", line.text); } test "parseLine dispatches to the domains parser" { const line = parseLine(.domains, "0.0.0.0 ads.example.com"); try testing.expectEqual(Kind.unsupported, line.kind); } test "parseLine dispatches to the abp parser" { const line = parseLine(.abp, "||ads.example.com^"); try testing.expectEqual(Kind.wildcard, line.kind); try testing.expectEqualStrings("ads.example.com", line.text); try testing.expect(line.covers_apex); } fn expectLine(expected: []const u8, event: ?LineEvent) !void { const got = event orelse return error.TestExpectedLine; switch (got) { .line => |line| try testing.expectEqualStrings(expected, line), .long_line => return error.TestExpectedLine, } } test "nextBoundedLine walks lines and ends at the stream" { var r: std.Io.Reader = .fixed("a\nbb\n\nccc"); try expectLine("a", try nextBoundedLine(&r, 16)); try expectLine("bb", try nextBoundedLine(&r, 16)); try expectLine("", try nextBoundedLine(&r, 16)); // A final line with no delimiter is still a line. try expectLine("ccc", try nextBoundedLine(&r, 16)); try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 16)); } test "nextBoundedLine reports an over-long line when the reader buffer is large" { var r: std.Io.Reader = .fixed("a\nxxxxxxxx\nb\n"); try expectLine("a", try nextBoundedLine(&r, 4)); try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(&r, 4)).?); try expectLine("b", try nextBoundedLine(&r, 4)); try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 4)); } test "nextBoundedLine steps over a line that does not fit the reader buffer" { // A buffer smaller than the long line makes `takeDelimiter` report // `error.StreamTooLong` and leave the stream where it was, which is the // path that loops forever without the discard. var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64 ++ "\nb\n"); var buf: [16]u8 = undefined; var limited = backing.limited(.unlimited, &buf); const r = &limited.interface; try expectLine("a", try nextBoundedLine(r, 16)); try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?); try expectLine("b", try nextBoundedLine(r, 16)); try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16)); } test "nextBoundedLine reports an over-long final line that ends inside the discard" { var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64); var buf: [16]u8 = undefined; var limited = backing.limited(.unlimited, &buf); const r = &limited.interface; try expectLine("a", try nextBoundedLine(r, 16)); try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?); try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16)); } test "looksLikeIpLiteral separates addresses from names" { try testing.expect(looksLikeIpLiteral("0.0.0.0")); try testing.expect(looksLikeIpLiteral("127.0.0.1")); try testing.expect(looksLikeIpLiteral("::1")); try testing.expect(looksLikeIpLiteral("fd00::dead:beef")); try testing.expect(!looksLikeIpLiteral("example.com")); try testing.expect(!looksLikeIpLiteral("add.face.cafe")); try testing.expect(!looksLikeIpLiteral("1.2.3")); try testing.expect(!looksLikeIpLiteral("")); }