milestone 21: abp list exceptions and a regex rule kind
This commit is contained in:
+174
-42
@@ -1,14 +1,15 @@
|
||||
//! Compiles a downloaded blocklist into the two bodies nxdns stores on disk:
|
||||
//! a `.list` body of exact names and a `.wild` body of suffixes.
|
||||
//! Compiles a downloaded blocklist into the three bodies nxdns stores on disk:
|
||||
//! a `.list` body of exact names, a `.wild` body of suffixes and an `.allow`
|
||||
//! body of the names the list's `@@` exceptions lift.
|
||||
//!
|
||||
//! Pure over reader/writer interfaces: an allocator, a `*std.Io.Reader` and two
|
||||
//! `*std.Io.Writer`. No `std.Io` value, no file, no clock. A compiled body is a
|
||||
//! pure function of (bytes, format), which is what makes two runs — and two
|
||||
//! Pure over reader/writer interfaces: an allocator, a `*std.Io.Reader` and
|
||||
//! three `*std.Io.Writer`. No `std.Io` value, no file, no clock. A compiled body
|
||||
//! is a pure function of (bytes, format), which is what makes two runs — and two
|
||||
//! permutations of the same input — byte-identical.
|
||||
//!
|
||||
//! Nothing but the sorted, deduplicated names is written: no header, no
|
||||
//! timestamp, no counts. The header belongs to the caller, and the checksum
|
||||
//! covers the two bodies only.
|
||||
//! covers the three bodies only.
|
||||
|
||||
const std = @import("std");
|
||||
const parsers = @import("parsers.zig");
|
||||
@@ -23,6 +24,12 @@ pub const max_line_len: usize = 4096;
|
||||
pub const Counts = struct {
|
||||
domains: u32 = 0,
|
||||
wildcards: u32 = 0,
|
||||
/// Written, deduplicated `.allow` entries: the names this list's `@@`
|
||||
/// exceptions lift out of what other lists block.
|
||||
exceptions: u32 = 0,
|
||||
/// Regex lines this list carried, counted and skipped. nxdns has an engine
|
||||
/// for them now, but it stays reserved for operator rules: a downloaded list
|
||||
/// is other people's patterns, and PLAN §2.2 keeps them out.
|
||||
skipped_regex: u32 = 0,
|
||||
skipped_unsupported: u32 = 0,
|
||||
/// Not a valid domain name (`dns.name.fromText` rejected it, a non-ASCII
|
||||
@@ -36,30 +43,35 @@ pub const Counts = struct {
|
||||
|
||||
pub const Result = struct {
|
||||
counts: Counts,
|
||||
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body.
|
||||
/// Lowercase hex sha256 over the `.list` body, then the `.wild` body, then
|
||||
/// the `.allow` body.
|
||||
///
|
||||
/// The `.allow` body came last for a reason: hashing an empty one adds
|
||||
/// nothing, so a list with no exceptions keeps the digest it had when only
|
||||
/// two bodies existed. Every checksum published before exceptions were
|
||||
/// honoured therefore stays valid, and upgrading forces no refetch.
|
||||
checksum: [64]u8,
|
||||
};
|
||||
|
||||
pub const Error = error{ OutOfMemory, TooManyDomains, ReadFailed, WriteFailed };
|
||||
|
||||
/// Reads `r` to end of stream and writes the two compiled bodies.
|
||||
/// Reads `r` to end of stream and writes the three compiled bodies.
|
||||
///
|
||||
/// `counts.domains` and `counts.wildcards` are the written, deduplicated
|
||||
/// counts: they are what `blocklist_sources.domain_count` and `wildcard_count`
|
||||
/// store and what the UI shows.
|
||||
/// `counts.domains`, `counts.wildcards` and `counts.exceptions` are the written,
|
||||
/// deduplicated counts: they are what `blocklist_sources.domain_count`,
|
||||
/// `wildcard_count` and `exception_count` store and what the UI shows.
|
||||
pub fn compile(
|
||||
gpa: std.mem.Allocator,
|
||||
r: *std.Io.Reader,
|
||||
format: parsers.Format,
|
||||
list_w: *std.Io.Writer,
|
||||
wild_w: *std.Io.Writer,
|
||||
allow_w: *std.Io.Writer,
|
||||
) Error!Result {
|
||||
var counts: Counts = .{};
|
||||
|
||||
var list: Entries = .{};
|
||||
defer list.deinit(gpa);
|
||||
var wild: Entries = .{};
|
||||
defer wild.deinit(gpa);
|
||||
var bodies: Bodies = .{};
|
||||
defer bodies.deinit(gpa);
|
||||
|
||||
while (try parsers.nextBoundedLine(r, max_line_len)) |event| {
|
||||
const raw = switch (event) {
|
||||
@@ -81,32 +93,27 @@ pub fn compile(
|
||||
.domain => {
|
||||
var fields = std.mem.tokenizeAny(u8, parsed.text, &std.ascii.whitespace);
|
||||
while (fields.next()) |field| {
|
||||
try addCandidate(gpa, field, false, false, &list, &wild, &counts);
|
||||
try addCandidate(gpa, field, parsed, &bodies, &counts);
|
||||
}
|
||||
},
|
||||
.wildcard => try addCandidate(
|
||||
gpa,
|
||||
parsed.text,
|
||||
true,
|
||||
parsed.covers_apex,
|
||||
&list,
|
||||
&wild,
|
||||
&counts,
|
||||
),
|
||||
.wildcard, .exception => try addCandidate(gpa, parsed.text, parsed, &bodies, &counts),
|
||||
}
|
||||
}
|
||||
|
||||
// The `.allow` body is hashed last so that a list with no exceptions
|
||||
// reproduces the digest a two-body compile of the same bytes produced.
|
||||
var hasher = Sha256.init(.{});
|
||||
counts.domains = try emit(&list, list_w, &hasher, &counts.duplicates);
|
||||
counts.wildcards = try emit(&wild, wild_w, &hasher, &counts.duplicates);
|
||||
counts.domains = try emit(&bodies.list, list_w, &hasher, &counts.duplicates);
|
||||
counts.wildcards = try emit(&bodies.wild, wild_w, &hasher, &counts.duplicates);
|
||||
counts.exceptions = try emit(&bodies.allow, allow_w, &hasher, &counts.duplicates);
|
||||
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
return .{ .counts = counts, .checksum = std.fmt.bytesToHex(digest, .lower) };
|
||||
}
|
||||
|
||||
/// Normalizes one whitespace-separated candidate and files it under `.list`,
|
||||
/// `.wild`, or neither.
|
||||
/// Normalizes one whitespace-separated candidate of `line` and files it under
|
||||
/// `.list`, `.wild`, `.allow`, or nowhere.
|
||||
///
|
||||
/// The normalization below is deliberately not `dns.name.normalizeText`: this
|
||||
/// one adds the two-label minimum, rejects control bytes, and reports every
|
||||
@@ -114,20 +121,19 @@ pub fn compile(
|
||||
fn addCandidate(
|
||||
gpa: std.mem.Allocator,
|
||||
field: []const u8,
|
||||
from_wildcard_line: bool,
|
||||
covers_apex: bool,
|
||||
list: *Entries,
|
||||
wild: *Entries,
|
||||
line: parsers.Line,
|
||||
bodies: *Bodies,
|
||||
counts: *Counts,
|
||||
) Error!void {
|
||||
var candidate = field;
|
||||
var is_wildcard = from_wildcard_line;
|
||||
var is_wildcard = line.kind == .wildcard;
|
||||
if (std.mem.startsWith(u8, candidate, "*.")) {
|
||||
is_wildcard = true;
|
||||
candidate = candidate[2..];
|
||||
}
|
||||
// A '*' anywhere else makes this a pattern, and patterns belong to the
|
||||
// `rules` table; a blocklist entry is a name or a suffix.
|
||||
// `rules` table, where the operator writes them as a `.wildcard` or a
|
||||
// `.regex`; a blocklist entry is a name or a suffix.
|
||||
if (std.mem.indexOfScalar(u8, candidate, '*') != null) {
|
||||
counts.invalid += 1;
|
||||
return;
|
||||
@@ -163,12 +169,17 @@ fn addCandidate(
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_wildcard) {
|
||||
try wild.append(gpa, normalized);
|
||||
// An exception needs no apex entry beside its suffix entry: the matcher
|
||||
// walks the `.allow` set over the full name and every parent, so one entry
|
||||
// lifts `x` and every subdomain of it at once.
|
||||
if (line.kind == .exception) {
|
||||
try bodies.allow.append(gpa, normalized);
|
||||
} else if (is_wildcard) {
|
||||
try bodies.wild.append(gpa, normalized);
|
||||
// An ABP `||x^` rule covers `x` itself as well as its subdomains.
|
||||
if (covers_apex) try list.append(gpa, normalized);
|
||||
if (line.covers_apex) try bodies.list.append(gpa, normalized);
|
||||
} else {
|
||||
try list.append(gpa, normalized);
|
||||
try bodies.list.append(gpa, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +213,20 @@ fn emit(
|
||||
return written;
|
||||
}
|
||||
|
||||
/// The three bodies under construction, in the order they are written and
|
||||
/// hashed.
|
||||
const Bodies = struct {
|
||||
list: Entries = .{},
|
||||
wild: Entries = .{},
|
||||
allow: Entries = .{},
|
||||
|
||||
fn deinit(self: *Bodies, gpa: std.mem.Allocator) void {
|
||||
self.list.deinit(gpa);
|
||||
self.wild.deinit(gpa);
|
||||
self.allow.deinit(gpa);
|
||||
}
|
||||
};
|
||||
|
||||
/// Length-prefixed candidate bytes plus the offsets that index them. Sorting
|
||||
/// permutes the offsets, so the bytes never move.
|
||||
const Entries = struct {
|
||||
@@ -247,10 +272,12 @@ const Compiled = struct {
|
||||
result: Result,
|
||||
list_w: std.Io.Writer.Allocating,
|
||||
wild_w: std.Io.Writer.Allocating,
|
||||
allow_w: std.Io.Writer.Allocating,
|
||||
|
||||
fn deinit(self: *Compiled) void {
|
||||
self.list_w.deinit();
|
||||
self.wild_w.deinit();
|
||||
self.allow_w.deinit();
|
||||
}
|
||||
|
||||
fn list(self: *Compiled) []const u8 {
|
||||
@@ -260,6 +287,10 @@ const Compiled = struct {
|
||||
fn wild(self: *Compiled) []const u8 {
|
||||
return self.wild_w.written();
|
||||
}
|
||||
|
||||
fn allow(self: *Compiled) []const u8 {
|
||||
return self.allow_w.written();
|
||||
}
|
||||
};
|
||||
|
||||
fn compileText(gpa: std.mem.Allocator, text: []const u8, format: parsers.Format) Error!Compiled {
|
||||
@@ -272,8 +303,10 @@ fn compileReader(gpa: std.mem.Allocator, r: *std.Io.Reader, format: parsers.Form
|
||||
errdefer list_w.deinit();
|
||||
var wild_w: std.Io.Writer.Allocating = .init(gpa);
|
||||
errdefer wild_w.deinit();
|
||||
const result = try compile(gpa, r, format, &list_w.writer, &wild_w.writer);
|
||||
return .{ .result = result, .list_w = list_w, .wild_w = wild_w };
|
||||
var allow_w: std.Io.Writer.Allocating = .init(gpa);
|
||||
errdefer allow_w.deinit();
|
||||
const result = try compile(gpa, r, format, &list_w.writer, &wild_w.writer, &allow_w.writer);
|
||||
return .{ .result = result, .list_w = list_w, .wild_w = wild_w, .allow_w = allow_w };
|
||||
}
|
||||
|
||||
const hosts_fixture =
|
||||
@@ -339,10 +372,106 @@ test "abp apex rule lands in both bodies" {
|
||||
|
||||
try testing.expectEqualStrings("bare.com\nx.com\n", c.list());
|
||||
try testing.expectEqualStrings("x.com\n", c.wild());
|
||||
try testing.expectEqualStrings("z.com\n", c.allow());
|
||||
try testing.expectEqual(@as(u32, 2), c.result.counts.domains);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.wildcards);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.exceptions);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
|
||||
try testing.expectEqual(@as(u32, 2), c.result.counts.skipped_unsupported);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);
|
||||
}
|
||||
|
||||
test "an abp list's hosts-style lines reach the domain body through the split" {
|
||||
// The case `parser_abp` defers here: it hands a whitespace-carrying bare
|
||||
// candidate over whole, and only the tokenization in `compile` files the
|
||||
// name out of it. A mixed list — `!` header and `||` rules, so `detectFormat`
|
||||
// calls the whole file `abp`, plus the hosts lines such lists carry — reaches
|
||||
// a compiled body no other way, and no parser test can see it happen.
|
||||
const fixture =
|
||||
"! Title: mixed\n" ++
|
||||
"||blocked.example^\n" ++
|
||||
"0.0.0.0 ads.example\n" ++
|
||||
"127.0.0.1 localhost\n";
|
||||
|
||||
var c = try compileText(testing.allocator, fixture, .abp);
|
||||
defer c.deinit();
|
||||
|
||||
// The address field is filed as a name of its own: abp lines have no hosts
|
||||
// framing, so the compiler cannot know which field is the address. `0.0.0.0`
|
||||
// and `127.0.0.1` are names nobody resolves, which is why the split is worth
|
||||
// more than the two spurious entries cost.
|
||||
try testing.expectEqualStrings(
|
||||
"0.0.0.0\n127.0.0.1\nads.example\nblocked.example\n",
|
||||
c.list(),
|
||||
);
|
||||
try testing.expectEqualStrings("blocked.example\n", c.wild());
|
||||
try testing.expectEqual(@as(u32, 4), c.result.counts.domains);
|
||||
// `localhost` is the one label the two-label minimum drops.
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
|
||||
}
|
||||
|
||||
test "the allow body is sorted, deduplicated and normalized like the others" {
|
||||
const fixture =
|
||||
"@@||GOOD.ads.example^\n" ++
|
||||
"@@||a.ads.example^$important\n" ++
|
||||
"@@||good.ads.example\n" ++
|
||||
"@@||localhost^\n" ++
|
||||
"@@||bad*.ads.example^\n" ++
|
||||
"||ads.example^\n";
|
||||
|
||||
var c = try compileText(testing.allocator, fixture, .abp);
|
||||
defer c.deinit();
|
||||
|
||||
try testing.expectEqualStrings("a.ads.example\ngood.ads.example\n", c.allow());
|
||||
try testing.expectEqual(@as(u32, 2), c.result.counts.exceptions);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.duplicates);
|
||||
// `localhost` is one label, and the starred name is not a name at all.
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);
|
||||
|
||||
// The exceptions changed neither block body.
|
||||
try testing.expectEqualStrings("ads.example\n", c.list());
|
||||
try testing.expectEqualStrings("ads.example\n", c.wild());
|
||||
}
|
||||
|
||||
test "an empty allow body reproduces the checksum of a two-body compile" {
|
||||
var c = try compileText(testing.allocator, hosts_fixture, .hosts);
|
||||
defer c.deinit();
|
||||
try testing.expectEqualStrings("", c.allow());
|
||||
|
||||
// What the digest was before the `.allow` body existed: the `.list` body
|
||||
// followed by the `.wild` body and nothing else. Every checksum stored by an
|
||||
// older nxdns was taken this way, and this is the equality that keeps them
|
||||
// valid — without it, every source on every installation would report
|
||||
// `ChecksumMismatch` at the first reload after the upgrade and re-download.
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update(c.list());
|
||||
hasher.update(c.wild());
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &c.result.checksum);
|
||||
}
|
||||
|
||||
test "the checksum of a source with exceptions covers all three bodies in order" {
|
||||
const fixture =
|
||||
"||ads.example^\n" ++
|
||||
"@@||good.ads.example^\n";
|
||||
|
||||
var c = try compileText(testing.allocator, fixture, .abp);
|
||||
defer c.deinit();
|
||||
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update(c.list());
|
||||
hasher.update(c.wild());
|
||||
hasher.update(c.allow());
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &c.result.checksum);
|
||||
|
||||
// A non-empty allow body does move the digest, so a list that gains an
|
||||
// exception is recompiled rather than silently kept.
|
||||
var without = try compileText(testing.allocator, "||ads.example^\n", .abp);
|
||||
defer without.deinit();
|
||||
try testing.expect(!std.mem.eql(u8, &c.result.checksum, &without.result.checksum));
|
||||
}
|
||||
|
||||
test "two runs of the same input are byte-identical" {
|
||||
@@ -353,6 +482,7 @@ test "two runs of the same input are byte-identical" {
|
||||
|
||||
try testing.expectEqualStrings(a.list(), b.list());
|
||||
try testing.expectEqualStrings(a.wild(), b.wild());
|
||||
try testing.expectEqualStrings(a.allow(), b.allow());
|
||||
try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
|
||||
}
|
||||
|
||||
@@ -374,6 +504,7 @@ test "a permutation of the input compiles to the same bodies" {
|
||||
|
||||
try testing.expectEqualStrings(a.list(), b.list());
|
||||
try testing.expectEqualStrings(a.wild(), b.wild());
|
||||
try testing.expectEqualStrings(a.allow(), b.allow());
|
||||
try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
|
||||
}
|
||||
|
||||
@@ -495,6 +626,7 @@ test "empty input produces empty bodies and the sha256 of the empty string" {
|
||||
|
||||
try testing.expectEqualStrings("", c.list());
|
||||
try testing.expectEqualStrings("", c.wild());
|
||||
try testing.expectEqualStrings("", c.allow());
|
||||
try testing.expectEqualStrings(
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
&c.result.checksum,
|
||||
|
||||
@@ -33,10 +33,13 @@ const sources_repo = @import("../storage/repositories/sources_repo.zig");
|
||||
|
||||
const compiler = @import("compiler.zig");
|
||||
const fetcher = @import("fetcher.zig");
|
||||
const parsers = @import("parsers.zig");
|
||||
const manager = @import("manager.zig");
|
||||
const matcher = @import("matcher.zig");
|
||||
const response = @import("response.zig");
|
||||
|
||||
const lookup = @import("../web/handlers/lookup.zig");
|
||||
|
||||
const forward_client = @import("../local/forward_client.zig");
|
||||
const forward_zones = @import("../local/forward_zones.zig");
|
||||
const records = @import("../local/records.zig");
|
||||
@@ -60,6 +63,11 @@ const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awak
|
||||
/// The forward-zone read timeout. Case 15 asserts a silent resolver gives up
|
||||
/// inside twice this, so it has to be short enough to keep the run quick and
|
||||
/// long enough that a loopback answer always beats it.
|
||||
/// `/api/lookup` reports the local tables beside the filter decision; this
|
||||
/// suite's cases are about the filter half, so both are empty here.
|
||||
const empty_records: records.Records = .empty;
|
||||
const empty_zones: forward_zones.Zones = .empty;
|
||||
|
||||
const read_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake };
|
||||
|
||||
const file_limit: std.Io.Limit = .limited(8 * 1024 * 1024);
|
||||
@@ -111,49 +119,67 @@ const http_body =
|
||||
|
||||
const http_domains: i64 = 2;
|
||||
const http_wildcards: i64 = 1;
|
||||
const http_exceptions: i64 = 0;
|
||||
const http_regex: i64 = 1;
|
||||
|
||||
/// Compiles `text` into `<base>.list` and `<base>.wild` under `dir`, exactly as
|
||||
/// the manager's compile stage does, and returns the compiler's own result.
|
||||
/// Compiles `text` into `<base>.list`, `<base>.wild` and `<base>.allow` under
|
||||
/// `dir`, exactly as the manager's compile stage does, and returns the
|
||||
/// compiler's own result.
|
||||
fn compileToFiles(
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
dir: std.Io.Dir,
|
||||
base: []const u8,
|
||||
text: []const u8,
|
||||
format: parsers.Format,
|
||||
) !compiler.Result {
|
||||
var list_name_buf: [64]u8 = undefined;
|
||||
var wild_name_buf: [64]u8 = undefined;
|
||||
var allow_name_buf: [64]u8 = undefined;
|
||||
const list_name = try std.fmt.bufPrint(&list_name_buf, "{s}.list", .{base});
|
||||
const wild_name = try std.fmt.bufPrint(&wild_name_buf, "{s}.wild", .{base});
|
||||
const allow_name = try std.fmt.bufPrint(&allow_name_buf, "{s}.allow", .{base});
|
||||
|
||||
const list_file = try dir.createFile(io, list_name, .{ .permissions = .fromMode(0o600) });
|
||||
defer list_file.close(io);
|
||||
const wild_file = try dir.createFile(io, wild_name, .{ .permissions = .fromMode(0o600) });
|
||||
defer wild_file.close(io);
|
||||
const allow_file = try dir.createFile(io, allow_name, .{ .permissions = .fromMode(0o600) });
|
||||
defer allow_file.close(io);
|
||||
|
||||
const buffers = try gpa.alloc(u8, 2 * 16 * 1024);
|
||||
const buffers = try gpa.alloc(u8, 3 * 16 * 1024);
|
||||
defer gpa.free(buffers);
|
||||
|
||||
var r: std.Io.Reader = .fixed(text);
|
||||
var list_w = list_file.writer(io, buffers[0 .. 16 * 1024]);
|
||||
var wild_w = wild_file.writer(io, buffers[16 * 1024 ..]);
|
||||
var wild_w = wild_file.writer(io, buffers[16 * 1024 .. 32 * 1024]);
|
||||
var allow_w = allow_file.writer(io, buffers[32 * 1024 ..]);
|
||||
|
||||
const result = try compiler.compile(gpa, &r, .hosts, &list_w.interface, &wild_w.interface);
|
||||
const result = try compiler.compile(
|
||||
gpa,
|
||||
&r,
|
||||
format,
|
||||
&list_w.interface,
|
||||
&wild_w.interface,
|
||||
&allow_w.interface,
|
||||
);
|
||||
try list_w.interface.flush();
|
||||
try wild_w.interface.flush();
|
||||
try allow_w.interface.flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The two compiled bodies of one source, read back from disk with their
|
||||
/// The three compiled bodies of one source, read back from disk with their
|
||||
/// headers stripped, exactly as `Manager.reload` reads them.
|
||||
const Bodies = struct {
|
||||
list: []u8,
|
||||
wild: []u8,
|
||||
allow: []u8,
|
||||
|
||||
fn read(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, base: []const u8) !Bodies {
|
||||
var list_name_buf: [64]u8 = undefined;
|
||||
var wild_name_buf: [64]u8 = undefined;
|
||||
var allow_name_buf: [64]u8 = undefined;
|
||||
const list = try dir.readFileAlloc(
|
||||
io,
|
||||
try std.fmt.bufPrint(&list_name_buf, "{s}.list", .{base}),
|
||||
@@ -167,21 +193,34 @@ const Bodies = struct {
|
||||
gpa,
|
||||
file_limit,
|
||||
);
|
||||
return .{ .list = list, .wild = wild };
|
||||
errdefer gpa.free(wild);
|
||||
const allow = try dir.readFileAlloc(
|
||||
io,
|
||||
try std.fmt.bufPrint(&allow_name_buf, "{s}.allow", .{base}),
|
||||
gpa,
|
||||
file_limit,
|
||||
);
|
||||
return .{ .list = list, .wild = wild, .allow = allow };
|
||||
}
|
||||
|
||||
fn deinit(self: *Bodies, gpa: std.mem.Allocator) void {
|
||||
gpa.free(self.list);
|
||||
gpa.free(self.wild);
|
||||
gpa.free(self.allow);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// A one-group, one-source snapshot over two compiled bodies.
|
||||
fn snapshotOver(gpa: std.mem.Allocator, list_body: []const u8, wild_body: []const u8) !matcher.Snapshot {
|
||||
/// A one-group, one-source snapshot over the three compiled bodies.
|
||||
fn snapshotOver(
|
||||
gpa: std.mem.Allocator,
|
||||
list_body: []const u8,
|
||||
wild_body: []const u8,
|
||||
allow_body: []const u8,
|
||||
) !matcher.Snapshot {
|
||||
const sources = [_]model.BlocklistSource{.{ .url = source_url, .name = source_name }};
|
||||
const compiled = [_]?matcher.Snapshot.Compiled{
|
||||
.{ .list_body = list_body, .wild_body = wild_body },
|
||||
.{ .list_body = list_body, .wild_body = wild_body, .allow_body = allow_body },
|
||||
};
|
||||
return matcher.Snapshot.build(gpa, .{
|
||||
.groups = &.{.{ .name = "default" }},
|
||||
@@ -198,10 +237,11 @@ fn snapshotOver(gpa: std.mem.Allocator, list_body: []const u8, wild_body: []cons
|
||||
});
|
||||
}
|
||||
|
||||
fn bodyChecksum(list_body: []const u8, wild_body: []const u8) [64]u8 {
|
||||
fn bodyChecksum(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) [64]u8 {
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update(list_body);
|
||||
hasher.update(wild_body);
|
||||
hasher.update(allow_body);
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
return std.fmt.bytesToHex(digest, .lower);
|
||||
@@ -679,7 +719,7 @@ test "1: a compiled hosts fixture loads into a snapshot that blocks its domains"
|
||||
const text = try hostsFixture(gpa);
|
||||
defer gpa.free(text);
|
||||
|
||||
const result = try compileToFiles(gpa, io, tmp.dir, "1", text);
|
||||
const result = try compileToFiles(gpa, io, tmp.dir, "1", text, .hosts);
|
||||
try testing.expectEqual(@as(u32, fixture_domains), result.counts.domains);
|
||||
try testing.expectEqual(@as(u32, 1), result.counts.skipped_regex);
|
||||
// The three single-label names are the only invalid candidates here.
|
||||
@@ -692,7 +732,7 @@ test "1: a compiled hosts fixture loads into a snapshot that blocks its domains"
|
||||
try testing.expect(std.mem.find(u8, bodies.list, bare) == null);
|
||||
}
|
||||
|
||||
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild);
|
||||
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild, bodies.allow);
|
||||
defer snapshot.deinit();
|
||||
const group = snapshot.groupIndexByName("default").?;
|
||||
|
||||
@@ -732,8 +772,8 @@ test "2: recompiling the same fixture produces byte-identical files and checksum
|
||||
var second_dir = try tmp.dir.createDirPathOpen(io, "second", .{});
|
||||
defer second_dir.close(io);
|
||||
|
||||
const first = try compileToFiles(gpa, io, first_dir, "1", text);
|
||||
const second = try compileToFiles(gpa, io, second_dir, "1", text);
|
||||
const first = try compileToFiles(gpa, io, first_dir, "1", text, .hosts);
|
||||
const second = try compileToFiles(gpa, io, second_dir, "1", text, .hosts);
|
||||
|
||||
try testing.expectEqualStrings(&first.checksum, &second.checksum);
|
||||
try testing.expectEqual(first.counts, second.counts);
|
||||
@@ -745,11 +785,12 @@ test "2: recompiling the same fixture produces byte-identical files and checksum
|
||||
|
||||
try testing.expectEqualSlices(u8, first_bodies.list, second_bodies.list);
|
||||
try testing.expectEqualSlices(u8, first_bodies.wild, second_bodies.wild);
|
||||
try testing.expectEqualSlices(u8, first_bodies.allow, second_bodies.allow);
|
||||
|
||||
// The checksum the compiler reported is the one over the two bodies it
|
||||
// The checksum the compiler reported is the one over the three bodies it
|
||||
// wrote, which is what the manager stores and compares against.
|
||||
try testing.expectEqualStrings(
|
||||
&bodyChecksum(first_bodies.list, first_bodies.wild),
|
||||
&bodyChecksum(first_bodies.list, first_bodies.wild, first_bodies.allow),
|
||||
&first.checksum,
|
||||
);
|
||||
}
|
||||
@@ -781,8 +822,9 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 2,
|
||||
.wildcard_count = 1,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.checksum = &bodyChecksum(good_list, good_wild),
|
||||
.checksum = &bodyChecksum(good_list, good_wild, ""),
|
||||
});
|
||||
|
||||
try env.mgr.reload(io);
|
||||
@@ -812,8 +854,9 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 2,
|
||||
.wildcard_count = 1,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.checksum = &bodyChecksum(unsorted_list, good_wild),
|
||||
.checksum = &bodyChecksum(unsorted_list, good_wild, ""),
|
||||
});
|
||||
|
||||
try testing.expectError(error.NotSorted, env.mgr.reload(io));
|
||||
@@ -1028,6 +1071,7 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
|
||||
.last_updated = 1_000,
|
||||
.domain_count = http_domains,
|
||||
.wildcard_count = http_wildcards,
|
||||
.exception_count = http_exceptions,
|
||||
.skipped_regex_count = http_regex,
|
||||
.checksum = blk: {
|
||||
var rows = try listRows(&env.database);
|
||||
@@ -1069,6 +1113,9 @@ const ReloadTask = struct {
|
||||
/// Writes one source's compiled files and records their checksum, without any
|
||||
/// network: the swap and the orphan sweep care about files and rows, not about
|
||||
/// where the bytes came from.
|
||||
///
|
||||
/// All three files, including an empty `.allow`, because that is what a publish
|
||||
/// leaves: `publishOne` runs once per body and never skips the empty one.
|
||||
fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []const u8) !void {
|
||||
const io = env.io();
|
||||
var dir = try env.blocklistDir();
|
||||
@@ -1076,6 +1123,7 @@ fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []c
|
||||
|
||||
var list_name_buf: [64]u8 = undefined;
|
||||
var wild_name_buf: [64]u8 = undefined;
|
||||
var allow_name_buf: [64]u8 = undefined;
|
||||
try dir.writeFile(io, .{
|
||||
.sub_path = try std.fmt.bufPrint(&list_name_buf, "{d}.list", .{id}),
|
||||
.data = list_body,
|
||||
@@ -1084,13 +1132,18 @@ fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []c
|
||||
.sub_path = try std.fmt.bufPrint(&wild_name_buf, "{d}.wild", .{id}),
|
||||
.data = wild_body,
|
||||
});
|
||||
try dir.writeFile(io, .{
|
||||
.sub_path = try std.fmt.bufPrint(&allow_name_buf, "{d}.allow", .{id}),
|
||||
.data = "",
|
||||
});
|
||||
|
||||
try sources_repo.updateSourceStats(&env.database, id, .{
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 1,
|
||||
.wildcard_count = 0,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.checksum = &bodyChecksum(list_body, wild_body),
|
||||
.checksum = &bodyChecksum(list_body, wild_body, ""),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1146,14 +1199,21 @@ test "10: pruneOrphans deletes the files of a deleted source and leaves live one
|
||||
|
||||
var dir = try env.blocklistDir();
|
||||
defer dir.close(io);
|
||||
// Every name a source can own, spelled out: this fixture is what decides
|
||||
// whether the sweep covers the whole set, so it enumerates
|
||||
// `manager.source_file_suffixes` by hand rather than sharing it. A suffix
|
||||
// added to the manager and not added here is swept by nothing and asserted
|
||||
// by nothing.
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.list", .data = "gone.example.com\n" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.wild", .data = "" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.allow", .data = "lifted.example.com\n" });
|
||||
// Id 9999 has no `blocklist_sources` row, so no refresh can be writing for
|
||||
// it: these temporaries are what a refresh that died mid-write leaves
|
||||
// behind, and the sweep is the only thing that will ever remove them.
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.raw.tmp", .data = "" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.list.tmp", .data = "" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.wild.tmp", .data = "" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });
|
||||
|
||||
// The live source does have a row, so its temporary is a refresh in
|
||||
// progress and must survive a sweep that runs beside it.
|
||||
@@ -1165,12 +1225,15 @@ test "10: pruneOrphans deletes the files of a deleted source and leaves live one
|
||||
|
||||
var live_buf: [64]u8 = undefined;
|
||||
try dir.access(io, try std.fmt.bufPrint(&live_buf, "{d}.list", .{id}), .{});
|
||||
try dir.access(io, try std.fmt.bufPrint(&live_buf, "{d}.allow", .{id}), .{});
|
||||
try dir.access(io, live_tmp, .{});
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.raw.tmp", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list.tmp", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild.tmp", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow.tmp", .{}));
|
||||
}
|
||||
|
||||
test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
@@ -1190,8 +1253,9 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
.last_updated = std.Io.Clock.real.now(io).toSeconds(),
|
||||
.domain_count = 1,
|
||||
.wildcard_count = 0,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.checksum = &bodyChecksum(list_body, ""),
|
||||
.checksum = &bodyChecksum(list_body, "", ""),
|
||||
});
|
||||
|
||||
var dir = try env.blocklistDir();
|
||||
@@ -1201,7 +1265,9 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
// either.
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.list", .data = "gone.example.com\n" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.wild", .data = "" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.allow", .data = "" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.raw.tmp", .data = "" });
|
||||
try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });
|
||||
|
||||
// `runScheduler` is the entry point `app.zig` hands to `Io.Group`, and the
|
||||
// only one the server ever calls. A disabled update stops it after the
|
||||
@@ -1212,7 +1278,9 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
|
||||
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.raw.tmp", .{}));
|
||||
try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow.tmp", .{}));
|
||||
|
||||
// The live source kept its files and is still filtering: the sweep did not
|
||||
// take the snapshot the same pass had just published.
|
||||
@@ -1376,6 +1444,7 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi
|
||||
try testing.expect(!std.mem.endsWith(u8, entry.name, ".tmp"));
|
||||
try testing.expect(!std.mem.endsWith(u8, entry.name, ".list"));
|
||||
try testing.expect(!std.mem.endsWith(u8, entry.name, ".wild"));
|
||||
try testing.expect(!std.mem.endsWith(u8, entry.name, ".allow"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1410,10 +1479,13 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
||||
defer dir.close(io);
|
||||
var list_buf: [64]u8 = undefined;
|
||||
var wild_buf: [64]u8 = undefined;
|
||||
var allow_buf: [64]u8 = undefined;
|
||||
const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
|
||||
const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{id});
|
||||
const allow_name = try std.fmt.bufPrint(&allow_buf, "{d}.allow", .{id});
|
||||
const list_before = try dir.statFile(io, list_name, .{});
|
||||
const wild_before = try dir.statFile(io, wild_name, .{});
|
||||
const allow_before = try dir.statFile(io, allow_name, .{});
|
||||
|
||||
// File mode, declaring exactly what the database already holds. The engine
|
||||
// has to recognise the source by its url and leave the row where it is:
|
||||
@@ -1459,14 +1531,19 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
|
||||
// decision the pass made rather than a connection it could not have opened.
|
||||
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
|
||||
|
||||
// The same two files: not recompiled, and not swept as orphans and written
|
||||
// back.
|
||||
// The same three files: not recompiled, and not swept as orphans and
|
||||
// written back. `.allow` is asserted with the other two because a restart
|
||||
// that rewrote only the exception body would otherwise leave this test
|
||||
// green while changing what the snapshot lets through.
|
||||
const list_after = try dir.statFile(io, list_name, .{});
|
||||
const wild_after = try dir.statFile(io, wild_name, .{});
|
||||
const allow_after = try dir.statFile(io, allow_name, .{});
|
||||
try testing.expectEqual(list_before.inode, list_after.inode);
|
||||
try testing.expectEqual(list_before.mtime, list_after.mtime);
|
||||
try testing.expectEqual(wild_before.inode, wild_after.inode);
|
||||
try testing.expectEqual(wild_before.mtime, wild_after.mtime);
|
||||
try testing.expectEqual(allow_before.inode, allow_after.inode);
|
||||
try testing.expectEqual(allow_before.mtime, allow_after.mtime);
|
||||
|
||||
// The row kept the id those files are named after, and the snapshot the
|
||||
// restart published is the one compiled from them.
|
||||
@@ -1788,12 +1865,12 @@ test "17: each blocking mode synthesizes the documented blocked reply" {
|
||||
|
||||
// The reply is synthesized for a name the compiled files actually block, so
|
||||
// this case covers the decision and the response together.
|
||||
const result = try compileToFiles(gpa, io, tmp.dir, "1", "0.0.0.0 ads.example.com\n");
|
||||
const result = try compileToFiles(gpa, io, tmp.dir, "1", "0.0.0.0 ads.example.com\n", .hosts);
|
||||
try testing.expectEqual(@as(u32, 1), result.counts.domains);
|
||||
|
||||
var bodies = try Bodies.read(gpa, io, tmp.dir, "1");
|
||||
defer bodies.deinit(gpa);
|
||||
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild);
|
||||
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild, bodies.allow);
|
||||
defer snapshot.deinit();
|
||||
|
||||
const group = snapshot.groupIndexByName("default").?;
|
||||
@@ -1909,3 +1986,187 @@ test "18: a body streamed in flushed parts survives the fetcher's multi-read pum
|
||||
try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 19–20: list exceptions (milestone 21)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A real ABP list: one domain anchor, the exception that lifts one subtree out
|
||||
/// of it in each of the two accepted spellings, and two `@@` forms nxdns does
|
||||
/// not honour.
|
||||
const abp_exception_body =
|
||||
"[Adblock Plus 2.0]\n" ++
|
||||
"! Title: exceptions\n" ++
|
||||
"||ads.example^\n" ++
|
||||
"||tracker.example^\n" ++
|
||||
"@@||good.ads.example^\n" ++
|
||||
"@@||fine.tracker.example$important\n" ++
|
||||
"@@||paid.ads.example^$third-party\n" ++
|
||||
"@@partial.ads.example\n";
|
||||
|
||||
const abp_exception_domains: i64 = 2;
|
||||
const abp_exception_wildcards: i64 = 2;
|
||||
const abp_exception_exceptions: i64 = 2;
|
||||
|
||||
test "19: a downloaded list's exceptions lift its own blocks and nothing else" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var fixture = try HttpFixture.init(io, abp_exception_body);
|
||||
defer fixture.deinit(io);
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try fixture.url(&url_buf);
|
||||
const id = try seedSource(&env.database, url);
|
||||
|
||||
try testing.expect(try refreshOnce(env, url));
|
||||
try env.mgr.reload(io);
|
||||
|
||||
// The `.allow` body is a third file beside the two, and the row and the
|
||||
// status entry both carry its count — which is what a
|
||||
// `POST /api/blocklists/update` response row reports as `exceptions`.
|
||||
var dir = try env.blocklistDir();
|
||||
defer dir.close(io);
|
||||
var bodies = try Bodies.read(gpa, io, dir, "1");
|
||||
defer bodies.deinit(gpa);
|
||||
try testing.expectEqualStrings(
|
||||
"fine.tracker.example\ngood.ads.example\n",
|
||||
manager.stripHeader(bodies.allow),
|
||||
);
|
||||
|
||||
var rows = try listRows(&env.database);
|
||||
defer rows.deinit();
|
||||
const row = try rows.byUrl(url);
|
||||
try testing.expectEqual(abp_exception_domains, row.domain_count);
|
||||
try testing.expectEqual(abp_exception_wildcards, row.wildcard_count);
|
||||
try testing.expectEqual(abp_exception_exceptions, row.exception_count);
|
||||
|
||||
const status = try env.status(id);
|
||||
try testing.expectEqual(manager.State.ok, status.state);
|
||||
try testing.expect(status.loaded);
|
||||
try testing.expectEqual(@as(u32, @intCast(abp_exception_exceptions)), status.counts.exceptions);
|
||||
|
||||
// The blocks the list makes still land, apex and subdomain alike.
|
||||
for ([_][]const u8{ "ads.example", "x.ads.example", "paid.ads.example", "partial.ads.example" }) |blocked| {
|
||||
const decision, _ = try env.evaluate(blocked);
|
||||
try testing.expect(decision.blocked);
|
||||
}
|
||||
|
||||
// The two exceptions lift the excepted name and everything under it.
|
||||
for ([_][]const u8{
|
||||
"good.ads.example",
|
||||
"y.good.ads.example",
|
||||
"fine.tracker.example",
|
||||
"z.fine.tracker.example",
|
||||
}) |lifted| {
|
||||
const decision, _ = try env.evaluate(lifted);
|
||||
try testing.expect(!decision.blocked);
|
||||
try testing.expectEqual(matcher.Reason.blocklist_exception, decision.reason);
|
||||
try testing.expectEqual(@as(?u32, 0), decision.source);
|
||||
}
|
||||
|
||||
// What `/api/lookup` answers, through the same function the handler calls:
|
||||
// the reason names the exception and the source id names the list.
|
||||
{
|
||||
const handle = env.mgr.acquire(io) orelse return error.TestNoSnapshot;
|
||||
defer handle.release(io);
|
||||
const group_index = handle.snapshot.groupIndexByName("default") orelse
|
||||
return error.TestGroupMissing;
|
||||
|
||||
const result = lookup.evaluate(
|
||||
handle.snapshot,
|
||||
group_index,
|
||||
"y.good.ads.example",
|
||||
&empty_records,
|
||||
&empty_zones,
|
||||
);
|
||||
try testing.expect(!result.blocked);
|
||||
try testing.expectEqual(matcher.Reason.blocklist_exception, result.reason);
|
||||
try testing.expectEqualStrings("good.ads.example", result.matched);
|
||||
try testing.expectEqual(@as(?i64, id), result.source_id);
|
||||
|
||||
const rendered = lookup.body("y.good.ads.example", result, url);
|
||||
try testing.expectEqualStrings("blocklist_exception", rendered.reason);
|
||||
try testing.expectEqualStrings(url, rendered.source_url.?);
|
||||
}
|
||||
|
||||
// An operator block rule outranks the list's exception: a downloaded list
|
||||
// may cancel what a list decided and never what the operator decided.
|
||||
const group_id = (try groups_repo.groupId(&env.database, "default")) orelse
|
||||
return error.TestGroupMissing;
|
||||
_ = try rules_repo.insertRuleRow(&env.database, .{
|
||||
.group_id = group_id,
|
||||
.pattern = "good.ads.example",
|
||||
.kind = .exact,
|
||||
.action = .block,
|
||||
}, 1_700_000_000);
|
||||
try env.mgr.reload(io);
|
||||
{
|
||||
const decision, _ = try env.evaluate("good.ads.example");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expectEqual(matcher.Reason.rule_block_exact, decision.reason);
|
||||
}
|
||||
}
|
||||
|
||||
test "20: a data directory written before exceptions existed loads with no checksum mismatch" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
const env = try Env.create(gpa);
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
const id = try seedSource(&env.database, source_url);
|
||||
|
||||
// Exactly what an older nxdns left behind: two compiled files, no `.allow`
|
||||
// file, and a checksum taken over the two bodies alone.
|
||||
const list_body = "aaa.example.com\nbbb.example.com\n";
|
||||
const wild_body = "ccc.example.com\n";
|
||||
|
||||
var dir = try env.blocklistDir();
|
||||
defer dir.close(io);
|
||||
var list_name_buf: [64]u8 = undefined;
|
||||
var wild_name_buf: [64]u8 = undefined;
|
||||
try dir.writeFile(io, .{
|
||||
.sub_path = try std.fmt.bufPrint(&list_name_buf, "{d}.list", .{id}),
|
||||
.data = list_body,
|
||||
});
|
||||
try dir.writeFile(io, .{
|
||||
.sub_path = try std.fmt.bufPrint(&wild_name_buf, "{d}.wild", .{id}),
|
||||
.data = wild_body,
|
||||
});
|
||||
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update(list_body);
|
||||
hasher.update(wild_body);
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
const legacy_checksum = std.fmt.bytesToHex(digest, .lower);
|
||||
|
||||
try sources_repo.updateSourceStats(&env.database, id, .{
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 2,
|
||||
.wildcard_count = 1,
|
||||
.exception_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.checksum = &legacy_checksum,
|
||||
});
|
||||
|
||||
try env.mgr.reload(io);
|
||||
|
||||
const status = try env.status(id);
|
||||
try testing.expectEqual(manager.State.ok, status.state);
|
||||
try testing.expect(status.loaded);
|
||||
try testing.expectEqualStrings("", status.errorText());
|
||||
|
||||
const decision, _ = try env.evaluate("aaa.example.com");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expect((try env.evaluate("x.ccc.example.com"))[0].blocked);
|
||||
}
|
||||
|
||||
+219
-69
@@ -38,9 +38,10 @@
|
||||
//! publish: the download of one source, at up to 300 s each, and the compile
|
||||
//! that follows it. It also covers blocklist-directory maintenance, because
|
||||
//! those stages are the only writers of `.raw.tmp` / `.list.tmp` /
|
||||
//! `.wild.tmp` and `pruneOrphans` must not sweep the temporaries of a refresh
|
||||
//! that is still running. Two concurrent refreshes would share the fetcher's
|
||||
//! buffers and, for one source, the same temporary paths.
|
||||
//! `.wild.tmp` / `.allow.tmp` and `pruneOrphans` must not sweep the
|
||||
//! temporaries of a refresh that is still running. Two concurrent refreshes
|
||||
//! would share the fetcher's buffers and, for one source, the same temporary
|
||||
//! paths.
|
||||
//!
|
||||
//! **Lock ordering: `refresh_lock` is never acquired while `writer_lock` is
|
||||
//! held.** A path that needs both takes `refresh_lock` first. The public entry
|
||||
@@ -94,7 +95,7 @@ const io_buf_len: usize = 64 * 1024;
|
||||
/// would then be decided by almost no data.
|
||||
const sample_buf_len: usize = parsers.sample_lines * (compiler.max_line_len + 1);
|
||||
|
||||
/// `<id>` is at most 20 characters and the longest suffix is `.list.tmp`.
|
||||
/// `<id>` is at most 20 characters and the longest suffix is `.allow.tmp`.
|
||||
const name_buf_len: usize = 48;
|
||||
|
||||
/// How one blocklist source is named in a log line: by its row id and its name,
|
||||
@@ -215,9 +216,13 @@ pub const SourceStatus = struct {
|
||||
};
|
||||
|
||||
/// The header every compiled file carries, ahead of the body. The `sha256`
|
||||
/// covers the `.list` body followed by the `.wild` body and **not** the header,
|
||||
/// so it stays stable across a refetch of unchanged content while
|
||||
/// `fetched_at` moves.
|
||||
/// covers the `.list` body, then the `.wild` body, then the `.allow` body, and
|
||||
/// **not** the header, so it stays stable across a refetch of unchanged content
|
||||
/// while `fetched_at` moves.
|
||||
///
|
||||
/// The `.allow` body is hashed last so that a source with no exceptions keeps
|
||||
/// the digest it had when only two bodies existed: every checksum written before
|
||||
/// exceptions were honoured stays valid, and no upgrade forces a refetch.
|
||||
pub const Header = struct {
|
||||
url: []const u8,
|
||||
format: parsers.Format,
|
||||
@@ -233,6 +238,7 @@ pub const Header = struct {
|
||||
try w.print("# fetched_at {d}\n", .{self.fetched_at});
|
||||
try w.print("# domains {d}\n", .{self.counts.domains});
|
||||
try w.print("# wildcards {d}\n", .{self.counts.wildcards});
|
||||
try w.print("# exceptions {d}\n", .{self.counts.exceptions});
|
||||
try w.print("# skipped_regex {d}\n", .{self.counts.skipped_regex});
|
||||
try w.print("# skipped_unsupported {d}\n", .{self.counts.skipped_unsupported});
|
||||
try w.print("# invalid {d}\n", .{self.counts.invalid});
|
||||
@@ -557,12 +563,14 @@ pub const Manager = struct {
|
||||
|
||||
var list_buf: [name_buf_len]u8 = undefined;
|
||||
var wild_buf: [name_buf_len]u8 = undefined;
|
||||
var allow_buf: [name_buf_len]u8 = undefined;
|
||||
const list_name = compiledName(&list_buf, row.id, ".list");
|
||||
const wild_name = compiledName(&wild_buf, row.id, ".wild");
|
||||
const allow_name = compiledName(&allow_buf, row.id, ".allow");
|
||||
|
||||
// Reserved before the reads, so neither buffer can be orphaned by a
|
||||
// failing append: `bodies` owns each one from the moment it is read.
|
||||
try bodies.ensureUnusedCapacity(self.gpa, 2);
|
||||
// Reserved before the reads, so no buffer can be orphaned by a failing
|
||||
// append: `bodies` owns each one from the moment it is read.
|
||||
try bodies.ensureUnusedCapacity(self.gpa, 3);
|
||||
|
||||
// `error.Canceled` is the one-shot signal that this task is being torn
|
||||
// down, and it is consumed by whoever catches it. Recording it as a
|
||||
@@ -583,18 +591,39 @@ pub const Manager = struct {
|
||||
};
|
||||
bodies.appendAssumeCapacity(wild_bytes);
|
||||
|
||||
// A missing `.allow` file is an empty allow body, not a failure. Two
|
||||
// sources are in that state and both are ordinary: one compiled before
|
||||
// exceptions were honoured, and one whose list carries no `@@` line.
|
||||
// Because the empty body contributes nothing to the checksum, the
|
||||
// stored digest of either still matches.
|
||||
const allow_bytes: []const u8 = blk: {
|
||||
const read = dir.readFileAlloc(io, allow_name, self.gpa, .limited(max_compiled_bytes)) catch |err| {
|
||||
if (err == error.OutOfMemory) return error.OutOfMemory;
|
||||
if (err == error.Canceled) return error.Canceled;
|
||||
if (err == error.FileNotFound) break :blk "";
|
||||
return loadFailure(row, allow_name, err);
|
||||
};
|
||||
bodies.appendAssumeCapacity(read);
|
||||
break :blk read;
|
||||
};
|
||||
|
||||
const list_body = stripHeader(list_bytes);
|
||||
const wild_body = stripHeader(wild_bytes);
|
||||
const allow_body = stripHeader(allow_bytes);
|
||||
|
||||
// The checksum covers both bodies together, so a crash between the two
|
||||
// The checksum covers the three bodies together, so a crash between the
|
||||
// `replace` calls — a new `.list` beside an old `.wild` — is caught
|
||||
// here and refreshed, not served as a half-updated list.
|
||||
if (!std.mem.eql(u8, stored, &bodyChecksum(list_body, wild_body))) {
|
||||
if (!std.mem.eql(u8, stored, &bodyChecksum(list_body, wild_body, allow_body))) {
|
||||
log.warn("blocklist {f}: compiled files do not match the stored checksum", .{SourceLabel.of(row)});
|
||||
return .{ .failed = .{ .state = .load_failed, .text = "ChecksumMismatch" } };
|
||||
}
|
||||
|
||||
return .{ .loaded = .{ .list_body = list_body, .wild_body = wild_body } };
|
||||
return .{ .loaded = .{
|
||||
.list_body = list_body,
|
||||
.wild_body = wild_body,
|
||||
.allow_body = allow_body,
|
||||
} };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -635,23 +664,28 @@ pub const Manager = struct {
|
||||
var raw_buf: [name_buf_len]u8 = undefined;
|
||||
var list_tmp_buf: [name_buf_len]u8 = undefined;
|
||||
var wild_tmp_buf: [name_buf_len]u8 = undefined;
|
||||
var allow_tmp_buf: [name_buf_len]u8 = undefined;
|
||||
const raw_name = compiledName(&raw_buf, row.id, ".raw.tmp");
|
||||
const list_tmp = compiledName(&list_tmp_buf, row.id, ".list.tmp");
|
||||
const wild_tmp = compiledName(&wild_tmp_buf, row.id, ".wild.tmp");
|
||||
const tmp: TempNames = .{
|
||||
.list = compiledName(&list_tmp_buf, row.id, ".list.tmp"),
|
||||
.wild = compiledName(&wild_tmp_buf, row.id, ".wild.tmp"),
|
||||
.allow = compiledName(&allow_tmp_buf, row.id, ".allow.tmp"),
|
||||
};
|
||||
|
||||
// Installed before the calls that create these files, not after: an
|
||||
// `error.Canceled` or `error.OutOfMemory` returned straight out of
|
||||
// `download` or `compileTo` would outrun a later `defer` and leave a
|
||||
// temporary behind. Deleting a name that was never created is a no-op.
|
||||
defer self.deleteQuietly(io, dir, raw_name);
|
||||
defer self.deleteQuietly(io, dir, list_tmp);
|
||||
defer self.deleteQuietly(io, dir, wild_tmp);
|
||||
defer self.deleteQuietly(io, dir, tmp.list);
|
||||
defer self.deleteQuietly(io, dir, tmp.wild);
|
||||
defer self.deleteQuietly(io, dir, tmp.allow);
|
||||
|
||||
// The half that takes the time: one download of up to `total_budget`
|
||||
// and one compile of everything it returned. `refresh_lock` alone is
|
||||
// held here, so a rule save, a settings change or any other web
|
||||
// mutation that ends in `reload` runs beside it instead of behind it.
|
||||
const prepared = try self.prepareRefresh(io, dir, row, &status, raw_name, list_tmp, wild_tmp);
|
||||
const prepared = try self.prepareRefresh(io, dir, row, &status, raw_name, tmp);
|
||||
|
||||
// The half that publishes. The compiled files, the runtime columns and
|
||||
// the status entry land under one `writer_lock`, so a reload never
|
||||
@@ -659,7 +693,7 @@ pub const Manager = struct {
|
||||
self.writer_lock.lockUncancelable(io);
|
||||
defer self.writer_lock.unlock(io);
|
||||
|
||||
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, list_tmp, wild_tmp);
|
||||
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, tmp);
|
||||
self.commitStatus(io, status);
|
||||
return replaced;
|
||||
}
|
||||
@@ -690,6 +724,14 @@ pub const Manager = struct {
|
||||
return self.reload(io);
|
||||
}
|
||||
|
||||
/// The three temporary files one refresh compiles into, before the header
|
||||
/// is prepended and each is renamed over the file it replaces.
|
||||
const TempNames = struct {
|
||||
list: []const u8,
|
||||
wild: []const u8,
|
||||
allow: []const u8,
|
||||
};
|
||||
|
||||
/// What the fetch-and-compile half of a refresh produced. `.failed` needs
|
||||
/// no publish and has already recorded why in the status entry.
|
||||
const Prepared = union(enum) {
|
||||
@@ -712,8 +754,7 @@ pub const Manager = struct {
|
||||
row: sources_repo.SourceRow,
|
||||
status: *SourceStatus,
|
||||
raw_name: []const u8,
|
||||
list_tmp: []const u8,
|
||||
wild_tmp: []const u8,
|
||||
tmp: TempNames,
|
||||
) Error!Prepared {
|
||||
self.download(io, dir, raw_name, row) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
@@ -733,7 +774,7 @@ pub const Manager = struct {
|
||||
},
|
||||
};
|
||||
|
||||
const result = self.compileTo(io, dir, raw_name, format, list_tmp, wild_tmp) catch |err| switch (err) {
|
||||
const result = self.compileTo(io, dir, raw_name, format, tmp) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Canceled => return error.Canceled,
|
||||
else => {
|
||||
@@ -762,8 +803,7 @@ pub const Manager = struct {
|
||||
row: sources_repo.SourceRow,
|
||||
status: *SourceStatus,
|
||||
prepared: Prepared,
|
||||
list_tmp: []const u8,
|
||||
wild_tmp: []const u8,
|
||||
tmp: TempNames,
|
||||
) Error!bool {
|
||||
const compiled = switch (prepared) {
|
||||
.failed => return false,
|
||||
@@ -786,6 +826,7 @@ pub const Manager = struct {
|
||||
.last_updated = now,
|
||||
.domain_count = row.domain_count,
|
||||
.wildcard_count = row.wildcard_count,
|
||||
.exception_count = row.exception_count,
|
||||
.skipped_regex_count = row.skipped_regex_count,
|
||||
.checksum = stored,
|
||||
});
|
||||
@@ -801,7 +842,7 @@ pub const Manager = struct {
|
||||
.counts = compiled.result.counts,
|
||||
.checksum = &compiled.result.checksum,
|
||||
};
|
||||
self.publish(io, dir, row.id, header, list_tmp, wild_tmp) catch |err| switch (err) {
|
||||
self.publish(io, dir, row.id, header, tmp) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Canceled => return error.Canceled,
|
||||
else => {
|
||||
@@ -814,6 +855,7 @@ pub const Manager = struct {
|
||||
.last_updated = now,
|
||||
.domain_count = compiled.result.counts.domains,
|
||||
.wildcard_count = compiled.result.counts.wildcards,
|
||||
.exception_count = compiled.result.counts.exceptions,
|
||||
.skipped_regex_count = compiled.result.counts.skipped_regex,
|
||||
.checksum = &compiled.result.checksum,
|
||||
});
|
||||
@@ -913,7 +955,7 @@ pub const Manager = struct {
|
||||
return parsers.detectFormat(sample.buffered());
|
||||
}
|
||||
|
||||
/// Compiles into two plain temporary files. The compiled bodies cannot go
|
||||
/// Compiles into three plain temporary files. The compiled bodies cannot go
|
||||
/// straight into the final files: the header carries counts that only exist
|
||||
/// once the whole input has been compiled, and the loader requires the
|
||||
/// header first.
|
||||
@@ -923,22 +965,24 @@ pub const Manager = struct {
|
||||
dir: std.Io.Dir,
|
||||
raw_name: []const u8,
|
||||
format: parsers.Format,
|
||||
list_tmp: []const u8,
|
||||
wild_tmp: []const u8,
|
||||
tmp: TempNames,
|
||||
) !compiler.Result {
|
||||
const raw = try dir.openFile(io, raw_name, .{});
|
||||
defer raw.close(io);
|
||||
const list_file = try dir.createFile(io, list_tmp, .{ .permissions = .fromMode(0o600) });
|
||||
const list_file = try dir.createFile(io, tmp.list, .{ .permissions = .fromMode(0o600) });
|
||||
defer list_file.close(io);
|
||||
const wild_file = try dir.createFile(io, wild_tmp, .{ .permissions = .fromMode(0o600) });
|
||||
const wild_file = try dir.createFile(io, tmp.wild, .{ .permissions = .fromMode(0o600) });
|
||||
defer wild_file.close(io);
|
||||
const allow_file = try dir.createFile(io, tmp.allow, .{ .permissions = .fromMode(0o600) });
|
||||
defer allow_file.close(io);
|
||||
|
||||
const buffers = try self.gpa.alloc(u8, 3 * io_buf_len);
|
||||
const buffers = try self.gpa.alloc(u8, 4 * io_buf_len);
|
||||
defer self.gpa.free(buffers);
|
||||
|
||||
var fr = raw.reader(io, buffers[0..io_buf_len]);
|
||||
var list_w = list_file.writer(io, buffers[io_buf_len .. 2 * io_buf_len]);
|
||||
var wild_w = wild_file.writer(io, buffers[2 * io_buf_len ..]);
|
||||
var wild_w = wild_file.writer(io, buffers[2 * io_buf_len .. 3 * io_buf_len]);
|
||||
var allow_w = allow_file.writer(io, buffers[3 * io_buf_len ..]);
|
||||
|
||||
const result = compiler.compile(
|
||||
self.gpa,
|
||||
@@ -946,18 +990,22 @@ pub const Manager = struct {
|
||||
format,
|
||||
&list_w.interface,
|
||||
&wild_w.interface,
|
||||
&allow_w.interface,
|
||||
) catch |err| switch (err) {
|
||||
// `compiler.Error` names the direction; the concrete cause is on
|
||||
// the stream that failed.
|
||||
error.ReadFailed => return fr.err orelse err,
|
||||
error.WriteFailed => return list_w.err orelse (wild_w.err orelse err),
|
||||
error.WriteFailed => return list_w.err orelse
|
||||
(wild_w.err orelse (allow_w.err orelse err)),
|
||||
else => return err,
|
||||
};
|
||||
|
||||
try list_w.interface.flush();
|
||||
try wild_w.interface.flush();
|
||||
try allow_w.interface.flush();
|
||||
try list_file.sync(io);
|
||||
try wild_file.sync(io);
|
||||
try allow_file.sync(io);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -970,16 +1018,17 @@ pub const Manager = struct {
|
||||
dir: std.Io.Dir,
|
||||
id: i64,
|
||||
header: Header,
|
||||
list_tmp: []const u8,
|
||||
wild_tmp: []const u8,
|
||||
tmp: TempNames,
|
||||
) !void {
|
||||
const buffers = try self.gpa.alloc(u8, 2 * io_buf_len);
|
||||
defer self.gpa.free(buffers);
|
||||
|
||||
var list_buf: [name_buf_len]u8 = undefined;
|
||||
var wild_buf: [name_buf_len]u8 = undefined;
|
||||
try publishOne(io, dir, compiledName(&list_buf, id, ".list"), list_tmp, header, buffers);
|
||||
try publishOne(io, dir, compiledName(&wild_buf, id, ".wild"), wild_tmp, header, buffers);
|
||||
var allow_buf: [name_buf_len]u8 = undefined;
|
||||
try publishOne(io, dir, compiledName(&list_buf, id, ".list"), tmp.list, header, buffers);
|
||||
try publishOne(io, dir, compiledName(&wild_buf, id, ".wild"), tmp.wild, header, buffers);
|
||||
try publishOne(io, dir, compiledName(&allow_buf, id, ".allow"), tmp.allow, header, buffers);
|
||||
}
|
||||
|
||||
fn publishOne(
|
||||
@@ -1014,12 +1063,19 @@ pub const Manager = struct {
|
||||
try af.replace(io);
|
||||
}
|
||||
|
||||
/// Whether the two compiled files on disk hash to `expected`. A missing,
|
||||
/// Whether the compiled files on disk hash to `expected`. A missing,
|
||||
/// unreadable or corrupt file answers false, which sends the caller down
|
||||
/// the rewrite path — the only path that can repair it.
|
||||
///
|
||||
/// A missing `.allow` file is the one exception, and it is the same one
|
||||
/// `loadSource` makes: a source compiled before exceptions were honoured has
|
||||
/// no such file, and its stored checksum was taken over an empty allow body.
|
||||
/// Answering false there would rewrite every list on the first refresh after
|
||||
/// an upgrade for no change in content.
|
||||
fn diskBodiesMatch(self: *Manager, io: std.Io, dir: std.Io.Dir, id: i64, expected: []const u8) bool {
|
||||
var list_buf: [name_buf_len]u8 = undefined;
|
||||
var wild_buf: [name_buf_len]u8 = undefined;
|
||||
var allow_buf: [name_buf_len]u8 = undefined;
|
||||
const limit: std.Io.Limit = .limited(max_compiled_bytes);
|
||||
|
||||
const list_bytes = dir.readFileAlloc(io, compiledName(&list_buf, id, ".list"), self.gpa, limit) catch
|
||||
@@ -1029,7 +1085,11 @@ pub const Manager = struct {
|
||||
return false;
|
||||
defer self.gpa.free(wild_bytes);
|
||||
|
||||
return compiledBodiesMatch(list_bytes, wild_bytes, expected);
|
||||
const allow_bytes = dir.readFileAlloc(io, compiledName(&allow_buf, id, ".allow"), self.gpa, limit) catch |err|
|
||||
if (err == error.FileNotFound) @as([]u8, &.{}) else return false;
|
||||
defer self.gpa.free(allow_bytes);
|
||||
|
||||
return compiledBodiesMatch(list_bytes, wild_bytes, allow_bytes, expected);
|
||||
}
|
||||
|
||||
fn reportFetchFailure(
|
||||
@@ -1231,12 +1291,12 @@ pub const Manager = struct {
|
||||
/// sweeps to nothing.
|
||||
pub fn pruneOrphans(self: *Manager, io: std.Io) Error!void {
|
||||
// `refresh_lock` first, and for the reason it exists: the download and
|
||||
// the compile are the only writers of `.raw.tmp`, `.list.tmp` and
|
||||
// `.wild.tmp`, and they hold it for as long as they run. Without it
|
||||
// here, a source deleted through the API would sweep the temporaries of
|
||||
// a refresh still writing them — the row is gone, so nothing else in
|
||||
// this function would spare them — and the pass would fail on a raw
|
||||
// file that vanished under it.
|
||||
// the compile are the only writers of `.raw.tmp`, `.list.tmp`,
|
||||
// `.wild.tmp` and `.allow.tmp`, and they hold it for as long as they
|
||||
// run. Without it here, a source deleted through the API would sweep
|
||||
// the temporaries of a refresh still writing them — the row is gone, so
|
||||
// nothing else in this function would spare them — and the pass would
|
||||
// fail on a raw file that vanished under it.
|
||||
//
|
||||
// `writer_lock` second, in the one order this file ever takes them,
|
||||
// because the rows this reads and the compiled files it deletes are
|
||||
@@ -1485,6 +1545,7 @@ fn applyLoadOutcomes(
|
||||
entry.succeed(row.last_updated orelse 0, .{
|
||||
.domains = countOf(row.domain_count),
|
||||
.wildcards = countOf(row.wildcard_count),
|
||||
.exceptions = countOf(row.exception_count),
|
||||
.skipped_regex = countOf(row.skipped_regex_count),
|
||||
});
|
||||
},
|
||||
@@ -1555,43 +1616,62 @@ fn collectSample(r: *std.Io.Reader, w: *std.Io.Writer) error{ ReadFailed, WriteF
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two compiled files carry the bodies `expected` was taken over.
|
||||
fn compiledBodiesMatch(list_bytes: []const u8, wild_bytes: []const u8, expected: []const u8) bool {
|
||||
return std.mem.eql(u8, expected, &bodyChecksum(stripHeader(list_bytes), stripHeader(wild_bytes)));
|
||||
/// Whether three compiled files carry the bodies `expected` was taken over.
|
||||
fn compiledBodiesMatch(
|
||||
list_bytes: []const u8,
|
||||
wild_bytes: []const u8,
|
||||
allow_bytes: []const u8,
|
||||
expected: []const u8,
|
||||
) bool {
|
||||
return std.mem.eql(u8, expected, &bodyChecksum(
|
||||
stripHeader(list_bytes),
|
||||
stripHeader(wild_bytes),
|
||||
stripHeader(allow_bytes),
|
||||
));
|
||||
}
|
||||
|
||||
/// A compile that produced no entry at all while rejecting lines is an error
|
||||
/// page, a compressed body or a format the sniff got wrong — not a blocklist.
|
||||
/// Publishing it would replace a working list with nothing and report `ok`. An
|
||||
/// input that rejected nothing is an empty list, which is legal.
|
||||
///
|
||||
/// A list of nothing but exceptions is loadable: an allow-only list published
|
||||
/// beside a blocking one is a shape operators use, and it produces entries.
|
||||
fn rejectedWithoutEntries(counts: compiler.Counts) bool {
|
||||
if (counts.domains != 0 or counts.wildcards != 0) return false;
|
||||
if (counts.domains != 0 or counts.wildcards != 0 or counts.exceptions != 0) return false;
|
||||
return counts.invalid != 0 or counts.skipped_unsupported != 0 or counts.long_lines != 0;
|
||||
}
|
||||
|
||||
fn bodyChecksum(list_body: []const u8, wild_body: []const u8) [64]u8 {
|
||||
/// The digest the `.list`, `.wild` and `.allow` bodies share, in that order.
|
||||
/// The allow body comes last so that hashing an empty one leaves the digest of
|
||||
/// the two-body form untouched, which is what keeps every checksum stored before
|
||||
/// exceptions were honoured valid.
|
||||
fn bodyChecksum(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) [64]u8 {
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update(list_body);
|
||||
hasher.update(wild_body);
|
||||
hasher.update(allow_body);
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
return std.fmt.bytesToHex(digest, .lower);
|
||||
}
|
||||
|
||||
fn compiledName(buf: *[name_buf_len]u8, id: i64, suffix: []const u8) []const u8 {
|
||||
// An `i64` prints in at most 20 characters and the longest suffix is nine,
|
||||
// An `i64` prints in at most 20 characters and the longest suffix is ten,
|
||||
// so `name_buf_len` cannot be exceeded.
|
||||
return std.fmt.bufPrint(buf, "{d}{s}", .{ id, suffix }) catch unreachable;
|
||||
}
|
||||
|
||||
/// Every name `compiledName` can produce, longest suffix first so `.list.tmp`
|
||||
/// is never read as `.list`.
|
||||
const source_file_suffixes = [_][]const u8{ ".list.tmp", ".wild.tmp", ".raw.tmp", ".list", ".wild" };
|
||||
const source_file_suffixes = [_][]const u8{
|
||||
".allow.tmp", ".list.tmp", ".wild.tmp", ".raw.tmp", ".allow", ".list", ".wild",
|
||||
};
|
||||
|
||||
/// The source id a file under the blocklist directory belongs to, or null when
|
||||
/// the name is not one of ours.
|
||||
///
|
||||
/// The three temporaries count. A refresh that dies between writing one and
|
||||
/// The four temporaries count. A refresh that dies between writing one and
|
||||
/// renaming it leaves a file no later refresh reuses and no `defer` reaches, so
|
||||
/// excluding them from the sweep means nothing ever removes them. Matching them
|
||||
/// is safe because `pruneOrphans` holds `refresh_lock` for its whole body:
|
||||
@@ -1830,18 +1910,24 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
|
||||
|
||||
const list_body = "aaa.example.com\n";
|
||||
const wild_body = "";
|
||||
const allow_body = "";
|
||||
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
|
||||
defer dir.close(io);
|
||||
var list_buf: [name_buf_len]u8 = undefined;
|
||||
var wild_buf: [name_buf_len]u8 = undefined;
|
||||
var allow_buf: [name_buf_len]u8 = undefined;
|
||||
try dir.writeFile(io, .{ .sub_path = compiledName(&list_buf, id, ".list"), .data = list_body });
|
||||
try dir.writeFile(io, .{ .sub_path = compiledName(&wild_buf, id, ".wild"), .data = wild_body });
|
||||
// Present rather than absent, so the third read is a real one: `loadSource`
|
||||
// treats a missing `.allow` as an empty body and would never open it.
|
||||
try dir.writeFile(io, .{ .sub_path = compiledName(&allow_buf, id, ".allow"), .data = allow_body });
|
||||
try sources_repo.updateSourceStats(&database, id, .{
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 1,
|
||||
.wildcard_count = 0,
|
||||
.skipped_regex_count = 0,
|
||||
.checksum = &bodyChecksum(list_body, wild_body),
|
||||
.exception_count = 0,
|
||||
.checksum = &bodyChecksum(list_body, wild_body, allow_body),
|
||||
});
|
||||
|
||||
// The baseline every assertion below is against: one clean reload, one
|
||||
@@ -1853,11 +1939,12 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
|
||||
try testing.expect(out[0].loaded);
|
||||
const published = mgr.generation;
|
||||
|
||||
// Both catch sites, in the order `loadSource` reads the two files. A
|
||||
// Every catch site, in the order `loadSource` reads the three files. A
|
||||
// cancellation is consumed by whoever catches it, so folding it into a load
|
||||
// failure would spend the shutdown signal and leave a status row reading
|
||||
// "Canceled" behind.
|
||||
for ([_][]const u8{ ".list", ".wild" }) |suffix| {
|
||||
// "Canceled" behind. The `.allow` read is the one that can get this wrong
|
||||
// twice over: it also has to keep `FileNotFound` apart from a cancellation.
|
||||
for ([_][]const u8{ ".list", ".wild", ".allow" }) |suffix| {
|
||||
var vtable: std.Io.VTable = undefined;
|
||||
const canceling = cancelingIo(io, suffix, &vtable);
|
||||
try testing.expectError(error.Canceled, mgr.reload(canceling));
|
||||
@@ -1921,6 +2008,7 @@ test "the header writer produces the documented text" {
|
||||
.counts = .{
|
||||
.domains = 12,
|
||||
.wildcards = 3,
|
||||
.exceptions = 7,
|
||||
.skipped_regex = 2,
|
||||
.skipped_unsupported = 1,
|
||||
.invalid = 5,
|
||||
@@ -1938,6 +2026,7 @@ test "the header writer produces the documented text" {
|
||||
\\# fetched_at 1700000000
|
||||
\\# domains 12
|
||||
\\# wildcards 3
|
||||
\\# exceptions 7
|
||||
\\# skipped_regex 2
|
||||
\\# skipped_unsupported 1
|
||||
\\# invalid 5
|
||||
@@ -2042,32 +2131,51 @@ test "a success clears the recorded error" {
|
||||
try testing.expectEqualStrings("", status.errorText());
|
||||
}
|
||||
|
||||
test "compiledName spells the four file names of a source" {
|
||||
comptime {
|
||||
// The two tests below spell every suffix out instead of looping over
|
||||
// `source_file_suffixes`: a test that reads the table moves with it, so a
|
||||
// name dropped from the table would take the assertion that covers it along.
|
||||
// An eighth suffix breaks the build here until both are extended.
|
||||
std.debug.assert(source_file_suffixes.len == 7);
|
||||
}
|
||||
|
||||
test "compiledName spells every file name of a source" {
|
||||
var buf: [name_buf_len]u8 = undefined;
|
||||
try testing.expectEqualStrings("42.list", compiledName(&buf, 42, ".list"));
|
||||
try testing.expectEqualStrings("42.wild", compiledName(&buf, 42, ".wild"));
|
||||
try testing.expectEqualStrings("42.allow", compiledName(&buf, 42, ".allow"));
|
||||
try testing.expectEqualStrings("42.raw.tmp", compiledName(&buf, 42, ".raw.tmp"));
|
||||
try testing.expectEqualStrings("42.list.tmp", compiledName(&buf, 42, ".list.tmp"));
|
||||
try testing.expectEqualStrings("42.wild.tmp", compiledName(&buf, 42, ".wild.tmp"));
|
||||
try testing.expectEqualStrings("42.allow.tmp", compiledName(&buf, 42, ".allow.tmp"));
|
||||
}
|
||||
|
||||
test "sourceFileId matches every name a refresh writes, including the temporaries" {
|
||||
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.list"));
|
||||
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.wild"));
|
||||
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.allow"));
|
||||
|
||||
// A temporary left by a refresh that died belongs to its source id, so the
|
||||
// sweep can tell whether that source still has a row.
|
||||
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.raw.tmp"));
|
||||
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.list.tmp"));
|
||||
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.wild.tmp"));
|
||||
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.allow.tmp"));
|
||||
|
||||
try testing.expectEqual(@as(?i64, null), sourceFileId("notes.list"));
|
||||
try testing.expectEqual(@as(?i64, null), sourceFileId("notes.allow"));
|
||||
try testing.expectEqual(@as(?i64, null), sourceFileId("notes.raw.tmp"));
|
||||
try testing.expectEqual(@as(?i64, null), sourceFileId("notes.allow.tmp"));
|
||||
try testing.expectEqual(@as(?i64, null), sourceFileId("7.tmp"));
|
||||
try testing.expectEqual(@as(?i64, null), sourceFileId("7.raw"));
|
||||
try testing.expectEqual(@as(?i64, null), sourceFileId("7.allowed"));
|
||||
try testing.expectEqual(@as(?i64, null), sourceFileId("README"));
|
||||
}
|
||||
|
||||
test "every name compiledName writes is a name the sweep can attribute" {
|
||||
// A round-trip over the table, not a coverage check: this loop reads the
|
||||
// same array the code reads, so it cannot notice a missing entry. The two
|
||||
// tests above are what pins the set.
|
||||
var buf: [name_buf_len]u8 = undefined;
|
||||
for (source_file_suffixes) |suffix| {
|
||||
try testing.expectEqual(@as(?i64, 42), sourceFileId(compiledName(&buf, 42, suffix)));
|
||||
@@ -2113,6 +2221,7 @@ fn testRow(id: i64, enabled: bool) sources_repo.SourceRow {
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 9,
|
||||
.wildcard_count = 4,
|
||||
.exception_count = 2,
|
||||
.skipped_regex_count = 1,
|
||||
.checksum = "0" ** 64,
|
||||
};
|
||||
@@ -2260,17 +2369,49 @@ test "SourceStatus truncates a long url at max_url_len" {
|
||||
test "compiledBodiesMatch verifies the bodies, not the presence of the files" {
|
||||
const list_body = "a.example.com\nb.example.com\n";
|
||||
const wild_body = "c.example.com\n";
|
||||
const expected = bodyChecksum(list_body, wild_body);
|
||||
const allow_body = "d.example.com\n";
|
||||
const expected = bodyChecksum(list_body, wild_body, allow_body);
|
||||
|
||||
const header =
|
||||
"# nxdns blocklist\n" ++
|
||||
"# url https://lists.example/hosts.txt\n";
|
||||
try testing.expect(compiledBodiesMatch(header ++ list_body, header ++ wild_body, &expected));
|
||||
try testing.expect(compiledBodiesMatch(
|
||||
header ++ list_body,
|
||||
header ++ wild_body,
|
||||
header ++ allow_body,
|
||||
&expected,
|
||||
));
|
||||
|
||||
// The corruption a reload reports as `ChecksumMismatch`: the file is there,
|
||||
// its body is not what the checksum was taken over.
|
||||
try testing.expect(!compiledBodiesMatch(header ++ "a.example.com\nb.exa", header ++ wild_body, &expected));
|
||||
try testing.expect(!compiledBodiesMatch("", "", &expected));
|
||||
// its body is not what the checksum was taken over. An allow body that lost
|
||||
// its entry counts, because a dropped exception silently restores a block.
|
||||
try testing.expect(!compiledBodiesMatch(
|
||||
header ++ "a.example.com\nb.exa",
|
||||
header ++ wild_body,
|
||||
header ++ allow_body,
|
||||
&expected,
|
||||
));
|
||||
try testing.expect(!compiledBodiesMatch(header ++ list_body, header ++ wild_body, "", &expected));
|
||||
try testing.expect(!compiledBodiesMatch("", "", "", &expected));
|
||||
}
|
||||
|
||||
test "a source with no exceptions keeps the checksum it had before the allow body existed" {
|
||||
const list_body = "a.example.com\nb.example.com\n";
|
||||
const wild_body = "c.example.com\n";
|
||||
|
||||
// What an older nxdns stored: the digest of the two bodies alone. It is what
|
||||
// sits in `blocklist_sources.checksum` on every installation being upgraded,
|
||||
// and the files on disk are the two it was taken over.
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update(list_body);
|
||||
hasher.update(wild_body);
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
const stored = std.fmt.bytesToHex(digest, .lower);
|
||||
|
||||
try testing.expectEqualStrings(&stored, &bodyChecksum(list_body, wild_body, ""));
|
||||
// No `.allow` file: what `loadSource` and `diskBodiesMatch` pass for one.
|
||||
try testing.expect(compiledBodiesMatch(list_body, wild_body, "", &stored));
|
||||
}
|
||||
|
||||
test "rejectedWithoutEntries fails a compile that produced nothing usable" {
|
||||
@@ -2391,14 +2532,23 @@ test "collectSample steps over a line that does not fit the reader buffer" {
|
||||
try testing.expectEqualStrings("ads.example.com\n", w.buffered());
|
||||
}
|
||||
|
||||
test "bodyChecksum covers the list body followed by the wild body" {
|
||||
const both = bodyChecksum("a.example.com\n", "b.example.com\n");
|
||||
test "bodyChecksum covers the list body, then the wild body, then the allow body" {
|
||||
const all = bodyChecksum("a.example.com\n", "b.example.com\n", "c.example.com\n");
|
||||
var hasher = Sha256.init(.{});
|
||||
hasher.update("a.example.com\nb.example.com\n");
|
||||
hasher.update("a.example.com\nb.example.com\nc.example.com\n");
|
||||
var digest: [Sha256.digest_length]u8 = undefined;
|
||||
hasher.final(&digest);
|
||||
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &both);
|
||||
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &all);
|
||||
|
||||
// Order matters: the two halves are not interchangeable.
|
||||
try testing.expect(!std.mem.eql(u8, &both, &bodyChecksum("b.example.com\n", "a.example.com\n")));
|
||||
// Order matters: the three parts are not interchangeable.
|
||||
try testing.expect(!std.mem.eql(
|
||||
u8,
|
||||
&all,
|
||||
&bodyChecksum("b.example.com\n", "a.example.com\n", "c.example.com\n"),
|
||||
));
|
||||
try testing.expect(!std.mem.eql(
|
||||
u8,
|
||||
&all,
|
||||
&bodyChecksum("a.example.com\n", "c.example.com\n", "b.example.com\n"),
|
||||
));
|
||||
}
|
||||
|
||||
+348
-11
@@ -27,6 +27,11 @@ pub const Reason = enum {
|
||||
rule_block_exact,
|
||||
rule_allow_wildcard,
|
||||
rule_block_wildcard,
|
||||
rule_allow_regex,
|
||||
rule_block_regex,
|
||||
/// An `@@` exception from a downloaded list. It cancels what another list
|
||||
/// blocks and never what a rule decides — see `evaluate`.
|
||||
blocklist_exception,
|
||||
blocklist_domain,
|
||||
blocklist_wildcard,
|
||||
};
|
||||
@@ -34,12 +39,14 @@ pub const Reason = enum {
|
||||
pub const Decision = struct {
|
||||
blocked: bool,
|
||||
reason: Reason,
|
||||
/// The candidate (for the exact and blocklist levels) or the pattern (for
|
||||
/// the wildcard levels) that decided it. Borrowed from the caller's
|
||||
/// normalized buffer or from the snapshot. "" when `reason == .none`.
|
||||
/// The candidate (for the exact, exception and blocklist levels) or the
|
||||
/// pattern (for the wildcard and regex levels) that decided it. Borrowed
|
||||
/// from the caller's normalized buffer or from the snapshot. "" when
|
||||
/// `reason == .none`.
|
||||
matched: []const u8,
|
||||
/// `.blocklist_*` only: index into `Snapshot.sources`, so the query log and
|
||||
/// the UI can name the list that blocked the query.
|
||||
/// the UI can name the list that blocked the query — or, for
|
||||
/// `.blocklist_exception`, the list that lifted it.
|
||||
source: ?u32 = null,
|
||||
};
|
||||
|
||||
@@ -95,6 +102,10 @@ pub const SourceSets = struct {
|
||||
name: []const u8,
|
||||
domains: domain_set.DomainSet,
|
||||
wildcards: domain_set.DomainSet,
|
||||
/// The names this source's `@@` exceptions lift. One entry covers the name
|
||||
/// and every subdomain of it, because `evaluate` walks the full name and
|
||||
/// each parent against this set.
|
||||
exceptions: domain_set.DomainSet,
|
||||
};
|
||||
|
||||
pub const Group = struct {
|
||||
@@ -122,7 +133,15 @@ pub const Snapshot = struct {
|
||||
/// can tell which generation answered a query.
|
||||
generation: u64,
|
||||
|
||||
pub const Compiled = struct { list_body: []const u8, wild_body: []const u8 };
|
||||
/// The compiled bodies of one source. `allow_body` defaults to empty
|
||||
/// because a source compiled before exceptions were honoured has no
|
||||
/// `.allow` file at all. A source refreshed since then always has one,
|
||||
/// empty when its list carries no `@@` line.
|
||||
pub const Compiled = struct {
|
||||
list_body: []const u8,
|
||||
wild_body: []const u8,
|
||||
allow_body: []const u8 = "",
|
||||
};
|
||||
|
||||
pub const Input = struct {
|
||||
groups: []const model.Group,
|
||||
@@ -192,6 +211,7 @@ pub const Snapshot = struct {
|
||||
.name = try arena.dupe(u8, row.name),
|
||||
.domains = try domain_set.DomainSet.build(arena, bodies.list_body, input.seed),
|
||||
.wildcards = try domain_set.DomainSet.build(arena, bodies.wild_body, input.seed),
|
||||
.exceptions = try domain_set.DomainSet.build(arena, bodies.allow_body, input.seed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,7 +241,11 @@ pub const Snapshot = struct {
|
||||
.id = id,
|
||||
.name = try arena.dupe(u8, row.name),
|
||||
.safe_search = row.safe_search,
|
||||
.rules = try rules.RuleSet.build(arena, group_rules.items, input.seed),
|
||||
// `arena` retains, `gpa` scratches: an arena reclaims only its
|
||||
// most recent allocation, so a rule build's temporaries taken
|
||||
// from it would outlive the build and go unreported by
|
||||
// `memoryBytes`.
|
||||
.rules = try rules.RuleSet.build(arena, gpa, group_rules.items, input.seed),
|
||||
.sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
|
||||
};
|
||||
}
|
||||
@@ -269,7 +293,9 @@ pub const Snapshot = struct {
|
||||
/// PLAN §3.10 precedence, allow winning at equal specificity:
|
||||
/// 1. exact/parent allow rules 2. exact/parent block rules
|
||||
/// 3. wildcard allow rules 4. wildcard block rules
|
||||
/// 5. blocklist domains 6. blocklist wildcards
|
||||
/// 5. regex allow rules 6. regex block rules
|
||||
/// 7. blocklist exceptions
|
||||
/// 8. blocklist domains 9. blocklist wildcards
|
||||
///
|
||||
/// The order is level-by-level over the whole candidate chain, not
|
||||
/// candidate-by-candidate over the levels: level 1 is checked against every
|
||||
@@ -277,10 +303,24 @@ pub const Snapshot = struct {
|
||||
/// allow rule on the parent beat a block rule on the child, which is the
|
||||
/// behaviour an allow list is written for.
|
||||
///
|
||||
/// Level 5 tests only the full name and level 6 tests only proper parents:
|
||||
/// Levels 5 and 6 are last among the operator rules because they are the
|
||||
/// only ones that cost more than a hash lookup or a label walk: a regex is
|
||||
/// reached only once every set-shaped level has missed. They are matched
|
||||
/// against the full name alone — a pattern that should cover subdomains
|
||||
/// says so, which is what an unanchored regex already does.
|
||||
///
|
||||
/// Level 7 is where a downloaded list's `@@` exceptions are honoured, and
|
||||
/// its position is the whole safety argument: every operator rule has
|
||||
/// already returned by the time it runs, so an exception can cancel a block
|
||||
/// levels 8 and 9 would have made and nothing else. No downloaded list can
|
||||
/// open an allow hole the operator did not open. It walks the full name and
|
||||
/// every parent, because one `@@||x^` entry lifts `x` together with its
|
||||
/// subdomains.
|
||||
///
|
||||
/// Level 8 tests only the full name and level 9 tests only proper parents:
|
||||
/// a `.list` entry is the domain itself, a `.wild` entry is what `*.x.y`
|
||||
/// means. Both walk the group's sources in ascending index order, so the
|
||||
/// reported source is stable for a given snapshot.
|
||||
/// means. All three list levels walk the group's sources in ascending index
|
||||
/// order, so the reported source is stable for a given snapshot.
|
||||
///
|
||||
/// `domain` is normalized (`normalize`). No allocation, no lock, no clock.
|
||||
pub fn evaluate(self: *const Snapshot, group: u32, domain: []const u8) Decision {
|
||||
@@ -307,6 +347,27 @@ pub const Snapshot = struct {
|
||||
return .{ .blocked = true, .reason = .rule_block_wildcard, .matched = pattern };
|
||||
}
|
||||
|
||||
if (rules.matchRegex(g.rules.regex_allow, domain)) |pattern| {
|
||||
return .{ .blocked = false, .reason = .rule_allow_regex, .matched = pattern };
|
||||
}
|
||||
if (rules.matchRegex(g.rules.regex_block, domain)) |pattern| {
|
||||
return .{ .blocked = true, .reason = .rule_block_regex, .matched = pattern };
|
||||
}
|
||||
|
||||
var exceptions: Candidates = .init(domain);
|
||||
while (exceptions.next()) |candidate| {
|
||||
for (g.sources) |index| {
|
||||
if (self.sources[index].exceptions.contains(candidate)) {
|
||||
return .{
|
||||
.blocked = false,
|
||||
.reason = .blocklist_exception,
|
||||
.matched = candidate,
|
||||
.source = index,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (g.sources) |index| {
|
||||
if (self.sources[index].domains.contains(domain)) {
|
||||
return .{
|
||||
@@ -373,7 +434,8 @@ pub const Snapshot = struct {
|
||||
var total: usize = 0;
|
||||
for (self.sources) |*source| {
|
||||
total += @sizeOf(SourceSets) + source.name.len +
|
||||
source.domains.memoryBytes() + source.wildcards.memoryBytes();
|
||||
source.domains.memoryBytes() + source.wildcards.memoryBytes() +
|
||||
source.exceptions.memoryBytes();
|
||||
}
|
||||
for (self.groups) |*group| {
|
||||
total += @sizeOf(Group) + group.name.len +
|
||||
@@ -586,6 +648,71 @@ test "precedence: a block wildcard with no allow blocks" {
|
||||
try testing.expectEqualStrings("*.example.com", decision.matched);
|
||||
}
|
||||
|
||||
test "precedence: a block regex with no allow blocks, and reports its pattern" {
|
||||
const rows = [_]model.Rule{rule("^ad[0-9]+-", .regex, .block)};
|
||||
var snapshot = try build(testing.allocator, .{ .rules = &rows });
|
||||
defer snapshot.deinit();
|
||||
|
||||
const decision = snapshot.evaluate(0, "ad42-tracker.example.com");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expectEqual(Reason.rule_block_regex, decision.reason);
|
||||
// `matched` is the pattern the operator wrote, which is what the query log
|
||||
// has to name for the block to be explicable.
|
||||
try testing.expectEqualStrings("^ad[0-9]+-", decision.matched);
|
||||
|
||||
// Unanchored at the tail, anchored at the head: the digits must lead.
|
||||
try testing.expect(!snapshot.evaluate(0, "x.ad42-tracker.example.com").blocked);
|
||||
try testing.expect(!snapshot.evaluate(0, "ads.example.com").blocked);
|
||||
}
|
||||
|
||||
test "precedence: an allow regex beats a block regex that matches the same name" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("tracker", .regex, .block),
|
||||
rule("^good\\.", .regex, .allow),
|
||||
};
|
||||
var snapshot = try build(testing.allocator, .{ .rules = &rows });
|
||||
defer snapshot.deinit();
|
||||
|
||||
const decision = snapshot.evaluate(0, "good.tracker.example.com");
|
||||
try testing.expect(!decision.blocked);
|
||||
try testing.expectEqual(Reason.rule_allow_regex, decision.reason);
|
||||
try testing.expectEqualStrings("^good\\.", decision.matched);
|
||||
|
||||
try testing.expect(snapshot.evaluate(0, "bad.tracker.example.com").blocked);
|
||||
}
|
||||
|
||||
test "precedence: both wildcard levels beat an allow regex that matches" {
|
||||
// The adjacent pair either side of the wildcard/regex boundary. A regex is
|
||||
// the most expensive level and therefore the last operator level, so a
|
||||
// wildcard decides first whichever way it decides.
|
||||
for ([_]model.Rule{
|
||||
rule("*.example.com", .wildcard, .allow),
|
||||
rule("*.example.com", .wildcard, .block),
|
||||
}) |wild| {
|
||||
const rows = [_]model.Rule{ wild, rule("example", .regex, .allow) };
|
||||
var snapshot = try build(testing.allocator, .{ .rules = &rows });
|
||||
defer snapshot.deinit();
|
||||
|
||||
const decision = snapshot.evaluate(0, "a.example.com");
|
||||
try testing.expectEqual(wild.action == .block, decision.blocked);
|
||||
try testing.expect(decision.reason == .rule_allow_wildcard or
|
||||
decision.reason == .rule_block_wildcard);
|
||||
}
|
||||
}
|
||||
|
||||
test "precedence: an exact allow rule beats a block regex" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("tracker", .regex, .block),
|
||||
rule("good.tracker.example.com", .exact, .allow),
|
||||
};
|
||||
var snapshot = try build(testing.allocator, .{ .rules = &rows });
|
||||
defer snapshot.deinit();
|
||||
|
||||
const decision = snapshot.evaluate(0, "good.tracker.example.com");
|
||||
try testing.expect(!decision.blocked);
|
||||
try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
|
||||
}
|
||||
|
||||
const one_source = [_]model.BlocklistSource{.{ .url = "https://lists.test/a", .name = "list a" }};
|
||||
const one_source_id = [_]i64{11};
|
||||
const one_link = [_]model.GroupSource{
|
||||
@@ -601,6 +728,14 @@ const Lists = struct {
|
||||
return .{ .compiled = .{.{ .list_body = list_body, .wild_body = wild_body }} };
|
||||
}
|
||||
|
||||
fn initWithExceptions(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) Lists {
|
||||
return .{ .compiled = .{.{
|
||||
.list_body = list_body,
|
||||
.wild_body = wild_body,
|
||||
.allow_body = allow_body,
|
||||
}} };
|
||||
}
|
||||
|
||||
fn fixture(self: *const Lists) Fixture {
|
||||
return .{
|
||||
.sources = &one_source,
|
||||
@@ -669,6 +804,208 @@ test "precedence: an allow rule beats a wild entry" {
|
||||
try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
|
||||
}
|
||||
|
||||
// --- list exceptions (milestone 21 ruling 2) --------------------------------
|
||||
|
||||
/// The fixture ruling 2 is written against: one list that blocks `ads.example`
|
||||
/// and its subdomains, and lifts `good.ads.example` back out.
|
||||
const exception_lists: Lists = .initWithExceptions(
|
||||
"ads.example\n",
|
||||
"ads.example\n",
|
||||
"good.ads.example\n",
|
||||
);
|
||||
|
||||
test "precedence: a list exception beats a list domain entry" {
|
||||
var snapshot = try build(testing.allocator, exception_lists.fixture());
|
||||
defer snapshot.deinit();
|
||||
|
||||
// The apex is blocked by the `.list` entry; the excepted name is not, even
|
||||
// though the same source blocks it through `.wild`.
|
||||
try testing.expect(snapshot.evaluate(0, "ads.example").blocked);
|
||||
|
||||
const decision = snapshot.evaluate(0, "good.ads.example");
|
||||
try testing.expect(!decision.blocked);
|
||||
try testing.expectEqual(Reason.blocklist_exception, decision.reason);
|
||||
try testing.expectEqualStrings("good.ads.example", decision.matched);
|
||||
try testing.expectEqual(@as(?u32, 0), decision.source);
|
||||
}
|
||||
|
||||
test "precedence: a list exception beats a list domain entry on the same name" {
|
||||
// The boundary the fixture above cannot pin: `good.ads.example` is not in
|
||||
// its `.list` body, so that test compares the exception against the
|
||||
// wildcard level. Here one name is carried by both `.allow` and `.list`,
|
||||
// which is the only way level 7 and level 8 are reached by one query.
|
||||
const lists: Lists = .initWithExceptions(
|
||||
"good.ads.example\n",
|
||||
"",
|
||||
"good.ads.example\n",
|
||||
);
|
||||
var snapshot = try build(testing.allocator, lists.fixture());
|
||||
defer snapshot.deinit();
|
||||
|
||||
const decision = snapshot.evaluate(0, "good.ads.example");
|
||||
try testing.expect(!decision.blocked);
|
||||
try testing.expectEqual(Reason.blocklist_exception, decision.reason);
|
||||
try testing.expectEqualStrings("good.ads.example", decision.matched);
|
||||
try testing.expectEqual(@as(?u32, 0), decision.source);
|
||||
}
|
||||
|
||||
test "precedence: a list domain entry beats a list wildcard entry" {
|
||||
// Level 8 over level 9: the name is its own `.list` entry and a subdomain
|
||||
// of a `.wild` entry, so both would block and the reported reason is what
|
||||
// separates them.
|
||||
const lists: Lists = .init("x.ads.example\n", "ads.example\n");
|
||||
var snapshot = try build(testing.allocator, lists.fixture());
|
||||
defer snapshot.deinit();
|
||||
|
||||
const decision = snapshot.evaluate(0, "x.ads.example");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expectEqual(Reason.blocklist_domain, decision.reason);
|
||||
try testing.expectEqualStrings("x.ads.example", decision.matched);
|
||||
try testing.expectEqual(@as(?u32, 0), decision.source);
|
||||
}
|
||||
|
||||
test "precedence: a list exception beats a list wildcard entry" {
|
||||
var snapshot = try build(testing.allocator, exception_lists.fixture());
|
||||
defer snapshot.deinit();
|
||||
|
||||
try testing.expect(snapshot.evaluate(0, "x.ads.example").blocked);
|
||||
|
||||
// The parent walk: one `@@||good.ads.example^` entry covers the subdomains
|
||||
// of the excepted name as well as the name itself.
|
||||
const decision = snapshot.evaluate(0, "y.good.ads.example");
|
||||
try testing.expect(!decision.blocked);
|
||||
try testing.expectEqual(Reason.blocklist_exception, decision.reason);
|
||||
try testing.expectEqualStrings("good.ads.example", decision.matched);
|
||||
try testing.expectEqual(@as(?u32, 0), decision.source);
|
||||
}
|
||||
|
||||
test "precedence: an operator block rule beats a list exception" {
|
||||
// The property the exception level's position exists for: a downloaded list
|
||||
// may cancel what another list blocks and may never cancel what the
|
||||
// operator decided. All three operator block levels are checked, because
|
||||
// all three sit above the exception level.
|
||||
for ([_]model.Rule{
|
||||
rule("good.ads.example", .exact, .block),
|
||||
rule("*.ads.example", .wildcard, .block),
|
||||
rule("^good\\.ads\\.example$", .regex, .block),
|
||||
}) |blocking| {
|
||||
var fixture = exception_lists.fixture();
|
||||
const rows = [_]model.Rule{blocking};
|
||||
fixture.rules = &rows;
|
||||
|
||||
var snapshot = try build(testing.allocator, fixture);
|
||||
defer snapshot.deinit();
|
||||
|
||||
const decision = snapshot.evaluate(0, "good.ads.example");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expect(decision.reason == .rule_block_exact or
|
||||
decision.reason == .rule_block_wildcard or
|
||||
decision.reason == .rule_block_regex);
|
||||
}
|
||||
}
|
||||
|
||||
test "the regex reasons render as the wire strings the API and the query log carry" {
|
||||
// `web/handlers/lookup.zig` renders a reason as `@tagName`, and
|
||||
// `storage/logger.zig` stores one in a 32-byte `max_reason_len` buffer.
|
||||
// Neither can be reached from this file — pure core imports no web and no
|
||||
// storage — so the tag names and their length are pinned here.
|
||||
try testing.expectEqualStrings("rule_allow_regex", @tagName(Reason.rule_allow_regex));
|
||||
try testing.expectEqualStrings("rule_block_regex", @tagName(Reason.rule_block_regex));
|
||||
inline for (@typeInfo(Reason).@"enum".fields) |field| {
|
||||
try testing.expect(field.name.len <= 32);
|
||||
}
|
||||
}
|
||||
|
||||
test "precedence: an allow regex beats every list level" {
|
||||
// The other side of the same boundary: an operator allow rule lifts a list
|
||||
// block, whichever of the three list levels made it.
|
||||
const rows = [_]model.Rule{rule("ads\\.example$", .regex, .allow)};
|
||||
var fixture = exception_lists.fixture();
|
||||
fixture.rules = &rows;
|
||||
|
||||
var snapshot = try build(testing.allocator, fixture);
|
||||
defer snapshot.deinit();
|
||||
|
||||
// `.list` blocks the apex and `.wild` blocks the subdomains; the regex
|
||||
// covers both, and answers before either is consulted.
|
||||
for ([_][]const u8{ "ads.example", "x.ads.example" }) |domain| {
|
||||
const decision = snapshot.evaluate(0, domain);
|
||||
try testing.expect(!decision.blocked);
|
||||
try testing.expectEqual(Reason.rule_allow_regex, decision.reason);
|
||||
try testing.expectEqual(@as(?u32, null), decision.source);
|
||||
}
|
||||
}
|
||||
|
||||
test "precedence: a block regex blocks a name no list carries" {
|
||||
const rows = [_]model.Rule{rule("^ad[0-9]+-", .regex, .block)};
|
||||
var fixture = exception_lists.fixture();
|
||||
fixture.rules = &rows;
|
||||
|
||||
var snapshot = try build(testing.allocator, fixture);
|
||||
defer snapshot.deinit();
|
||||
|
||||
const decision = snapshot.evaluate(0, "ad7-cdn.other.example");
|
||||
try testing.expect(decision.blocked);
|
||||
try testing.expectEqual(Reason.rule_block_regex, decision.reason);
|
||||
try testing.expectEqual(@as(?u32, null), decision.source);
|
||||
}
|
||||
|
||||
test "precedence: a list exception is scoped to the groups the source is in" {
|
||||
const groups = [_]model.Group{ .{ .name = "default" }, .{ .name = "kids" } };
|
||||
const ids = [_]i64{ 1, 2 };
|
||||
var snapshot = try build(testing.allocator, .{
|
||||
.groups = &groups,
|
||||
.group_ids = &ids,
|
||||
.sources = &one_source,
|
||||
.source_ids = &one_source_id,
|
||||
.group_sources = &one_link,
|
||||
.compiled = &exception_lists.compiled,
|
||||
});
|
||||
defer snapshot.deinit();
|
||||
|
||||
const kids = snapshot.groupIndexByName("kids").?;
|
||||
try testing.expectEqual(
|
||||
Reason.blocklist_exception,
|
||||
snapshot.evaluate(snapshot.default_group, "good.ads.example").reason,
|
||||
);
|
||||
// `kids` is linked to no source, so neither the block nor the exception
|
||||
// reaches it.
|
||||
try testing.expectEqual(Reason.none, snapshot.evaluate(kids, "good.ads.example").reason);
|
||||
}
|
||||
|
||||
test "precedence: an exception in one list lifts the block another list made" {
|
||||
const sources = [_]model.BlocklistSource{
|
||||
.{ .url = "https://lists.test/a", .name = "list a" },
|
||||
.{ .url = "https://lists.test/b", .name = "list b" },
|
||||
};
|
||||
const source_ids = [_]i64{ 11, 12 };
|
||||
const links = [_]model.GroupSource{
|
||||
.{ .group = "default", .source_url = "https://lists.test/a" },
|
||||
.{ .group = "default", .source_url = "https://lists.test/b" },
|
||||
};
|
||||
const compiled = [_]?Snapshot.Compiled{
|
||||
.{ .list_body = "ads.example\n", .wild_body = "ads.example\n" },
|
||||
.{ .list_body = "", .wild_body = "", .allow_body = "good.ads.example\n" },
|
||||
};
|
||||
|
||||
var snapshot = try build(testing.allocator, .{
|
||||
.sources = &sources,
|
||||
.source_ids = &source_ids,
|
||||
.group_sources = &links,
|
||||
.compiled = &compiled,
|
||||
});
|
||||
defer snapshot.deinit();
|
||||
|
||||
try testing.expect(snapshot.evaluate(0, "ads.example").blocked);
|
||||
|
||||
const decision = snapshot.evaluate(0, "good.ads.example");
|
||||
try testing.expect(!decision.blocked);
|
||||
try testing.expectEqual(Reason.blocklist_exception, decision.reason);
|
||||
// The source reported is the one that lifted the block, not the one that
|
||||
// made it.
|
||||
try testing.expectEqual(@as(?u32, 1), decision.source);
|
||||
}
|
||||
|
||||
test "precedence: nothing configured allows with reason none" {
|
||||
var snapshot = try build(testing.allocator, .{});
|
||||
defer snapshot.deinit();
|
||||
|
||||
+132
-8
@@ -1,9 +1,17 @@
|
||||
//! The Adblock Plus filter syntax, restricted to what a DNS sinkhole can
|
||||
//! honour: domain anchors and bare names. Pure, `std` only.
|
||||
//!
|
||||
//! Exception rules (`@@`) are `.unsupported` rather than an allow entry. The
|
||||
//! allow surface is the `rules` table, and a downloaded list that could quietly
|
||||
//! allow a domain across every group is a policy hole the operator did not open.
|
||||
//! An exception rule is `.exception` in exactly two spellings, `@@||name^` and
|
||||
//! `@@||name`, each of which may carry the literal `$important` behind it. Every
|
||||
//! other `@@` form stays `.unsupported`: a bare `@@name`, a path, a scheme, any
|
||||
//! other modifier.
|
||||
//!
|
||||
//! What makes honouring them safe is where they land, not what they say. A list
|
||||
//! exception is evaluated below every operator rule (PLAN §3.10), so it can
|
||||
//! cancel a block another list made and nothing else. No downloaded list can
|
||||
//! open an allow hole the operator did not open, which is why the allow surface
|
||||
//! stays the `rules` table — including its `.regex` kind, which is the one
|
||||
//! regex dialect nxdns evaluates and which no downloaded list can reach.
|
||||
|
||||
const std = @import("std");
|
||||
const parsers = @import("parsers.zig");
|
||||
@@ -12,6 +20,31 @@ const parsers = @import("parsers.zig");
|
||||
/// this syntax and only a trailing one is meaningful for a domain rule.
|
||||
const rule_tokens = "*^|/$";
|
||||
|
||||
/// The one modifier an exception line may carry. AdGuard-authored lists write it
|
||||
/// on most of their `@@` rules and it changes nothing here: these exceptions
|
||||
/// already sit below every operator rule, so "important" cannot raise one above
|
||||
/// the decisions it is not allowed to reach.
|
||||
const important_modifier = "$important";
|
||||
|
||||
/// A candidate the compiler could turn into a name, for the two anchored forms
|
||||
/// only. `rule_tokens` covers the syntax characters; this also refuses
|
||||
/// whitespace, which is neither a rule token nor a control byte and so used to
|
||||
/// survive into a compiled body as an entry only a query carrying the same
|
||||
/// space could match. That is true of `||name` and `@@||name` because
|
||||
/// `compiler.zig` hands a `.wildcard` or `.exception` text to `addCandidate`
|
||||
/// whole. A `.domain` text is tokenized on whitespace first and each field
|
||||
/// filed separately, so the bare form does not come through here: refusing a
|
||||
/// space there would drop the hosts-style lines that a mixed list classified
|
||||
/// `abp` by `detectFormat` still contributes.
|
||||
fn isNameCandidate(candidate: []const u8) bool {
|
||||
if (candidate.len == 0) return false;
|
||||
if (std.mem.findAny(u8, candidate, rule_tokens) != null) return false;
|
||||
for (candidate) |c| {
|
||||
if (std.ascii.isWhitespace(c) or std.ascii.isControl(c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn parseLine(line: []const u8) parsers.Line {
|
||||
const text = std.mem.trim(u8, line, &std.ascii.whitespace);
|
||||
if (text.len == 0) return .{ .kind = .ignore };
|
||||
@@ -19,15 +52,14 @@ pub fn parseLine(line: []const u8) parsers.Line {
|
||||
if (text[0] == '[') return .{ .kind = .ignore };
|
||||
if (parsers.isElementHiding(text)) return .{ .kind = .unsupported };
|
||||
if (text[0] == '#') return .{ .kind = .ignore };
|
||||
if (std.mem.startsWith(u8, text, "@@")) return .{ .kind = .unsupported };
|
||||
if (std.mem.startsWith(u8, text, "@@")) return parseException(text[2..]);
|
||||
if (text[0] == '/') return .{ .kind = .regex };
|
||||
if (std.mem.findScalar(u8, text, '$') != null) return .{ .kind = .unsupported };
|
||||
|
||||
if (std.mem.startsWith(u8, text, "||")) {
|
||||
var candidate = text[2..];
|
||||
if (std.mem.endsWith(u8, candidate, "^")) candidate = candidate[0 .. candidate.len - 1];
|
||||
if (candidate.len == 0) return .{ .kind = .unsupported };
|
||||
if (std.mem.findAny(u8, candidate, rule_tokens) != null) return .{ .kind = .unsupported };
|
||||
if (!isNameCandidate(candidate)) return .{ .kind = .unsupported };
|
||||
// A domain anchor covers the domain itself as well as its subdomains,
|
||||
// so the compiler emits an apex entry beside the wildcard one.
|
||||
return .{ .kind = .wildcard, .text = candidate, .covers_apex = true };
|
||||
@@ -37,6 +69,24 @@ pub fn parseLine(line: []const u8) parsers.Line {
|
||||
return .{ .kind = .domain, .text = text };
|
||||
}
|
||||
|
||||
/// One exception line, past its `@@`. The domain anchor and the trailing `^` get
|
||||
/// the same treatment they get on a block rule, so `@@||x^` and `||x^` accept
|
||||
/// and reject the same names.
|
||||
///
|
||||
/// `$important` is stripped before the anchor is read, because the `$` would
|
||||
/// otherwise be a rule token and refuse the whole line.
|
||||
fn parseException(rest: []const u8) parsers.Line {
|
||||
if (!std.mem.startsWith(u8, rest, "||")) return .{ .kind = .unsupported };
|
||||
|
||||
var candidate = rest[2..];
|
||||
if (std.mem.endsWith(u8, candidate, important_modifier)) {
|
||||
candidate = candidate[0 .. candidate.len - important_modifier.len];
|
||||
}
|
||||
if (std.mem.endsWith(u8, candidate, "^")) candidate = candidate[0 .. candidate.len - 1];
|
||||
if (!isNameCandidate(candidate)) return .{ .kind = .unsupported };
|
||||
return .{ .kind = .exception, .text = candidate, .covers_apex = true };
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a bang comment is ignored" {
|
||||
@@ -69,8 +119,46 @@ test "a modifier list is unsupported" {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("||example.com^$third-party").kind);
|
||||
}
|
||||
|
||||
test "an exception rule is unsupported" {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("@@||example.com^").kind);
|
||||
test "an exception rule is an exception that covers its apex" {
|
||||
const line = parseLine("@@||example.com^");
|
||||
try testing.expectEqual(parsers.Kind.exception, line.kind);
|
||||
try testing.expectEqualStrings("example.com", line.text);
|
||||
try testing.expect(line.covers_apex);
|
||||
}
|
||||
|
||||
test "an exception rule without a separator is still an exception" {
|
||||
const line = parseLine("@@||example.com");
|
||||
try testing.expectEqual(parsers.Kind.exception, line.kind);
|
||||
try testing.expectEqualStrings("example.com", line.text);
|
||||
try testing.expect(line.covers_apex);
|
||||
}
|
||||
|
||||
test "an exception rule tolerates the important modifier" {
|
||||
for ([_][]const u8{ "@@||example.com^$important", "@@||example.com$important" }) |text| {
|
||||
const line = parseLine(text);
|
||||
try testing.expectEqual(parsers.Kind.exception, line.kind);
|
||||
try testing.expectEqualStrings("example.com", line.text);
|
||||
try testing.expect(line.covers_apex);
|
||||
}
|
||||
}
|
||||
|
||||
test "every exception form outside the two anchored ones is unsupported" {
|
||||
for ([_][]const u8{
|
||||
// No domain anchor: this is a substring rule in browser syntax, and
|
||||
// reading it as a name would allow far more than it says.
|
||||
"@@example.com",
|
||||
"@@|http://example.com",
|
||||
"@@||example.com/path^",
|
||||
"@@||example.com^$third-party",
|
||||
"@@||example.com^$important$third-party",
|
||||
"@@||example.com^$dnstype=A",
|
||||
"@@||^",
|
||||
"@@||$important",
|
||||
"@@",
|
||||
"@@||ads*.example.com^",
|
||||
}) |text| {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine(text).kind);
|
||||
}
|
||||
}
|
||||
|
||||
test "element hiding is unsupported" {
|
||||
@@ -94,6 +182,42 @@ test "a bare name is a domain" {
|
||||
try testing.expectEqualStrings("example.com", line.text);
|
||||
}
|
||||
|
||||
test "a candidate carrying whitespace is unsupported in the anchored forms" {
|
||||
// A space is not a rule token and it is not a control byte, so it used to
|
||||
// reach the compiler, which lowercases and length-checks but does not
|
||||
// reject it. An anchored form's text is filed whole, so the entry it wrote
|
||||
// could only ever match a query name carrying the same space.
|
||||
for ([_][]const u8{
|
||||
"||good.example bad.example^",
|
||||
"@@||good.example bad.example^",
|
||||
"||good.example\tbad.example",
|
||||
"@@||good.example\tbad.example",
|
||||
}) |text| {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine(text).kind);
|
||||
}
|
||||
}
|
||||
|
||||
test "a bare candidate carrying whitespace stays a domain" {
|
||||
// Not the same case: `compiler.zig` tokenizes a `.domain` text on
|
||||
// whitespace and files each field. Refusing it here would drop the
|
||||
// hosts-style lines of a mixed list, which `detectFormat` classifies `abp`
|
||||
// as a whole and which only reach a compiled body through that split.
|
||||
//
|
||||
// What this test pins is the parser half — the kind and the untouched text.
|
||||
// The split itself belongs to the compiler and is asserted there, by
|
||||
// "an abp list's hosts-style lines reach the domain body through the split":
|
||||
// a tokenizer removed from `compile` would leave every assertion below true.
|
||||
for ([_][]const u8{
|
||||
"good.example bad.example",
|
||||
"0.0.0.0 ads.example",
|
||||
"good.example\tbad.example",
|
||||
}) |text| {
|
||||
const line = parseLine(text);
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings(text, line.text);
|
||||
}
|
||||
}
|
||||
|
||||
test "a rule token outside the supported forms is unsupported" {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("ads*.example.com").kind);
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com^").kind);
|
||||
|
||||
+12
-4
@@ -22,10 +22,15 @@ pub const Kind = enum {
|
||||
domain,
|
||||
/// `text` holds one candidate suffix; every proper subdomain of it matches.
|
||||
wildcard,
|
||||
/// A regex rule. Counted, skipped, never compiled (PLAN §2.2).
|
||||
/// 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 rule, element hiding, a scheme anchor.
|
||||
/// an ABP modifier list, an exception form outside `@@||x^`, element
|
||||
/// hiding, a scheme anchor.
|
||||
unsupported,
|
||||
};
|
||||
|
||||
@@ -33,8 +38,11 @@ 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.
|
||||
/// `.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,
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+339
-62
@@ -1,4 +1,4 @@
|
||||
//! One group's explicit rules (PLAN §3.10 levels 1–4), compiled once into an
|
||||
//! One group's explicit rules (PLAN §3.10 levels 1–6), compiled once into an
|
||||
//! immutable form the query path can read without allocating.
|
||||
//!
|
||||
//! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted
|
||||
@@ -7,7 +7,13 @@
|
||||
//! that many short patterns is cheaper than an index that would have to be
|
||||
//! rebuilt on every snapshot swap.
|
||||
//!
|
||||
//! Pure: an allocator and plain values, no `std.Io`, no clock, no entropy
|
||||
//! Regex patterns are compiled here, once per snapshot, into the linear-time
|
||||
//! programs of `regex.zig` and scanned the same way. `max_regex_per_group` caps
|
||||
//! them far lower, at 256: a regex costs a whole VM run where a wildcard costs a
|
||||
//! label comparison, and the matcher reaches them only after every hash and
|
||||
//! wildcard level has missed.
|
||||
//!
|
||||
//! Pure: allocators and plain values, no `std.Io`, no clock, no entropy
|
||||
//! source. The hash seed arrives as a parameter.
|
||||
|
||||
const std = @import("std");
|
||||
@@ -17,14 +23,40 @@ const model = @import("../config/model.zig");
|
||||
const name = @import("../dns/name.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const domain_set = @import("domain_set.zig");
|
||||
const regex = @import("regex.zig");
|
||||
const wildcard = @import("wildcard.zig");
|
||||
|
||||
pub const Error = error{ OutOfMemory, BadPattern, TooManyWildcards } || domain_set.DomainSet.Error;
|
||||
pub const Error = error{
|
||||
OutOfMemory,
|
||||
BadPattern,
|
||||
TooManyWildcards,
|
||||
TooManyRegexRules,
|
||||
} || domain_set.DomainSet.Error;
|
||||
|
||||
/// Both wildcard lists of one group together. The cap exists so a rules table
|
||||
/// edited into the millions cannot turn every query into a linear scan.
|
||||
pub const max_wildcards_per_group: usize = 4096;
|
||||
|
||||
/// Both regex lists of one group together, capped well below the wildcards: a
|
||||
/// miss at this level runs every program to its end.
|
||||
pub const max_regex_per_group: usize = 256;
|
||||
|
||||
/// A compiled operator regex beside the text it was written as. The text is what
|
||||
/// `Decision.matched` reports, so the query log names the rule the operator
|
||||
/// wrote rather than an instruction count.
|
||||
pub const RegexRule = struct {
|
||||
pattern: []const u8,
|
||||
program: regex.Program,
|
||||
|
||||
/// Frees through a copy of the program: the slices holding these rules are
|
||||
/// `const`, and `Program.deinit` wants a mutable pointer only to blank the
|
||||
/// struct it is finished with.
|
||||
fn free(self: RegexRule, gpa: Allocator) void {
|
||||
var program = self.program;
|
||||
program.deinit(gpa);
|
||||
}
|
||||
};
|
||||
|
||||
pub const RuleSet = struct {
|
||||
exact_allow: domain_set.DomainSet = .empty,
|
||||
exact_block: domain_set.DomainSet = .empty,
|
||||
@@ -32,75 +64,115 @@ pub const RuleSet = struct {
|
||||
/// order and the same first match.
|
||||
wildcard_allow: []const []const u8 = &.{},
|
||||
wildcard_block: []const []const u8 = &.{},
|
||||
/// One block holding the bytes of both wildcard lists; freed as a unit.
|
||||
wildcard_bytes: []const u8 = &.{},
|
||||
/// Sorted and deduplicated like the wildcards, so the first regex to match a
|
||||
/// name is the same one on every rebuild of the same rows.
|
||||
regex_allow: []const RegexRule = &.{},
|
||||
regex_block: []const RegexRule = &.{},
|
||||
/// One block holding the pattern bytes of all four lists; freed as a unit.
|
||||
pattern_bytes: []const u8 = &.{},
|
||||
|
||||
pub const empty: RuleSet = .{};
|
||||
|
||||
/// `rows` are one group's rules only; splitting `listRules` output by group
|
||||
/// belongs to the caller, which is the only holder of the group table.
|
||||
///
|
||||
/// Patterns are normalized (lowercase over ASCII, one trailing dot
|
||||
/// Name patterns are normalized (lowercase over ASCII, one trailing dot
|
||||
/// stripped) and validated: `.exact` through `dns.name.fromText`,
|
||||
/// `.wildcard` through `wildcard.validate`. An invalid pattern is
|
||||
/// `error.BadPattern`, not a skipped row — every pattern passed
|
||||
/// `config/validate.zig` on the way in, so an invalid one here means the
|
||||
/// rows were edited underneath nxdns and a silently dropped allow rule
|
||||
/// `.wildcard` through `wildcard.validate`, `.regex` by compiling it. An
|
||||
/// invalid pattern is `error.BadPattern`, not a skipped row — every pattern
|
||||
/// passed `config/validate.zig` on the way in, so an invalid one here means
|
||||
/// the rows were edited underneath nxdns and a silently dropped allow rule
|
||||
/// would block a domain the operator unblocked.
|
||||
pub fn build(gpa: Allocator, rows: []const model.Rule, seed: u64) Error!RuleSet {
|
||||
///
|
||||
/// Two allocators, because the caller's `perm` is a snapshot arena: an
|
||||
/// arena reclaims only its most recent allocation, so every temporary taken
|
||||
/// from it would live as long as the snapshot and go unreported by
|
||||
/// `memoryBytes`. `perm` owns what the returned set retains and is what
|
||||
/// `deinit` frees; `scratch` owns the build's working storage, which is
|
||||
/// released by the time `build` returns. Passing one allocator as both is
|
||||
/// correct wherever freeing works normally.
|
||||
pub fn build(
|
||||
perm: Allocator,
|
||||
scratch: Allocator,
|
||||
rows: []const model.Rule,
|
||||
seed: u64,
|
||||
) Error!RuleSet {
|
||||
if (rows.len == 0) return .empty;
|
||||
|
||||
var scratch: std.ArrayList(u8) = .empty;
|
||||
defer scratch.deinit(gpa);
|
||||
var spans: [4]std.ArrayList(Span) = .{ .empty, .empty, .empty, .empty };
|
||||
defer for (&spans) |*bucket| bucket.deinit(gpa);
|
||||
var joined: std.ArrayList(u8) = .empty;
|
||||
defer joined.deinit(scratch);
|
||||
var spans: [6]std.ArrayList(Span) = @splat(.empty);
|
||||
defer for (&spans) |*bucket| bucket.deinit(scratch);
|
||||
|
||||
var wildcards: usize = 0;
|
||||
var regexes: usize = 0;
|
||||
var buf: [types.max_name_len]u8 = undefined;
|
||||
for (rows) |row| {
|
||||
const pattern = normalize(row.pattern, &buf) catch return error.BadPattern;
|
||||
switch (row.kind) {
|
||||
.exact => _ = name.fromText(pattern) catch return error.BadPattern,
|
||||
.wildcard => {
|
||||
wildcard.validate(pattern) catch return error.BadPattern;
|
||||
const pattern = switch (row.kind) {
|
||||
.exact => blk: {
|
||||
const text = normalize(row.pattern, &buf) catch return error.BadPattern;
|
||||
_ = name.fromText(text) catch return error.BadPattern;
|
||||
break :blk text;
|
||||
},
|
||||
.wildcard => blk: {
|
||||
const text = normalize(row.pattern, &buf) catch return error.BadPattern;
|
||||
wildcard.validate(text) catch return error.BadPattern;
|
||||
wildcards += 1;
|
||||
if (wildcards > max_wildcards_per_group) return error.TooManyWildcards;
|
||||
break :blk text;
|
||||
},
|
||||
}
|
||||
// A regex is not a name, so `normalize` must not touch it: it
|
||||
// strips a trailing `.`, which here is the any-byte atom, and it
|
||||
// lowercases, which turns the rejected `\D` into the accepted
|
||||
// `\d`. Either would silently change what the rule matches. The
|
||||
// bytes stay as the operator wrote them — the same bytes
|
||||
// `config/validate.zig` compiled at the edge. Compiling waits
|
||||
// until after the sort, so a duplicate is compiled once.
|
||||
.regex => blk: {
|
||||
regexes += 1;
|
||||
if (regexes > max_regex_per_group) return error.TooManyRegexRules;
|
||||
break :blk row.pattern;
|
||||
},
|
||||
};
|
||||
const bucket = &spans[bucketOf(row.kind, row.action)];
|
||||
try bucket.append(gpa, .{ .offset = scratch.items.len, .len = pattern.len });
|
||||
try scratch.appendSlice(gpa, pattern);
|
||||
try bucket.append(scratch, .{ .offset = joined.items.len, .len = pattern.len });
|
||||
try joined.appendSlice(scratch, pattern);
|
||||
}
|
||||
|
||||
// `scratch` stops growing here, so spans can become slices of it.
|
||||
var sorted: [4]std.ArrayList([]const u8) = .{ .empty, .empty, .empty, .empty };
|
||||
defer for (&sorted) |*bucket| bucket.deinit(gpa);
|
||||
// `joined` stops growing here, so spans can become slices of it.
|
||||
var sorted: [6]std.ArrayList([]const u8) = @splat(.empty);
|
||||
defer for (&sorted) |*bucket| bucket.deinit(scratch);
|
||||
for (&spans, &sorted) |*bucket, *out| {
|
||||
try out.ensureTotalCapacityPrecise(gpa, bucket.items.len);
|
||||
try out.ensureTotalCapacityPrecise(scratch, bucket.items.len);
|
||||
for (bucket.items) |span| {
|
||||
out.appendAssumeCapacity(scratch.items[span.offset..][0..span.len]);
|
||||
out.appendAssumeCapacity(joined.items[span.offset..][0..span.len]);
|
||||
}
|
||||
std.mem.sort([]const u8, out.items, {}, lessThanBytes);
|
||||
dedupSorted(out);
|
||||
}
|
||||
|
||||
var self: RuleSet = .empty;
|
||||
errdefer self.deinit(gpa);
|
||||
errdefer self.deinit(perm);
|
||||
|
||||
self.exact_allow = try buildSet(gpa, sorted[bucketOf(.exact, .allow)].items, seed);
|
||||
self.exact_block = try buildSet(gpa, sorted[bucketOf(.exact, .block)].items, seed);
|
||||
self.exact_allow = try buildSet(perm, scratch, sorted[bucketOf(.exact, .allow)].items, seed);
|
||||
self.exact_block = try buildSet(perm, scratch, sorted[bucketOf(.exact, .block)].items, seed);
|
||||
|
||||
const allow = sorted[bucketOf(.wildcard, .allow)].items;
|
||||
const block = sorted[bucketOf(.wildcard, .block)].items;
|
||||
const wild_allow = sorted[bucketOf(.wildcard, .allow)].items;
|
||||
const wild_block = sorted[bucketOf(.wildcard, .block)].items;
|
||||
const re_allow = sorted[bucketOf(.regex, .allow)].items;
|
||||
const re_block = sorted[bucketOf(.regex, .block)].items;
|
||||
var total: usize = 0;
|
||||
for (allow) |pattern| total += pattern.len;
|
||||
for (block) |pattern| total += pattern.len;
|
||||
for ([_][]const []const u8{ wild_allow, wild_block, re_allow, re_block }) |list| {
|
||||
for (list) |pattern| total += pattern.len;
|
||||
}
|
||||
|
||||
const bytes = try gpa.alloc(u8, total);
|
||||
self.wildcard_bytes = bytes;
|
||||
const bytes = try perm.alloc(u8, total);
|
||||
self.pattern_bytes = bytes;
|
||||
var at: usize = 0;
|
||||
self.wildcard_allow = try copyPatterns(gpa, allow, bytes, &at);
|
||||
self.wildcard_block = try copyPatterns(gpa, block, bytes, &at);
|
||||
self.wildcard_allow = try copyPatterns(perm, wild_allow, bytes, &at);
|
||||
self.wildcard_block = try copyPatterns(perm, wild_block, bytes, &at);
|
||||
self.regex_allow = try compilePatterns(perm, scratch, re_allow, bytes, &at);
|
||||
self.regex_block = try compilePatterns(perm, scratch, re_block, bytes, &at);
|
||||
|
||||
return self;
|
||||
}
|
||||
@@ -110,18 +182,35 @@ pub const RuleSet = struct {
|
||||
self.exact_block.deinit(gpa);
|
||||
gpa.free(self.wildcard_allow);
|
||||
gpa.free(self.wildcard_block);
|
||||
gpa.free(self.wildcard_bytes);
|
||||
freeRules(gpa, self.regex_allow);
|
||||
freeRules(gpa, self.regex_block);
|
||||
gpa.free(self.pattern_bytes);
|
||||
self.* = .empty;
|
||||
}
|
||||
|
||||
pub fn memoryBytes(self: *const RuleSet) usize {
|
||||
var programs: usize = 0;
|
||||
for (self.regex_allow) |item| programs += item.program.memoryBytes();
|
||||
for (self.regex_block) |item| programs += item.program.memoryBytes();
|
||||
return self.exact_allow.memoryBytes() +
|
||||
self.exact_block.memoryBytes() +
|
||||
self.wildcard_bytes.len +
|
||||
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8);
|
||||
self.pattern_bytes.len +
|
||||
programs +
|
||||
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8) +
|
||||
(self.regex_allow.len + self.regex_block.len) * @sizeOf(RegexRule);
|
||||
}
|
||||
};
|
||||
|
||||
/// The first regex of `list` that matches `domain`, or null. `list` is sorted,
|
||||
/// so "first" is stable across rebuilds of the same rows. The caller checks the
|
||||
/// allow list before the block list, as it does for wildcards.
|
||||
pub fn matchRegex(list: []const RegexRule, domain: []const u8) ?[]const u8 {
|
||||
for (list) |item| {
|
||||
if (regex.matches(&item.program, domain)) return item.pattern;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -131,15 +220,16 @@ pub const RuleSet = struct {
|
||||
const Span = struct { offset: usize, len: usize };
|
||||
|
||||
fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize {
|
||||
const kind_bit: usize = switch (kind) {
|
||||
const kind_base: usize = switch (kind) {
|
||||
.exact => 0,
|
||||
.wildcard => 2,
|
||||
.regex => 4,
|
||||
};
|
||||
const action_bit: usize = switch (action) {
|
||||
const action_offset: usize = switch (action) {
|
||||
.allow => 0,
|
||||
.block => 1,
|
||||
};
|
||||
return kind_bit + action_bit;
|
||||
return kind_base + action_offset;
|
||||
}
|
||||
|
||||
fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool {
|
||||
@@ -159,26 +249,31 @@ fn dedupSorted(list: *std.ArrayList([]const u8)) void {
|
||||
list.shrinkRetainingCapacity(kept);
|
||||
}
|
||||
|
||||
fn buildSet(gpa: Allocator, patterns: []const []const u8, seed: u64) Error!domain_set.DomainSet {
|
||||
fn buildSet(
|
||||
perm: Allocator,
|
||||
scratch: Allocator,
|
||||
patterns: []const []const u8,
|
||||
seed: u64,
|
||||
) Error!domain_set.DomainSet {
|
||||
if (patterns.len == 0) return .empty;
|
||||
|
||||
var body: std.ArrayList(u8) = .empty;
|
||||
defer body.deinit(gpa);
|
||||
defer body.deinit(scratch);
|
||||
for (patterns) |pattern| {
|
||||
try body.appendSlice(gpa, pattern);
|
||||
try body.append(gpa, '\n');
|
||||
try body.appendSlice(scratch, pattern);
|
||||
try body.append(scratch, '\n');
|
||||
}
|
||||
return domain_set.DomainSet.build(gpa, body.items, seed);
|
||||
return domain_set.DomainSet.build(perm, body.items, seed);
|
||||
}
|
||||
|
||||
fn copyPatterns(
|
||||
gpa: Allocator,
|
||||
perm: Allocator,
|
||||
patterns: []const []const u8,
|
||||
bytes: []u8,
|
||||
at: *usize,
|
||||
) Error![]const []const u8 {
|
||||
if (patterns.len == 0) return &.{};
|
||||
const out = try gpa.alloc([]const u8, patterns.len);
|
||||
const out = try perm.alloc([]const u8, patterns.len);
|
||||
for (out, patterns) |*slot, pattern| {
|
||||
@memcpy(bytes[at.*..][0..pattern.len], pattern);
|
||||
slot.* = bytes[at.*..][0..pattern.len];
|
||||
@@ -187,6 +282,49 @@ fn copyPatterns(
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Copies the pattern texts into `bytes` like `copyPatterns` and compiles each
|
||||
/// one. A compile failure is `error.BadPattern` whichever of the engine's three
|
||||
/// refusals fired: the pattern already passed `config/validate.zig`, which names
|
||||
/// the limit, so a row that fails here was written around that check.
|
||||
///
|
||||
/// Compiling into `scratch` and cloning across is what keeps a parse-time AST
|
||||
/// out of `perm`: `regex.compile` builds the AST, the child lists and the
|
||||
/// growing instruction buffer through the allocator it returns the program on.
|
||||
fn compilePatterns(
|
||||
perm: Allocator,
|
||||
scratch: Allocator,
|
||||
patterns: []const []const u8,
|
||||
bytes: []u8,
|
||||
at: *usize,
|
||||
) Error![]const RegexRule {
|
||||
if (patterns.len == 0) return &.{};
|
||||
const out = try perm.alloc(RegexRule, patterns.len);
|
||||
var built: usize = 0;
|
||||
errdefer {
|
||||
for (out[0..built]) |item| item.free(perm);
|
||||
perm.free(out);
|
||||
}
|
||||
for (out, patterns) |*slot, pattern| {
|
||||
var compiled = regex.compile(scratch, pattern) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return error.BadPattern,
|
||||
};
|
||||
defer compiled.deinit(scratch);
|
||||
const program = try compiled.clone(perm);
|
||||
|
||||
@memcpy(bytes[at.*..][0..pattern.len], pattern);
|
||||
slot.* = .{ .pattern = bytes[at.*..][0..pattern.len], .program = program };
|
||||
at.* += pattern.len;
|
||||
built += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
fn freeRules(gpa: Allocator, list: []const RegexRule) void {
|
||||
for (list) |item| item.free(gpa);
|
||||
gpa.free(list);
|
||||
}
|
||||
|
||||
const NameError = error{BadName};
|
||||
|
||||
/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
|
||||
@@ -223,7 +361,7 @@ test "exact rules land in the matching set" {
|
||||
rule("ads.example.com", .exact, .block),
|
||||
rule("good.example.com", .exact, .allow),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(set.exact_block.contains("ads.example.com"));
|
||||
@@ -239,7 +377,7 @@ test "wildcard rules land in the matching list, sorted" {
|
||||
rule("*.a.example.com", .wildcard, .block),
|
||||
rule("*.allowed.example.com", .wildcard, .allow),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), set.wildcard_block.len);
|
||||
@@ -254,7 +392,7 @@ test "patterns are normalized to lowercase without a trailing dot" {
|
||||
rule("ADS.Example.COM.", .exact, .block),
|
||||
rule("*.Tracker.NET.", .wildcard, .block),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0);
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(set.exact_block.contains("ads.example.com"));
|
||||
@@ -268,7 +406,7 @@ test "duplicate rows collapse to one entry" {
|
||||
rule("*.x.example.com", .wildcard, .block),
|
||||
rule("*.x.example.com", .wildcard, .block),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0);
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), set.exact_block.count);
|
||||
@@ -278,14 +416,98 @@ test "duplicate rows collapse to one entry" {
|
||||
test "an invalid exact pattern is an error" {
|
||||
for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| {
|
||||
const rows = [_]model.Rule{rule(pattern, .exact, .block)};
|
||||
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0));
|
||||
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
|
||||
}
|
||||
}
|
||||
|
||||
test "regex rules land in their own buckets, compiled and sorted" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("^zz", .regex, .block),
|
||||
rule("^aa", .regex, .block),
|
||||
rule("ok$", .regex, .allow),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), set.regex_block.len);
|
||||
try testing.expectEqualStrings("^aa", set.regex_block[0].pattern);
|
||||
try testing.expectEqualStrings("^zz", set.regex_block[1].pattern);
|
||||
try testing.expectEqual(@as(usize, 1), set.regex_allow.len);
|
||||
try testing.expectEqualStrings("ok$", set.regex_allow[0].pattern);
|
||||
|
||||
try testing.expectEqualStrings("^aa", matchRegex(set.regex_block, "aabb.example").?);
|
||||
try testing.expect(matchRegex(set.regex_block, "bbaa.example") == null);
|
||||
try testing.expectEqualStrings("ok$", matchRegex(set.regex_allow, "example.ok").?);
|
||||
}
|
||||
|
||||
test "a regex pattern keeps the bytes the operator wrote" {
|
||||
// `normalize` would strip the trailing dot and lowercase the escape, and
|
||||
// either edit would change what the pattern matches. The exact and wildcard
|
||||
// kinds still normalize; only this one is exempt.
|
||||
const rows = [_]model.Rule{
|
||||
rule("ADS\\.Example\\.", .regex, .block),
|
||||
rule("ADS.Example.", .exact, .block),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqualStrings("ADS\\.Example\\.", set.regex_block[0].pattern);
|
||||
try testing.expect(set.exact_block.contains("ads.example"));
|
||||
}
|
||||
|
||||
test "duplicate regex rows collapse to one compiled program" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("^ad[0-9]+-", .regex, .block),
|
||||
rule("^ad[0-9]+-", .regex, .block),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), set.regex_block.len);
|
||||
}
|
||||
|
||||
test "a regex pattern the engine refuses is an error, not a skipped row" {
|
||||
for ([_][]const u8{ "(", "", "a+?", "[z-a]", "\\s" }) |pattern| {
|
||||
const rows = [_]model.Rule{rule(pattern, .regex, .block)};
|
||||
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
|
||||
}
|
||||
|
||||
// The size limits arrive as `BadPattern` too: which one fired is
|
||||
// `config/validate.zig`'s to report, and by here the row is simply wrong.
|
||||
const long = [_]model.Rule{rule("a" ** 300, .regex, .block)};
|
||||
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &long, 0));
|
||||
const complex = [_]model.Rule{rule("(abcdefghij){200}", .regex, .block)};
|
||||
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &complex, 0));
|
||||
}
|
||||
|
||||
test "too many regex rules is an error" {
|
||||
const gpa = testing.allocator;
|
||||
const rows = try gpa.alloc(model.Rule, max_regex_per_group + 1);
|
||||
defer gpa.free(rows);
|
||||
|
||||
var patterns: std.ArrayList([]u8) = .empty;
|
||||
defer {
|
||||
for (patterns.items) |p| gpa.free(p);
|
||||
patterns.deinit(gpa);
|
||||
}
|
||||
for (rows, 0..) |*row, i| {
|
||||
const pattern = try std.fmt.allocPrint(gpa, "^n{d}-", .{i});
|
||||
try patterns.append(gpa, pattern);
|
||||
row.* = rule(pattern, .regex, .block);
|
||||
}
|
||||
|
||||
try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0));
|
||||
|
||||
// The cap counts both actions together, like the wildcard one.
|
||||
rows[0].action = .allow;
|
||||
try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0));
|
||||
try testing.expectEqual(@as(usize, 256), max_regex_per_group);
|
||||
}
|
||||
|
||||
test "an invalid wildcard pattern is an error" {
|
||||
for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| {
|
||||
const rows = [_]model.Rule{rule(pattern, .wildcard, .block)};
|
||||
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0));
|
||||
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,11 +527,11 @@ test "too many wildcards is an error" {
|
||||
row.* = rule(pattern, .wildcard, .block);
|
||||
}
|
||||
|
||||
try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, rows, 0));
|
||||
try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, gpa, rows, 0));
|
||||
}
|
||||
|
||||
test "an empty rule list builds the empty set" {
|
||||
var set = try RuleSet.build(testing.allocator, &[_]model.Rule{}, 0);
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &[_]model.Rule{}, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(!set.exact_block.contains("ads.example.com"));
|
||||
@@ -328,11 +550,63 @@ test "memoryBytes counts every part" {
|
||||
rule("ads.example.com", .exact, .block),
|
||||
rule("*.tracker.net", .wildcard, .block),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0);
|
||||
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes());
|
||||
try testing.expect(set.memoryBytes() >= "*.tracker.net".len);
|
||||
|
||||
// A compiled program is the largest thing a rule set holds, so leaving it
|
||||
// out would make the snapshot's memory report a fiction.
|
||||
const with_regex = [_]model.Rule{ rows[0], rows[1], rule("^ad[0-9]+-", .regex, .block) };
|
||||
var wider = try RuleSet.build(testing.allocator, testing.allocator, &with_regex, 0);
|
||||
defer wider.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(wider.memoryBytes() > set.memoryBytes() + "^ad[0-9]+-".len);
|
||||
try testing.expect(wider.memoryBytes() >= wider.regex_block[0].program.memoryBytes());
|
||||
}
|
||||
|
||||
test "the build's temporaries stay out of the permanent allocator" {
|
||||
// The property the two-allocator split exists for. An arena reclaims only
|
||||
// its most recent allocation, so a temporary taken from `perm` would live
|
||||
// as long as the arena and be invisible to `memoryBytes`. Two checks, one
|
||||
// per direction: `testing.allocator` fails the test if anything the set
|
||||
// retains was taken from `scratch`, and the arena's capacity fails it if
|
||||
// the build's working storage was taken from `perm`.
|
||||
const gpa = testing.allocator;
|
||||
var patterns: std.ArrayList([]u8) = .empty;
|
||||
defer {
|
||||
for (patterns.items) |p| gpa.free(p);
|
||||
patterns.deinit(gpa);
|
||||
}
|
||||
var rows: std.ArrayList(model.Rule) = .empty;
|
||||
defer rows.deinit(gpa);
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < 64) : (i += 1) {
|
||||
const regex_pattern = try std.fmt.allocPrint(gpa, "^r{d}-[0-9]+\\.ads\\.invalid$", .{i});
|
||||
try patterns.append(gpa, regex_pattern);
|
||||
try rows.append(gpa, rule(regex_pattern, .regex, .block));
|
||||
|
||||
const wild = try std.fmt.allocPrint(gpa, "*.w{d:0>5}.example.com", .{i});
|
||||
try patterns.append(gpa, wild);
|
||||
try rows.append(gpa, rule(wild, .wildcard, .block));
|
||||
|
||||
const exact = try std.fmt.allocPrint(gpa, "e{d:0>5}.example.com", .{i});
|
||||
try patterns.append(gpa, exact);
|
||||
try rows.append(gpa, rule(exact, .exact, .block));
|
||||
}
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena.deinit();
|
||||
const set = try RuleSet.build(arena.allocator(), gpa, rows.items, 0x5eed);
|
||||
|
||||
try testing.expectEqual(@as(usize, 64), set.regex_block.len);
|
||||
try testing.expect(set.exact_block.contains("e00007.example.com"));
|
||||
// Whole-arena capacity against what the set says it holds. The slack is the
|
||||
// allocator's page rounding; the defect this guards against was a factor of
|
||||
// twelve.
|
||||
try testing.expect(arena.queryCapacity() < 2 * set.memoryBytes());
|
||||
}
|
||||
|
||||
fn buildUnderFailure(gpa: Allocator) !void {
|
||||
@@ -341,11 +615,14 @@ fn buildUnderFailure(gpa: Allocator) !void {
|
||||
rule("good.example.com", .exact, .allow),
|
||||
rule("*.tracker.net", .wildcard, .block),
|
||||
rule("*.ok.tracker.net", .wildcard, .allow),
|
||||
rule("^ad[0-9]+-", .regex, .block),
|
||||
rule("\\.ok\\.", .regex, .allow),
|
||||
};
|
||||
var set = try RuleSet.build(gpa, &rows, 0x5eed);
|
||||
var set = try RuleSet.build(gpa, gpa, &rows, 0x5eed);
|
||||
defer set.deinit(gpa);
|
||||
try testing.expect(set.exact_block.contains("ads.example.com"));
|
||||
try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
|
||||
try testing.expectEqualStrings("^ad[0-9]+-", set.regex_block[0].pattern);
|
||||
}
|
||||
|
||||
test "build leaks nothing under allocation failure" {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! A pattern is a domain name in which one or more labels are exactly `*`.
|
||||
//! Each `*` label matches one or more labels of the queried name. Partial-label
|
||||
//! globbing (`ad*.example.com`) is deliberately absent: it is regex by another
|
||||
//! name, which PLAN §2.2 rules out.
|
||||
//! name, and PLAN §2.2 keeps one regex dialect rather than two. An operator who
|
||||
//! needs one writes a `.regex` rule, which `filter/regex.zig` compiles.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
@@ -18,8 +19,9 @@ pub const PatternError = error{
|
||||
/// No label is exactly "*".
|
||||
NoWildcard,
|
||||
/// A label contains '*' but is not exactly "*". Partial-label globbing
|
||||
/// (`ad*.example.com`) is out of scope: it is regex by another name, and
|
||||
/// PLAN §3.9 defines the wildcard as a label pattern.
|
||||
/// (`ad*.example.com`) is out of scope: PLAN §3.9 defines the wildcard as a
|
||||
/// label pattern, and the `.regex` kind covers what partial globbing was
|
||||
/// wanted for.
|
||||
PartialWildcardLabel,
|
||||
EmptyLabel,
|
||||
LabelTooLong,
|
||||
|
||||
Reference in New Issue
Block a user