Files
nxdns/src/filter/parsers.zig
T

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(""));
}