milestone 5: blocklist filtering, local records and conditional forwarding

This commit is contained in:
2026-08-01 16:43:55 +02:00
parent 3baf5d6581
commit 59d94df722
29 changed files with 10257 additions and 81 deletions
+218
View File
@@ -0,0 +1,218 @@
//! 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 rule. Counted, skipped, never compiled (PLAN §2.2).
regex,
/// Syntactically a rule of this format, but one nxdns cannot honour:
/// an ABP modifier list, an exception rule, 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` only. ABP `||x^` covers `x` itself as well as its subdomains,
/// so the compiler emits an additional `.list` entry when this is set.
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 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`).
pub fn isElementHiding(line: []const u8) bool {
for ([_][]const u8{ "##", "#@#", "#?#", "#$#", "#%#" }) |marker| {
if (std.mem.find(u8, line, marker) != null) return true;
}
return false;
}
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));
}
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);
}
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(""));
}