milestone 5: blocklist filtering, local records and conditional forwarding
This commit is contained in:
@@ -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(""));
|
||||
}
|
||||
Reference in New Issue
Block a user