426 lines
16 KiB
Zig
426 lines
16 KiB
Zig
//! 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(""));
|
|
}
|