milestone 5: blocklist filtering, local records and conditional forwarding
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
//! Compiles a downloaded blocklist into the two bodies nxdns stores on disk:
|
||||
//! a `.list` body of exact names and a `.wild` body of suffixes.
|
||||
//!
|
||||
//! 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
|
||||
//! 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.
|
||||
|
||||
const std = @import("std");
|
||||
const parsers = @import("parsers.zig");
|
||||
const name = @import("../dns/name.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
|
||||
const Sha256 = std.crypto.hash.sha2.Sha256;
|
||||
|
||||
pub const max_domains: u32 = 2_000_000;
|
||||
pub const max_line_len: usize = 4096;
|
||||
|
||||
pub const Counts = struct {
|
||||
domains: u32 = 0,
|
||||
wildcards: u32 = 0,
|
||||
skipped_regex: u32 = 0,
|
||||
skipped_unsupported: u32 = 0,
|
||||
/// Not a valid domain name (`dns.name.fromText` rejected it, a non-ASCII
|
||||
/// byte, or fewer than two labels).
|
||||
invalid: u32 = 0,
|
||||
/// Lines longer than `max_line_len`, skipped whole.
|
||||
long_lines: u32 = 0,
|
||||
/// Duplicates removed by the sort/unique pass.
|
||||
duplicates: u32 = 0,
|
||||
};
|
||||
|
||||
pub const Result = struct {
|
||||
counts: Counts,
|
||||
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body.
|
||||
checksum: [64]u8,
|
||||
};
|
||||
|
||||
pub const Error = error{ OutOfMemory, TooManyDomains, ReadFailed, WriteFailed };
|
||||
|
||||
/// Reads `r` to end of stream and writes the two 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.
|
||||
pub fn compile(
|
||||
gpa: std.mem.Allocator,
|
||||
r: *std.Io.Reader,
|
||||
format: parsers.Format,
|
||||
list_w: *std.Io.Writer,
|
||||
wild_w: *std.Io.Writer,
|
||||
) Error!Result {
|
||||
var counts: Counts = .{};
|
||||
|
||||
var list: Entries = .{};
|
||||
defer list.deinit(gpa);
|
||||
var wild: Entries = .{};
|
||||
defer wild.deinit(gpa);
|
||||
|
||||
while (true) {
|
||||
const raw = r.takeDelimiter('\n') catch |err| switch (err) {
|
||||
error.ReadFailed => return error.ReadFailed,
|
||||
// `takeDelimiter` leaves the stream unmodified on `StreamTooLong`
|
||||
// (Reader.zig:885). Without this discard the loop re-reads the same
|
||||
// bytes forever.
|
||||
error.StreamTooLong => {
|
||||
counts.long_lines += 1;
|
||||
_ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) {
|
||||
error.EndOfStream => break,
|
||||
error.ReadFailed => return error.ReadFailed,
|
||||
};
|
||||
continue;
|
||||
},
|
||||
} orelse break;
|
||||
|
||||
var line = raw;
|
||||
if (line.len != 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
|
||||
// A reader whose buffer is larger than `max_line_len` reports the
|
||||
// over-long line here instead of through `error.StreamTooLong`.
|
||||
if (line.len > max_line_len) {
|
||||
counts.long_lines += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parsers.parseLine(format, line);
|
||||
switch (parsed.kind) {
|
||||
.ignore => {},
|
||||
.regex => counts.skipped_regex += 1,
|
||||
.unsupported => counts.skipped_unsupported += 1,
|
||||
.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);
|
||||
}
|
||||
},
|
||||
.wildcard => try addCandidate(
|
||||
gpa,
|
||||
parsed.text,
|
||||
true,
|
||||
parsed.covers_apex,
|
||||
&list,
|
||||
&wild,
|
||||
&counts,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
var hasher = Sha256.init(.{});
|
||||
counts.domains = try emit(&list, list_w, &hasher, &counts.duplicates);
|
||||
counts.wildcards = try emit(&wild, wild_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.
|
||||
fn addCandidate(
|
||||
gpa: std.mem.Allocator,
|
||||
field: []const u8,
|
||||
from_wildcard_line: bool,
|
||||
covers_apex: bool,
|
||||
list: *Entries,
|
||||
wild: *Entries,
|
||||
counts: *Counts,
|
||||
) Error!void {
|
||||
var candidate = field;
|
||||
var is_wildcard = from_wildcard_line;
|
||||
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.
|
||||
if (std.mem.indexOfScalar(u8, candidate, '*') != null) {
|
||||
counts.invalid += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidate.len != 0 and candidate[candidate.len - 1] == '.') {
|
||||
candidate = candidate[0 .. candidate.len - 1];
|
||||
}
|
||||
if (candidate.len == 0 or candidate.len > types.max_name_len) {
|
||||
counts.invalid += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var buf: [types.max_name_len]u8 = undefined;
|
||||
for (candidate, 0..) |c, i| {
|
||||
if (c >= 0x80 or std.ascii.isControl(c)) {
|
||||
counts.invalid += 1;
|
||||
return;
|
||||
}
|
||||
buf[i] = std.ascii.toLower(c);
|
||||
}
|
||||
const normalized = buf[0..candidate.len];
|
||||
|
||||
const parsed_name = name.fromText(normalized) catch {
|
||||
counts.invalid += 1;
|
||||
return;
|
||||
};
|
||||
// The two-label minimum is what keeps `localhost`, `local`, `broadcasthost`
|
||||
// and the `ip6-*` names that every hosts list carries from black-holing the
|
||||
// loopback names of every client on the LAN.
|
||||
if (parsed_name.labelCount() < 2) {
|
||||
counts.invalid += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_wildcard) {
|
||||
try wild.append(gpa, normalized);
|
||||
// An ABP `||x^` rule covers `x` itself as well as its subdomains.
|
||||
if (covers_apex) try list.append(gpa, normalized);
|
||||
} else {
|
||||
try list.append(gpa, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts, deduplicates, writes and hashes one body. Returns the written count.
|
||||
fn emit(
|
||||
entries: *Entries,
|
||||
w: *std.Io.Writer,
|
||||
hasher: *Sha256,
|
||||
duplicates: *u32,
|
||||
) Error!u32 {
|
||||
const offsets = entries.offsets.items;
|
||||
if (offsets.len > 1) std.mem.sortUnstableContext(0, offsets.len, SortContext{ .entries = entries });
|
||||
|
||||
var written: u32 = 0;
|
||||
var prev: []const u8 = "";
|
||||
var first = true;
|
||||
for (0..offsets.len) |i| {
|
||||
const text = entries.get(i);
|
||||
if (!first and std.mem.eql(u8, prev, text)) {
|
||||
duplicates.* += 1;
|
||||
continue;
|
||||
}
|
||||
try w.writeAll(text);
|
||||
try w.writeByte('\n');
|
||||
hasher.update(text);
|
||||
hasher.update("\n");
|
||||
prev = text;
|
||||
first = false;
|
||||
written += 1;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/// Length-prefixed candidate bytes plus the offsets that index them. Sorting
|
||||
/// permutes the offsets, so the bytes never move.
|
||||
const Entries = struct {
|
||||
bytes: std.ArrayList(u8) = .empty,
|
||||
offsets: std.ArrayList(u32) = .empty,
|
||||
|
||||
fn deinit(self: *Entries, gpa: std.mem.Allocator) void {
|
||||
self.bytes.deinit(gpa);
|
||||
self.offsets.deinit(gpa);
|
||||
}
|
||||
|
||||
fn append(self: *Entries, gpa: std.mem.Allocator, text: []const u8) Error!void {
|
||||
if (self.offsets.items.len >= max_domains) return error.TooManyDomains;
|
||||
const offset = std.math.cast(u32, self.bytes.items.len) orelse return error.TooManyDomains;
|
||||
try self.bytes.append(gpa, @intCast(text.len));
|
||||
try self.bytes.appendSlice(gpa, text);
|
||||
try self.offsets.append(gpa, offset);
|
||||
}
|
||||
|
||||
fn get(self: *const Entries, i: usize) []const u8 {
|
||||
const offset = self.offsets.items[i];
|
||||
const len = self.bytes.items[offset];
|
||||
return self.bytes.items[offset + 1 ..][0..len];
|
||||
}
|
||||
};
|
||||
|
||||
const SortContext = struct {
|
||||
entries: *Entries,
|
||||
|
||||
pub fn lessThan(self: SortContext, a: usize, b: usize) bool {
|
||||
return std.mem.order(u8, self.entries.get(a), self.entries.get(b)) == .lt;
|
||||
}
|
||||
|
||||
pub fn swap(self: SortContext, a: usize, b: usize) void {
|
||||
const offsets = self.entries.offsets.items;
|
||||
std.mem.swap(u32, &offsets[a], &offsets[b]);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const Compiled = struct {
|
||||
result: Result,
|
||||
list_w: std.Io.Writer.Allocating,
|
||||
wild_w: std.Io.Writer.Allocating,
|
||||
|
||||
fn deinit(self: *Compiled) void {
|
||||
self.list_w.deinit();
|
||||
self.wild_w.deinit();
|
||||
}
|
||||
|
||||
fn list(self: *Compiled) []const u8 {
|
||||
return self.list_w.written();
|
||||
}
|
||||
|
||||
fn wild(self: *Compiled) []const u8 {
|
||||
return self.wild_w.written();
|
||||
}
|
||||
};
|
||||
|
||||
fn compileText(gpa: std.mem.Allocator, text: []const u8, format: parsers.Format) Error!Compiled {
|
||||
var r: std.Io.Reader = .fixed(text);
|
||||
return compileReader(gpa, &r, format);
|
||||
}
|
||||
|
||||
fn compileReader(gpa: std.mem.Allocator, r: *std.Io.Reader, format: parsers.Format) Error!Compiled {
|
||||
var list_w: std.Io.Writer.Allocating = .init(gpa);
|
||||
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 };
|
||||
}
|
||||
|
||||
const hosts_fixture =
|
||||
"# a comment\n" ++
|
||||
"0.0.0.0 ads.example.com\n" ++
|
||||
"0.0.0.0 ads.example.com\n" ++
|
||||
"127.0.0.1 localhost\n" ++
|
||||
"0.0.0.0 tracker.example.org # tracker\n" ++
|
||||
"/ads\\d+/\n" ++
|
||||
"\n" ++
|
||||
"0.0.0.0 EXAMPLE.com.\n";
|
||||
|
||||
test "hosts fixture compiles to a sorted deduplicated body" {
|
||||
var c = try compileText(testing.allocator, hosts_fixture, .hosts);
|
||||
defer c.deinit();
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"ads.example.com\nexample.com\ntracker.example.org\n",
|
||||
c.list(),
|
||||
);
|
||||
try testing.expectEqualStrings("", c.wild());
|
||||
try testing.expectEqual(@as(u32, 3), c.result.counts.domains);
|
||||
try testing.expectEqual(@as(u32, 0), c.result.counts.wildcards);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
|
||||
try testing.expectEqual(@as(u32, 0), c.result.counts.skipped_unsupported);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
|
||||
try testing.expectEqual(@as(u32, 0), c.result.counts.long_lines);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.duplicates);
|
||||
}
|
||||
|
||||
test "domains fixture compiles" {
|
||||
const fixture =
|
||||
"# a comment\n" ++
|
||||
"b.example.com\n" ++
|
||||
"! a bang comment\n" ++
|
||||
"a.example.com\n" ++
|
||||
"/re/\n" ++
|
||||
"0.0.0.0 two.fields.com\n" ++
|
||||
"localhost\n";
|
||||
|
||||
var c = try compileText(testing.allocator, fixture, .domains);
|
||||
defer c.deinit();
|
||||
|
||||
try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
|
||||
try testing.expectEqualStrings("", c.wild());
|
||||
try testing.expectEqual(@as(u32, 2), c.result.counts.domains);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
|
||||
}
|
||||
|
||||
test "abp apex rule lands in both bodies" {
|
||||
const fixture =
|
||||
"! Title: test\n" ++
|
||||
"||x.com^\n" ++
|
||||
"||y.com^$third-party\n" ++
|
||||
"@@||z.com^\n" ++
|
||||
"/re/\n" ++
|
||||
"bare.com\n";
|
||||
|
||||
var c = try compileText(testing.allocator, fixture, .abp);
|
||||
defer c.deinit();
|
||||
|
||||
try testing.expectEqualStrings("bare.com\nx.com\n", c.list());
|
||||
try testing.expectEqualStrings("x.com\n", c.wild());
|
||||
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.skipped_regex);
|
||||
try testing.expectEqual(@as(u32, 2), c.result.counts.skipped_unsupported);
|
||||
}
|
||||
|
||||
test "two runs of the same input are byte-identical" {
|
||||
var a = try compileText(testing.allocator, hosts_fixture, .hosts);
|
||||
defer a.deinit();
|
||||
var b = try compileText(testing.allocator, hosts_fixture, .hosts);
|
||||
defer b.deinit();
|
||||
|
||||
try testing.expectEqualStrings(a.list(), b.list());
|
||||
try testing.expectEqualStrings(a.wild(), b.wild());
|
||||
try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
|
||||
}
|
||||
|
||||
test "a permutation of the input compiles to the same bodies" {
|
||||
const shuffled =
|
||||
"0.0.0.0 EXAMPLE.com.\n" ++
|
||||
"/ads\\d+/\n" ++
|
||||
"0.0.0.0 tracker.example.org # tracker\n" ++
|
||||
"\n" ++
|
||||
"0.0.0.0 ads.example.com\n" ++
|
||||
"127.0.0.1 localhost\n" ++
|
||||
"# a comment\n" ++
|
||||
"0.0.0.0 ads.example.com\n";
|
||||
|
||||
var a = try compileText(testing.allocator, hosts_fixture, .hosts);
|
||||
defer a.deinit();
|
||||
var b = try compileText(testing.allocator, shuffled, .hosts);
|
||||
defer b.deinit();
|
||||
|
||||
try testing.expectEqualStrings(a.list(), b.list());
|
||||
try testing.expectEqualStrings(a.wild(), b.wild());
|
||||
try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
|
||||
}
|
||||
|
||||
test "uppercase and trailing dot normalize" {
|
||||
var c = try compileText(testing.allocator, "AdS.Example.COM.\n", .domains);
|
||||
defer c.deinit();
|
||||
try testing.expectEqualStrings("ads.example.com\n", c.list());
|
||||
}
|
||||
|
||||
test "invalid candidates are counted and written nowhere" {
|
||||
const fixture =
|
||||
"caf\xc3\xa9.example.com\n" ++
|
||||
"localhost\n" ++
|
||||
"a*b.com\n";
|
||||
|
||||
var c = try compileText(testing.allocator, fixture, .domains);
|
||||
defer c.deinit();
|
||||
|
||||
try testing.expectEqual(@as(u32, 3), c.result.counts.invalid);
|
||||
try testing.expectEqual(@as(u32, 0), c.result.counts.domains);
|
||||
try testing.expectEqualStrings("", c.list());
|
||||
try testing.expectEqualStrings("", c.wild());
|
||||
}
|
||||
|
||||
test "a leading star label becomes a wildcard entry" {
|
||||
var c = try compileText(testing.allocator, "*.ads.example.com\n", .domains);
|
||||
defer c.deinit();
|
||||
try testing.expectEqualStrings("", c.list());
|
||||
try testing.expectEqualStrings("ads.example.com\n", c.wild());
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.wildcards);
|
||||
}
|
||||
|
||||
test "an over-long line is skipped whole" {
|
||||
const gpa = testing.allocator;
|
||||
var text: std.ArrayList(u8) = .empty;
|
||||
defer text.deinit(gpa);
|
||||
|
||||
var line_buf: [64]u8 = undefined;
|
||||
var i: usize = 0;
|
||||
while (i < 3_000) : (i += 1) {
|
||||
if (i == 1_500) {
|
||||
try text.appendNTimes(gpa, 'x', 5_000);
|
||||
try text.append(gpa, '\n');
|
||||
}
|
||||
try text.appendSlice(gpa, try std.fmt.bufPrint(&line_buf, "d{d:0>5}.example.com\n", .{i}));
|
||||
}
|
||||
|
||||
// A reader buffer smaller than the long line makes `takeDelimiter` report
|
||||
// `error.StreamTooLong`, which is the path that loops forever without the
|
||||
// discard.
|
||||
var backing: std.Io.Reader = .fixed(text.items);
|
||||
var buf: [max_line_len]u8 = undefined;
|
||||
var limited = backing.limited(.unlimited, &buf);
|
||||
|
||||
var c = try compileReader(gpa, &limited.interface, .domains);
|
||||
defer c.deinit();
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.long_lines);
|
||||
try testing.expectEqual(@as(u32, 3_000), c.result.counts.domains);
|
||||
try testing.expect(std.mem.startsWith(u8, c.list(), "d00000.example.com\n"));
|
||||
try testing.expect(std.mem.endsWith(u8, c.list(), "d02999.example.com\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, c.list(), "d01499.example.com\n") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, c.list(), "d01500.example.com\n") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, c.list(), "xxxx") == null);
|
||||
}
|
||||
|
||||
test "an over-long line is skipped when the reader buffer is large" {
|
||||
const gpa = testing.allocator;
|
||||
var text: std.ArrayList(u8) = .empty;
|
||||
defer text.deinit(gpa);
|
||||
|
||||
try text.appendSlice(gpa, "a.example.com\n");
|
||||
try text.appendNTimes(gpa, 'x', 5_000);
|
||||
try text.append(gpa, '\n');
|
||||
try text.appendSlice(gpa, "b.example.com\n");
|
||||
|
||||
var c = try compileText(gpa, text.items, .domains);
|
||||
defer c.deinit();
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), c.result.counts.long_lines);
|
||||
try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
|
||||
}
|
||||
|
||||
test "carriage returns are stripped" {
|
||||
var c = try compileText(testing.allocator, "b.example.com\r\na.example.com\r\n", .domains);
|
||||
defer c.deinit();
|
||||
try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
|
||||
}
|
||||
|
||||
test "empty input produces empty bodies and the sha256 of the empty string" {
|
||||
var c = try compileText(testing.allocator, "", .domains);
|
||||
defer c.deinit();
|
||||
|
||||
try testing.expectEqualStrings("", c.list());
|
||||
try testing.expectEqualStrings("", c.wild());
|
||||
try testing.expectEqualStrings(
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
&c.result.checksum,
|
||||
);
|
||||
}
|
||||
|
||||
fn compileUnderFailure(gpa: std.mem.Allocator) !void {
|
||||
var c = compileText(gpa, hosts_fixture, .hosts) catch |err| switch (err) {
|
||||
// `Writer.Allocating` reports an exhausted allocator as `WriteFailed`;
|
||||
// it has no other failure mode.
|
||||
error.WriteFailed => return error.OutOfMemory,
|
||||
else => |e| return e,
|
||||
};
|
||||
defer c.deinit();
|
||||
try testing.expectEqual(@as(u32, 3), c.result.counts.domains);
|
||||
}
|
||||
|
||||
test "compile under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, compileUnderFailure, .{});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! An immutable exact-match set of domain names, built from a compiled
|
||||
//! blocklist body.
|
||||
//!
|
||||
//! The layout is a flat arena of length-prefixed names plus an open-addressed
|
||||
//! table of `u32` offsets into it. No Bloom filter and no hash-only key set: a
|
||||
//! false positive in a DNS sinkhole blocks a real domain for a real household
|
||||
//! and is undebuggable from the outside, so every probe compares full bytes.
|
||||
//!
|
||||
//! Pure. Takes an allocator and bytes; no `std.Io`, no clock, no entropy
|
||||
//! source — the hash seed arrives as a parameter.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const DomainSet = struct {
|
||||
/// Length-prefixed lowercase names, back to back: [len: u8][bytes]…
|
||||
arena: []const u8,
|
||||
/// Open-addressed table of offsets into `arena`; `empty_slot` marks a hole.
|
||||
/// Length is always a power of two.
|
||||
index: []const u32,
|
||||
count: u32,
|
||||
seed: u64,
|
||||
|
||||
pub const empty_slot: u32 = std.math.maxInt(u32);
|
||||
pub const max_count: u32 = 4_000_000;
|
||||
pub const max_arena_bytes: usize = 1 << 31;
|
||||
|
||||
pub const Error = error{ OutOfMemory, TooManyDomains, SetTooLarge, NotSorted, NotLowercase };
|
||||
|
||||
/// An empty set that owns nothing. `contains` on it is always false.
|
||||
pub const empty: DomainSet = .{
|
||||
.arena = &.{},
|
||||
.index = &.{},
|
||||
.count = 0,
|
||||
.seed = 0,
|
||||
};
|
||||
|
||||
/// Builds from a compiled body: LF-separated, lowercase, sorted ascending,
|
||||
/// deduplicated, every line 1–255 bytes.
|
||||
///
|
||||
/// The sortedness is verified, not assumed: a hand-edited or truncated file
|
||||
/// must fail loudly rather than produce a set that silently misses entries.
|
||||
/// Verification is one comparison per line. Because a valid line is at least
|
||||
/// one byte, comparing against an initial empty `prev` also rejects an empty
|
||||
/// line and a duplicate line — both are `error.NotSorted`, since neither is
|
||||
/// strictly ascending.
|
||||
///
|
||||
/// A line longer than 255 bytes is `error.SetTooLarge`: the arena stores a
|
||||
/// `u8` length prefix and must not silently truncate, for the same reason
|
||||
/// the `u32` offsets bound `max_arena_bytes`.
|
||||
///
|
||||
/// `seed` randomizes the hash. Query names are attacker-supplied, so a fixed
|
||||
/// seed would make probe-chain flooding computable offline.
|
||||
pub fn build(gpa: std.mem.Allocator, body: []const u8, seed: u64) Error!DomainSet {
|
||||
if (body.len == 0) return empty;
|
||||
if (body.len > max_arena_bytes) return error.SetTooLarge;
|
||||
|
||||
var count: u32 = 0;
|
||||
var arena_len: usize = 0;
|
||||
{
|
||||
var prev: []const u8 = "";
|
||||
var it: LineIterator = .{ .rest = body };
|
||||
while (it.next()) |line| {
|
||||
for (line) |c| if (c >= 'A' and c <= 'Z') return error.NotLowercase;
|
||||
if (line.len > std.math.maxInt(u8)) return error.SetTooLarge;
|
||||
if (std.mem.order(u8, prev, line) != .lt) return error.NotSorted;
|
||||
if (count == max_count) return error.TooManyDomains;
|
||||
prev = line;
|
||||
count += 1;
|
||||
arena_len += 1 + line.len;
|
||||
}
|
||||
}
|
||||
|
||||
const capacity = capacityFor(count);
|
||||
|
||||
const arena = try gpa.alloc(u8, arena_len);
|
||||
errdefer gpa.free(arena);
|
||||
const index = try gpa.alloc(u32, capacity);
|
||||
@memset(index, empty_slot);
|
||||
|
||||
const mask = capacity - 1;
|
||||
var write_at: usize = 0;
|
||||
var it: LineIterator = .{ .rest = body };
|
||||
while (it.next()) |line| {
|
||||
const offset: u32 = @intCast(write_at);
|
||||
arena[write_at] = @intCast(line.len);
|
||||
@memcpy(arena[write_at + 1 ..][0..line.len], line);
|
||||
write_at += 1 + line.len;
|
||||
|
||||
var slot: usize = @as(usize, @truncate(std.hash.Wyhash.hash(seed, line))) & mask;
|
||||
while (index[slot] != empty_slot) slot = (slot + 1) & mask;
|
||||
index[slot] = offset;
|
||||
}
|
||||
|
||||
return .{ .arena = arena, .index = index, .count = count, .seed = seed };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DomainSet, gpa: std.mem.Allocator) void {
|
||||
gpa.free(self.arena);
|
||||
gpa.free(self.index);
|
||||
self.* = empty;
|
||||
}
|
||||
|
||||
/// `domain` must be normalized (lowercase, no trailing dot). Allocation-free.
|
||||
pub fn contains(self: *const DomainSet, domain: []const u8) bool {
|
||||
if (self.index.len == 0) return false;
|
||||
const mask = self.index.len - 1;
|
||||
var slot: usize = @as(usize, @truncate(std.hash.Wyhash.hash(self.seed, domain))) & mask;
|
||||
while (true) {
|
||||
const offset = self.index[slot];
|
||||
if (offset == empty_slot) return false;
|
||||
const len = self.arena[offset];
|
||||
if (len == domain.len and std.mem.eql(u8, self.arena[offset + 1 ..][0..len], domain)) {
|
||||
return true;
|
||||
}
|
||||
slot = (slot + 1) & mask;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes held, for the memory report in `Snapshot.memoryBytes`.
|
||||
pub fn memoryBytes(self: *const DomainSet) usize {
|
||||
return self.arena.len + self.index.len * @sizeOf(u32);
|
||||
}
|
||||
};
|
||||
|
||||
/// Smallest power of two at least `count * 4 / 3`, minimum 16. The load factor
|
||||
/// stays at or under 0.75, which keeps at least one hole and therefore
|
||||
/// terminates the probe loop in `contains`.
|
||||
fn capacityFor(count: u32) usize {
|
||||
const wanted = (@as(u64, count) * 4 + 2) / 3;
|
||||
var capacity: usize = 16;
|
||||
while (capacity < wanted) capacity *= 2;
|
||||
return capacity;
|
||||
}
|
||||
|
||||
/// Yields LF-separated lines. A final line without a trailing LF is yielded;
|
||||
/// a trailing LF does not yield a final empty line.
|
||||
const LineIterator = struct {
|
||||
rest: []const u8,
|
||||
|
||||
fn next(self: *LineIterator) ?[]const u8 {
|
||||
if (self.rest.len == 0) return null;
|
||||
if (std.mem.indexOfScalar(u8, self.rest, '\n')) |nl| {
|
||||
defer self.rest = self.rest[nl + 1 ..];
|
||||
return self.rest[0..nl];
|
||||
}
|
||||
defer self.rest = self.rest[self.rest.len..];
|
||||
return self.rest;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const small_body =
|
||||
"ads.example.com\n" ++
|
||||
"example.com\n" ++
|
||||
"tracker.example.org\n" ++
|
||||
"zzz.example.net\n";
|
||||
|
||||
test "build and contains over a small body" {
|
||||
var set = try DomainSet.build(testing.allocator, small_body, 0x1234);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(u32, 4), set.count);
|
||||
try testing.expect(set.contains("ads.example.com"));
|
||||
try testing.expect(set.contains("example.com"));
|
||||
try testing.expect(set.contains("tracker.example.org"));
|
||||
try testing.expect(set.contains("zzz.example.net"));
|
||||
|
||||
// A prefix, a suffix, an uppercase spelling and the empty string.
|
||||
try testing.expect(!set.contains("example.co"));
|
||||
try testing.expect(!set.contains("example.com.evil.net"));
|
||||
try testing.expect(!set.contains("Example.com"));
|
||||
try testing.expect(!set.contains(""));
|
||||
}
|
||||
|
||||
test "build rejects an unsorted body" {
|
||||
try testing.expectError(
|
||||
error.NotSorted,
|
||||
DomainSet.build(testing.allocator, "b.example.com\na.example.com\n", 0),
|
||||
);
|
||||
}
|
||||
|
||||
test "build rejects an uppercase byte" {
|
||||
try testing.expectError(
|
||||
error.NotLowercase,
|
||||
DomainSet.build(testing.allocator, "a.example.com\nB.example.com\n", 0),
|
||||
);
|
||||
}
|
||||
|
||||
test "build rejects a duplicate line" {
|
||||
try testing.expectError(
|
||||
error.NotSorted,
|
||||
DomainSet.build(testing.allocator, "a.example.com\na.example.com\n", 0),
|
||||
);
|
||||
}
|
||||
|
||||
test "build rejects an over-long line" {
|
||||
var body: [300]u8 = undefined;
|
||||
@memset(&body, 'a');
|
||||
body[299] = '\n';
|
||||
try testing.expectError(error.SetTooLarge, DomainSet.build(testing.allocator, &body, 0));
|
||||
}
|
||||
|
||||
test "contains does not depend on the seed" {
|
||||
var a = try DomainSet.build(testing.allocator, small_body, 0);
|
||||
defer a.deinit(testing.allocator);
|
||||
var b = try DomainSet.build(testing.allocator, small_body, 0xdead_beef_cafe_f00d);
|
||||
defer b.deinit(testing.allocator);
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
var i: usize = 0;
|
||||
while (i < 50) : (i += 1) {
|
||||
const probe = try std.fmt.bufPrint(&buf, "n{d}.example.com", .{i});
|
||||
try testing.expectEqual(a.contains(probe), b.contains(probe));
|
||||
}
|
||||
for ([_][]const u8{ "ads.example.com", "example.com", "zzz.example.net", "nope.test" }) |probe| {
|
||||
try testing.expectEqual(a.contains(probe), b.contains(probe));
|
||||
}
|
||||
}
|
||||
|
||||
test "ten thousand names round-trip" {
|
||||
const gpa = testing.allocator;
|
||||
var body: std.ArrayList(u8) = .empty;
|
||||
defer body.deinit(gpa);
|
||||
|
||||
// Fixed-width zero padding makes the generated order the sorted order.
|
||||
var line_buf: [64]u8 = undefined;
|
||||
var i: usize = 0;
|
||||
while (i < 10_000) : (i += 1) {
|
||||
try body.appendSlice(gpa, try std.fmt.bufPrint(&line_buf, "d{d:0>5}.example.com\n", .{i}));
|
||||
}
|
||||
|
||||
var set = try DomainSet.build(gpa, body.items, 0x5eed);
|
||||
defer set.deinit(gpa);
|
||||
try testing.expectEqual(@as(u32, 10_000), set.count);
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
i = 0;
|
||||
while (i < 10_000) : (i += 1) {
|
||||
const nameStr = try std.fmt.bufPrint(&buf, "d{d:0>5}.example.com", .{i});
|
||||
try testing.expect(set.contains(nameStr));
|
||||
}
|
||||
try testing.expect(!set.contains("d10000.example.com"));
|
||||
try testing.expect(set.memoryBytes() < 2 * body.items.len);
|
||||
}
|
||||
|
||||
fn buildUnderFailure(gpa: std.mem.Allocator) !void {
|
||||
var set = try DomainSet.build(gpa, small_body, 0x1234);
|
||||
defer set.deinit(gpa);
|
||||
try testing.expect(set.contains("example.com"));
|
||||
}
|
||||
|
||||
test "build under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
|
||||
}
|
||||
|
||||
test "the empty set owns nothing" {
|
||||
var set: DomainSet = .empty;
|
||||
try testing.expect(!set.contains("x"));
|
||||
try testing.expect(!set.contains(""));
|
||||
try testing.expectEqual(@as(usize, 0), set.memoryBytes());
|
||||
set.deinit(testing.allocator);
|
||||
try testing.expect(!set.contains("x"));
|
||||
}
|
||||
|
||||
test "an empty body builds the empty set" {
|
||||
var set = try DomainSet.build(testing.allocator, "", 7);
|
||||
defer set.deinit(testing.allocator);
|
||||
try testing.expectEqual(@as(u32, 0), set.count);
|
||||
try testing.expect(!set.contains("x"));
|
||||
}
|
||||
|
||||
test "a final line without a trailing newline is kept" {
|
||||
var set = try DomainSet.build(testing.allocator, "a.example.com\nb.example.com", 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
try testing.expectEqual(@as(u32, 2), set.count);
|
||||
try testing.expect(set.contains("b.example.com"));
|
||||
}
|
||||
|
||||
test "capacityFor keeps the load factor at or under three quarters" {
|
||||
try testing.expectEqual(@as(usize, 16), capacityFor(0));
|
||||
try testing.expectEqual(@as(usize, 16), capacityFor(12));
|
||||
try testing.expectEqual(@as(usize, 32), capacityFor(13));
|
||||
try testing.expectEqual(@as(usize, 2 << 20), capacityFor(1_000_000));
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Blocklist download over HTTP/1.1.
|
||||
//!
|
||||
//! One `Fetcher` wraps a caller-owned `std.http.Client`, which owns the
|
||||
//! connection pool and the CA bundle, exactly as `upstream/doh_client.zig`
|
||||
//! does. This file knows nothing about parsing, files or the database: it GETs
|
||||
//! a URL and streams the bytes into a writer the caller supplies.
|
||||
//!
|
||||
//! The body is never held whole. A blocklist can reach `max_body_bytes`, and
|
||||
//! the caller writes into a temporary file anyway, so nothing here allocates.
|
||||
//!
|
||||
//! There is no timeout parameter and no sleep. `std.http.Client` has no
|
||||
//! per-request deadline, so the caller runs `fetch` under `io.concurrent` and
|
||||
//! cancels the future; this file only propagates `error.Canceled`.
|
||||
|
||||
const std = @import("std");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
pub const max_body_bytes: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// RFC 9110 recommends at least 8000 bytes for the redirect buffer
|
||||
/// (`std.http.Client` doc comment, Client.zig:1128).
|
||||
pub const redirect_buffer_len: usize = 8192;
|
||||
|
||||
pub const min_transfer_buf: usize = 16 * 1024;
|
||||
|
||||
pub const Error = error{
|
||||
BadUrl,
|
||||
ConnectFailed,
|
||||
TlsFailed,
|
||||
SendFailed,
|
||||
ReceiveFailed,
|
||||
HttpStatus,
|
||||
BodyTooLarge,
|
||||
Timeout,
|
||||
Canceled,
|
||||
OutOfMemory,
|
||||
SystemResources,
|
||||
Unexpected,
|
||||
};
|
||||
|
||||
pub const Result = struct {
|
||||
bytes_read: u64,
|
||||
status: std.http.Status,
|
||||
};
|
||||
|
||||
pub const Fetcher = struct {
|
||||
/// Caller-owned; shared across sources, pools connections.
|
||||
http: *std.http.Client,
|
||||
/// Caller-owned HTTP body transfer buffer, at least `min_transfer_buf`.
|
||||
transfer_buf: []u8,
|
||||
/// Caller-owned. `receiveHead` follows redirects itself and needs this to
|
||||
/// outlive `Request.uri`. At least `redirect_buffer_len`.
|
||||
redirect_buf: []u8,
|
||||
/// The status of the most recent response head, or null before the first
|
||||
/// one. `error.HttpStatus` carries no `Result`, and the operator's message
|
||||
/// needs the number, so it is readable here after a failed `fetch`.
|
||||
last_status: ?std.http.Status = null,
|
||||
|
||||
/// GETs `url` and streams the body into `w`.
|
||||
pub fn fetch(
|
||||
self: *Fetcher,
|
||||
io: std.Io,
|
||||
url: []const u8,
|
||||
w: *std.Io.Writer,
|
||||
) Error!Result {
|
||||
// `std.http.Client` carries the `std.Io` it was constructed with and
|
||||
// takes none per request. The parameter stays in the signature because
|
||||
// the manager drives every fetch through one `std.Io`.
|
||||
_ = io;
|
||||
|
||||
std.debug.assert(self.transfer_buf.len >= min_transfer_buf);
|
||||
std.debug.assert(self.redirect_buf.len >= redirect_buffer_len);
|
||||
|
||||
const uri = try parseUrl(url);
|
||||
|
||||
self.last_status = null;
|
||||
|
||||
var req = self.http.request(.GET, uri, .{
|
||||
.keep_alive = true,
|
||||
.headers = .{
|
||||
// Identity only: a compressed transfer encoding would need
|
||||
// `Response.readerDecompressing`, a decompression buffer and a
|
||||
// second failure surface, for a download that runs once a day.
|
||||
.accept_encoding = .{ .override = "identity" },
|
||||
},
|
||||
}) catch |err| return mapError(err, .connect);
|
||||
defer req.deinit();
|
||||
|
||||
req.sendBodiless() catch |err| return mapError(err, .send);
|
||||
|
||||
var resp = req.receiveHead(self.redirect_buf) catch |err| return mapError(err, .receive);
|
||||
|
||||
self.last_status = resp.head.status;
|
||||
if (resp.head.status != .ok) return error.HttpStatus;
|
||||
|
||||
// `content-type` is deliberately not checked: blocklists are served as
|
||||
// text/plain, application/octet-stream and text/html alike, and the
|
||||
// compiler's invalid-line counters are the honest signal about content.
|
||||
if (resp.head.content_length) |declared| {
|
||||
if (declared > max_body_bytes) return error.BodyTooLarge;
|
||||
}
|
||||
|
||||
const body = resp.reader(self.transfer_buf);
|
||||
var total: u64 = 0;
|
||||
while (true) {
|
||||
const n = body.readSliceShort(self.transfer_buf) catch |err|
|
||||
return mapError(err, .receive);
|
||||
if (n == 0) break;
|
||||
total += n;
|
||||
if (total > max_body_bytes) return error.BodyTooLarge;
|
||||
// The caller owns `w` and can read the concrete failure from its
|
||||
// own writer; this taxonomy has no member for a failing sink.
|
||||
w.writeAll(self.transfer_buf[0..n]) catch return error.Unexpected;
|
||||
}
|
||||
|
||||
return .{ .bytes_read = total, .status = resp.head.status };
|
||||
}
|
||||
};
|
||||
|
||||
/// A scheme other than `http`/`https`, an unparseable URL and a URL with no
|
||||
/// host are one fault to the operator: the source row is unusable.
|
||||
fn parseUrl(url: []const u8) Error!std.Uri {
|
||||
const uri = std.Uri.parse(url) catch return error.BadUrl;
|
||||
if (!std.mem.eql(u8, uri.scheme, "http") and
|
||||
!std.mem.eql(u8, uri.scheme, "https")) return error.BadUrl;
|
||||
const host = uri.host orelse return error.BadUrl;
|
||||
if (host.isEmpty()) return error.BadUrl;
|
||||
return uri;
|
||||
}
|
||||
|
||||
/// Which call failed. The phase is what decides the classification, and only
|
||||
/// the call site knows it — guessing it from an error name would be wrong the
|
||||
/// first time two phases shared an error.
|
||||
const Phase = enum { connect, send, receive };
|
||||
|
||||
fn mapError(err: anyerror, phase: Phase) Error {
|
||||
if (transport.mapLocal(err)) |local| return narrowLocal(local);
|
||||
switch (err) {
|
||||
error.Timeout => return error.Timeout,
|
||||
// Both are ruled out by `parseUrl` before the client is touched.
|
||||
error.UnsupportedUriScheme, error.UriMissingHost => return error.BadUrl,
|
||||
error.TooManyHttpRedirects => return error.HttpStatus,
|
||||
else => {},
|
||||
}
|
||||
const err_name = @errorName(err);
|
||||
if (std.mem.startsWith(u8, err_name, "Tls") or
|
||||
std.mem.startsWith(u8, err_name, "Certificate")) return error.TlsFailed;
|
||||
return switch (phase) {
|
||||
.connect => error.ConnectFailed,
|
||||
.send => error.SendFailed,
|
||||
.receive => error.ReceiveFailed,
|
||||
};
|
||||
}
|
||||
|
||||
/// `transport.mapLocal` answers in `transport.ExchangeError`, which is wider
|
||||
/// than this file's taxonomy. Both file-descriptor quotas are the same
|
||||
/// exhaustion to a downloader, and `error.BufferTooSmall` cannot occur because
|
||||
/// this file hands the client no undersized buffer.
|
||||
fn narrowLocal(local: transport.ExchangeError) Error {
|
||||
return switch (local) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.SystemResources,
|
||||
error.ProcessFdQuotaExceeded,
|
||||
error.SystemFdQuotaExceeded,
|
||||
=> error.SystemResources,
|
||||
error.Canceled => error.Canceled,
|
||||
else => error.Unexpected,
|
||||
};
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn undefinedFetcher(transfer_buf: []u8, redirect_buf: []u8) Fetcher {
|
||||
// `http` is never driven: every test below asserts a rejection that
|
||||
// happens before the first client call.
|
||||
const http: *std.http.Client = undefined;
|
||||
return .{ .http = http, .transfer_buf = transfer_buf, .redirect_buf = redirect_buf };
|
||||
}
|
||||
|
||||
test "fetch rejects a non-http scheme before any client use" {
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
var redirect_buf: [redirect_buffer_len]u8 = undefined;
|
||||
var f = undefinedFetcher(&transfer_buf, &redirect_buf);
|
||||
var sink_buf: [0]u8 = .{};
|
||||
var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
|
||||
try testing.expectError(
|
||||
error.BadUrl,
|
||||
f.fetch(undefined, "ftp://example.com/list.txt", &discarding.writer),
|
||||
);
|
||||
}
|
||||
|
||||
test "fetch rejects a url with no scheme before any client use" {
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
var redirect_buf: [redirect_buffer_len]u8 = undefined;
|
||||
var f = undefinedFetcher(&transfer_buf, &redirect_buf);
|
||||
var sink_buf: [0]u8 = .{};
|
||||
var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
|
||||
try testing.expectError(error.BadUrl, f.fetch(undefined, "x", &discarding.writer));
|
||||
}
|
||||
|
||||
test "fetch rejects a url with no host before any client use" {
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
var redirect_buf: [redirect_buffer_len]u8 = undefined;
|
||||
var f = undefinedFetcher(&transfer_buf, &redirect_buf);
|
||||
var sink_buf: [0]u8 = .{};
|
||||
var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
|
||||
try testing.expectError(error.BadUrl, f.fetch(undefined, "https://", &discarding.writer));
|
||||
}
|
||||
|
||||
test "parseUrl accepts http and https urls" {
|
||||
const plain = try parseUrl("http://example.com/hosts.txt");
|
||||
try testing.expectEqualStrings("http", plain.scheme);
|
||||
const secure = try parseUrl("https://example.com:8443/hosts.txt");
|
||||
try testing.expectEqualStrings("https", secure.scheme);
|
||||
try testing.expectEqual(@as(?u16, 8443), secure.port);
|
||||
}
|
||||
|
||||
test "parseUrl rejects the url forms the source table can hold" {
|
||||
try testing.expectError(error.BadUrl, parseUrl("ftp://example.com/list"));
|
||||
try testing.expectError(error.BadUrl, parseUrl("file:///etc/hosts"));
|
||||
try testing.expectError(error.BadUrl, parseUrl("x"));
|
||||
try testing.expectError(error.BadUrl, parseUrl(""));
|
||||
try testing.expectError(error.BadUrl, parseUrl("https://"));
|
||||
}
|
||||
|
||||
test "mapError maps local errors before phase errors" {
|
||||
try testing.expectEqual(error.OutOfMemory, mapError(error.OutOfMemory, .connect));
|
||||
try testing.expectEqual(error.Canceled, mapError(error.Canceled, .receive));
|
||||
try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send));
|
||||
try testing.expectEqual(error.SystemResources, mapError(error.SystemResources, .connect));
|
||||
try testing.expectEqual(
|
||||
error.SystemResources,
|
||||
mapError(error.ProcessFdQuotaExceeded, .connect),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
error.SystemResources,
|
||||
mapError(error.SystemFdQuotaExceeded, .connect),
|
||||
);
|
||||
}
|
||||
|
||||
test "mapError maps tls errors regardless of phase" {
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsAlert, .receive));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateExpired, .connect));
|
||||
}
|
||||
|
||||
test "mapError maps a redirect overrun to HttpStatus" {
|
||||
try testing.expectEqual(error.HttpStatus, mapError(error.TooManyHttpRedirects, .receive));
|
||||
}
|
||||
|
||||
test "mapError maps a connect timeout to Timeout" {
|
||||
try testing.expectEqual(error.Timeout, mapError(error.Timeout, .connect));
|
||||
}
|
||||
|
||||
test "mapError maps remaining errors by phase" {
|
||||
try testing.expectEqual(error.ConnectFailed, mapError(error.ConnectionRefused, .connect));
|
||||
try testing.expectEqual(error.SendFailed, mapError(error.WriteFailed, .send));
|
||||
try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive));
|
||||
try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive));
|
||||
}
|
||||
|
||||
test "caps are the values the memory budget was sized against" {
|
||||
try testing.expectEqual(@as(usize, 64 * 1024 * 1024), max_body_bytes);
|
||||
try testing.expectEqual(@as(usize, 8192), redirect_buffer_len);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
//! 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.
|
||||
|
||||
const std = @import("std");
|
||||
const parsers = @import("parsers.zig");
|
||||
|
||||
/// Tokens that a candidate name may never contain. `^` is a separator token in
|
||||
/// this syntax and only a trailing one is meaningful for a domain rule.
|
||||
const rule_tokens = "*^|/$";
|
||||
|
||||
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 };
|
||||
if (text[0] == '!') return .{ .kind = .ignore };
|
||||
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 (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 };
|
||||
// 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 };
|
||||
}
|
||||
|
||||
if (std.mem.findAny(u8, text, rule_tokens) != null) return .{ .kind = .unsupported };
|
||||
return .{ .kind = .domain, .text = text };
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a bang comment is ignored" {
|
||||
try testing.expectEqual(parsers.Kind.ignore, parseLine("! comment").kind);
|
||||
}
|
||||
|
||||
test "a list header is ignored" {
|
||||
try testing.expectEqual(parsers.Kind.ignore, parseLine("[Adblock Plus 2.0]").kind);
|
||||
}
|
||||
|
||||
test "an empty line is ignored" {
|
||||
try testing.expectEqual(parsers.Kind.ignore, parseLine("").kind);
|
||||
}
|
||||
|
||||
test "a domain anchor is a wildcard that covers its apex" {
|
||||
const line = parseLine("||example.com^");
|
||||
try testing.expectEqual(parsers.Kind.wildcard, line.kind);
|
||||
try testing.expectEqualStrings("example.com", line.text);
|
||||
try testing.expect(line.covers_apex);
|
||||
}
|
||||
|
||||
test "a domain anchor without a separator is still a wildcard" {
|
||||
const line = parseLine("||example.com");
|
||||
try testing.expectEqual(parsers.Kind.wildcard, line.kind);
|
||||
try testing.expectEqualStrings("example.com", line.text);
|
||||
try testing.expect(line.covers_apex);
|
||||
}
|
||||
|
||||
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 "element hiding is unsupported" {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("##.ad-banner").kind);
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com#@#.ad").kind);
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com#?#.ad").kind);
|
||||
}
|
||||
|
||||
test "a scheme anchor is unsupported" {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("|http://example.com").kind);
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("|https://example.com").kind);
|
||||
}
|
||||
|
||||
test "a regex rule is recognized" {
|
||||
try testing.expectEqual(parsers.Kind.regex, parseLine("/ads[0-9]+/").kind);
|
||||
}
|
||||
|
||||
test "a bare name is a domain" {
|
||||
const line = parseLine("example.com");
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("example.com", 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);
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com|").kind);
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("||example.com/path^").kind);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The plain domain-list format: one candidate name per line. Pure, `std` only.
|
||||
|
||||
const std = @import("std");
|
||||
const parsers = @import("parsers.zig");
|
||||
|
||||
pub fn parseLine(line: []const u8) parsers.Line {
|
||||
// Both comment markers appear in domain lists in the wild.
|
||||
const marker = std.mem.findAny(u8, line, "#!") orelse line.len;
|
||||
const text = std.mem.trim(u8, line[0..marker], &std.ascii.whitespace);
|
||||
if (text.len == 0) return .{ .kind = .ignore };
|
||||
if (text[0] == '/') return .{ .kind = .regex };
|
||||
// A second field means the list is a mis-detected hosts file. Counting that
|
||||
// is honest; guessing which field is the name is not.
|
||||
if (std.mem.findAny(u8, text, &std.ascii.whitespace) != null) return .{ .kind = .unsupported };
|
||||
return .{ .kind = .domain, .text = text };
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a bare name" {
|
||||
const line = parseLine("ads.example.com");
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("ads.example.com", line.text);
|
||||
}
|
||||
|
||||
test "a bang comment line is ignored" {
|
||||
try testing.expectEqual(parsers.Kind.ignore, parseLine("! comment").kind);
|
||||
}
|
||||
|
||||
test "a hash comment line is ignored" {
|
||||
try testing.expectEqual(parsers.Kind.ignore, parseLine("# comment").kind);
|
||||
}
|
||||
|
||||
test "an inline comment is removed" {
|
||||
const line = parseLine("example.com # x");
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("example.com", line.text);
|
||||
}
|
||||
|
||||
test "a regex line is recognized" {
|
||||
try testing.expectEqual(parsers.Kind.regex, parseLine("/re/").kind);
|
||||
}
|
||||
|
||||
test "a hosts line in a domains list is unsupported" {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("0.0.0.0 example.com").kind);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! The `hosts(5)` blocklist format: an optional sink address followed by one or
|
||||
//! more names. Pure, `std` only.
|
||||
//!
|
||||
//! The sink address is not checked against a list of "blocking" addresses: a
|
||||
//! list that maps to `127.0.0.1`, `0.0.0.0` or `::` carries the same
|
||||
//! instruction, and a list that maps to a real address is still a set of names
|
||||
//! the operator asked to block.
|
||||
|
||||
const std = @import("std");
|
||||
const parsers = @import("parsers.zig");
|
||||
|
||||
pub fn parseLine(line: []const u8) parsers.Line {
|
||||
const uncommented = if (std.mem.findScalar(u8, line, '#')) |at| line[0..at] else line;
|
||||
const text = std.mem.trim(u8, uncommented, &std.ascii.whitespace);
|
||||
if (text.len == 0) return .{ .kind = .ignore };
|
||||
if (text[0] == '/') return .{ .kind = .regex };
|
||||
|
||||
const split = std.mem.findAny(u8, text, &std.ascii.whitespace) orelse {
|
||||
// A bare address with no name is a hosts line that names nothing to
|
||||
// block; it is counted rather than compiled into an entry.
|
||||
if (parsers.looksLikeIpLiteral(text)) return .{ .kind = .unsupported };
|
||||
return .{ .kind = .domain, .text = text };
|
||||
};
|
||||
|
||||
// Lists that are bare name lists with a hosts extension are common, so a
|
||||
// first field that is not an address is a name like any other.
|
||||
if (!parsers.looksLikeIpLiteral(text[0..split])) return .{ .kind = .domain, .text = text };
|
||||
|
||||
const names = std.mem.trimStart(u8, text[split..], &std.ascii.whitespace);
|
||||
return .{ .kind = .domain, .text = names };
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a sink address and one name" {
|
||||
const line = parseLine("0.0.0.0 ads.example.com");
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("ads.example.com", line.text);
|
||||
}
|
||||
|
||||
test "a sink address and several names" {
|
||||
const line = parseLine("127.0.0.1 a.example.com b.example.com");
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("a.example.com b.example.com", line.text);
|
||||
}
|
||||
|
||||
test "single-label names survive the parser" {
|
||||
const line = parseLine("::1 ip6-localhost ip6-loopback");
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("ip6-localhost ip6-loopback", line.text);
|
||||
}
|
||||
|
||||
test "a trailing comment is removed" {
|
||||
const line = parseLine("0.0.0.0 ads.example.com # tracker");
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("ads.example.com", line.text);
|
||||
}
|
||||
|
||||
test "a comment line is ignored" {
|
||||
try testing.expectEqual(parsers.Kind.ignore, parseLine("# whole line").kind);
|
||||
}
|
||||
|
||||
test "an empty line is ignored" {
|
||||
try testing.expectEqual(parsers.Kind.ignore, parseLine("").kind);
|
||||
}
|
||||
|
||||
test "a blank line is ignored" {
|
||||
try testing.expectEqual(parsers.Kind.ignore, parseLine(" ").kind);
|
||||
}
|
||||
|
||||
test "a regex line is recognized" {
|
||||
try testing.expectEqual(parsers.Kind.regex, parseLine("/ads\\d+/").kind);
|
||||
}
|
||||
|
||||
test "a bare name without a sink address" {
|
||||
const line = parseLine("example.com");
|
||||
try testing.expectEqual(parsers.Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("example.com", line.text);
|
||||
}
|
||||
|
||||
test "a bare sink address names nothing" {
|
||||
try testing.expectEqual(parsers.Kind.unsupported, parseLine("0.0.0.0").kind);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Blocklist line parsers: the shared vocabulary and the format sniffer.
|
||||
//!
|
||||
//! These files decide **format**, not validity. Whether a candidate is a usable
|
||||
//! domain name is the compiler's decision, taken through `dns.name.fromText`.
|
||||
//! `std` is the only import here and in every sibling parser: this file is the
|
||||
//! root of a separate fuzz module, and a module root cannot import across its
|
||||
//! own directory boundary.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const hosts = @import("parser_hosts.zig");
|
||||
pub const domains = @import("parser_domains.zig");
|
||||
pub const abp = @import("parser_abp.zig");
|
||||
pub const wildcard = @import("wildcard.zig");
|
||||
|
||||
pub const Format = enum { hosts, domains, abp };
|
||||
|
||||
pub const Kind = enum {
|
||||
/// Nothing on the line, or only a comment.
|
||||
ignore,
|
||||
/// `text` holds one or more whitespace-separated candidate names.
|
||||
domain,
|
||||
/// `text` holds one candidate suffix; every proper subdomain of it matches.
|
||||
wildcard,
|
||||
/// A regex rule. Counted, skipped, never compiled (PLAN §2.2).
|
||||
regex,
|
||||
/// Syntactically a rule of this format, but one nxdns cannot honour:
|
||||
/// an ABP modifier list, an exception rule, element hiding, a scheme anchor.
|
||||
unsupported,
|
||||
};
|
||||
|
||||
pub const Line = struct {
|
||||
kind: Kind,
|
||||
/// Borrowed from the caller's line. Not lowercased, not validated.
|
||||
text: []const u8 = "",
|
||||
/// `.wildcard` only. ABP `||x^` covers `x` itself as well as its subdomains,
|
||||
/// so the compiler emits an additional `.list` entry when this is set.
|
||||
covers_apex: bool = false,
|
||||
};
|
||||
|
||||
/// Dispatches to the format's parser. The line must not contain '\n' or '\r';
|
||||
/// the caller strips them.
|
||||
pub fn parseLine(format: Format, line: []const u8) Line {
|
||||
return switch (format) {
|
||||
.hosts => hosts.parseLine(line),
|
||||
.domains => domains.parseLine(line),
|
||||
.abp => abp.parseLine(line),
|
||||
};
|
||||
}
|
||||
|
||||
pub const sample_lines = 64;
|
||||
|
||||
/// Picks a format from the first `sample_lines` lines that are not blank and
|
||||
/// not comments: an ABP marker (`||`, `@@`, `##`, `$`) wins `.abp`; otherwise a
|
||||
/// majority of lines whose first field looks like an IP literal wins `.hosts`;
|
||||
/// otherwise `.domains`.
|
||||
pub fn detectFormat(sample: []const u8) Format {
|
||||
var considered: usize = 0;
|
||||
var ip_first: usize = 0;
|
||||
|
||||
var it = std.mem.splitScalar(u8, sample, '\n');
|
||||
while (it.next()) |raw| {
|
||||
if (considered == sample_lines) break;
|
||||
const line = std.mem.trim(u8, raw, &std.ascii.whitespace);
|
||||
if (line.len == 0) continue;
|
||||
if (hasAbpMarker(line)) return .abp;
|
||||
if (isComment(line)) continue;
|
||||
considered += 1;
|
||||
if (looksLikeIpLiteral(firstField(line))) ip_first += 1;
|
||||
}
|
||||
|
||||
if (ip_first * 2 > considered) return .hosts;
|
||||
return .domains;
|
||||
}
|
||||
|
||||
/// `!` is the ABP comment marker and `#` the hosts one; both appear in every
|
||||
/// format in the wild. `##`, `#@#` and `#?#` are element-hiding rules, not
|
||||
/// comments, so they stay visible to `hasAbpMarker`.
|
||||
pub fn isComment(line: []const u8) bool {
|
||||
if (line.len == 0) return false;
|
||||
if (line[0] == '!') return true;
|
||||
if (line[0] != '#') return false;
|
||||
return !isElementHiding(line);
|
||||
}
|
||||
|
||||
/// The element-hiding separators, which may also follow a domain list
|
||||
/// (`example.com##.ad-banner`).
|
||||
pub fn isElementHiding(line: []const u8) bool {
|
||||
for ([_][]const u8{ "##", "#@#", "#?#", "#$#", "#%#" }) |marker| {
|
||||
if (std.mem.find(u8, line, marker) != null) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn hasAbpMarker(line: []const u8) bool {
|
||||
if (std.mem.startsWith(u8, line, "||")) return true;
|
||||
if (std.mem.startsWith(u8, line, "@@")) return true;
|
||||
if (isElementHiding(line)) return true;
|
||||
// A '$' modifier list only counts on a rule line: a hosts file whose
|
||||
// comments mention a price must not be sniffed as ABP.
|
||||
if (!isComment(line) and std.mem.findScalar(u8, line, '$') != null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The line up to the first ASCII whitespace byte.
|
||||
pub fn firstField(line: []const u8) []const u8 {
|
||||
const end = std.mem.findAny(u8, line, &std.ascii.whitespace) orelse line.len;
|
||||
return line[0..end];
|
||||
}
|
||||
|
||||
/// A sniffing heuristic, not a parser: it recognizes dotted-quad IPv4 and any
|
||||
/// hex-and-colon IPv6 spelling. `platform/address.zig` holds the real parser and
|
||||
/// importing it would break this file's std-only constraint.
|
||||
pub fn looksLikeIpLiteral(field: []const u8) bool {
|
||||
if (field.len == 0) return false;
|
||||
|
||||
if (std.mem.findScalar(u8, field, ':') != null) {
|
||||
for (field) |c| {
|
||||
if (c != ':' and c != '.' and !std.ascii.isHex(c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var parts: usize = 0;
|
||||
var it = std.mem.splitScalar(u8, field, '.');
|
||||
while (it.next()) |part| {
|
||||
parts += 1;
|
||||
if (part.len == 0 or part.len > 3) return false;
|
||||
for (part) |c| {
|
||||
if (!std.ascii.isDigit(c)) return false;
|
||||
}
|
||||
}
|
||||
return parts == 4;
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "detectFormat recognizes a hosts file" {
|
||||
const sample =
|
||||
\\# Title: example
|
||||
\\0.0.0.0 ads.example.com
|
||||
\\0.0.0.0 track.example.net
|
||||
\\127.0.0.1 metrics.example.org
|
||||
\\
|
||||
;
|
||||
try testing.expectEqual(Format.hosts, detectFormat(sample));
|
||||
}
|
||||
|
||||
test "detectFormat recognizes a domains file" {
|
||||
const sample =
|
||||
\\# Title: example
|
||||
\\ads.example.com
|
||||
\\track.example.net
|
||||
\\metrics.example.org
|
||||
\\
|
||||
;
|
||||
try testing.expectEqual(Format.domains, detectFormat(sample));
|
||||
}
|
||||
|
||||
test "detectFormat recognizes an abp file" {
|
||||
const sample =
|
||||
\\[Adblock Plus 2.0]
|
||||
\\! Title: example
|
||||
\\||ads.example.com^
|
||||
\\||track.example.net^
|
||||
\\
|
||||
;
|
||||
try testing.expectEqual(Format.abp, detectFormat(sample));
|
||||
}
|
||||
|
||||
test "detectFormat falls back to domains on an all-comment sample" {
|
||||
var buffer: [64 * 16]u8 = undefined;
|
||||
var w: usize = 0;
|
||||
for (0..64) |_| {
|
||||
@memcpy(buffer[w..][0..14], "# a comment.\n\n");
|
||||
w += 14;
|
||||
}
|
||||
try testing.expectEqual(Format.domains, detectFormat(buffer[0..w]));
|
||||
}
|
||||
|
||||
test "detectFormat is not fooled by a dollar sign in a comment" {
|
||||
const sample =
|
||||
\\# donations welcome, $5 covers a month
|
||||
\\0.0.0.0 ads.example.com
|
||||
\\0.0.0.0 track.example.net
|
||||
\\
|
||||
;
|
||||
try testing.expectEqual(Format.hosts, detectFormat(sample));
|
||||
}
|
||||
|
||||
test "parseLine dispatches to the hosts parser" {
|
||||
const line = parseLine(.hosts, "0.0.0.0 ads.example.com");
|
||||
try testing.expectEqual(Kind.domain, line.kind);
|
||||
try testing.expectEqualStrings("ads.example.com", line.text);
|
||||
}
|
||||
|
||||
test "parseLine dispatches to the domains parser" {
|
||||
const line = parseLine(.domains, "0.0.0.0 ads.example.com");
|
||||
try testing.expectEqual(Kind.unsupported, line.kind);
|
||||
}
|
||||
|
||||
test "parseLine dispatches to the abp parser" {
|
||||
const line = parseLine(.abp, "||ads.example.com^");
|
||||
try testing.expectEqual(Kind.wildcard, line.kind);
|
||||
try testing.expectEqualStrings("ads.example.com", line.text);
|
||||
try testing.expect(line.covers_apex);
|
||||
}
|
||||
|
||||
test "looksLikeIpLiteral separates addresses from names" {
|
||||
try testing.expect(looksLikeIpLiteral("0.0.0.0"));
|
||||
try testing.expect(looksLikeIpLiteral("127.0.0.1"));
|
||||
try testing.expect(looksLikeIpLiteral("::1"));
|
||||
try testing.expect(looksLikeIpLiteral("fd00::dead:beef"));
|
||||
try testing.expect(!looksLikeIpLiteral("example.com"));
|
||||
try testing.expect(!looksLikeIpLiteral("add.face.cafe"));
|
||||
try testing.expect(!looksLikeIpLiteral("1.2.3"));
|
||||
try testing.expect(!looksLikeIpLiteral(""));
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Blocked-response synthesis (PLAN §6.2). Pure: no allocation, no `std.Io`,
|
||||
//! no clock. The caller supplies the buffer and gets back a prefix of it.
|
||||
//!
|
||||
//! No SOA is placed in the authority section. nxdns is not authoritative for a
|
||||
//! blocked name, and a synthesized SOA would hand resolvers a negative-caching
|
||||
//! TTL nxdns cannot honour: the operator can unblock the name at any moment,
|
||||
//! and a client that cached the negative answer for the SOA's MINIMUM would
|
||||
//! keep failing long after the block was lifted.
|
||||
|
||||
const std = @import("std");
|
||||
const types = @import("../dns/types.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const question = @import("../dns/question.zig");
|
||||
const edns = @import("../dns/edns.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
|
||||
pub const Options = struct {
|
||||
mode: model.BlockResponse,
|
||||
ttl: u32,
|
||||
};
|
||||
|
||||
pub const Error = packet.ResponseBuilder.Error;
|
||||
|
||||
const zero_a = [_]u8{0} ** 4;
|
||||
const zero_aaaa = [_]u8{0} ** 16;
|
||||
|
||||
/// Writes a blocked reply for `q` into `buf` and returns a prefix of it.
|
||||
///
|
||||
/// `.zero`: A → 0.0.0.0, AAAA → ::, every other qtype → NOERROR with no answer
|
||||
/// (NODATA). No address exists to synthesize for a qtype that carries none,
|
||||
/// and answering NXDOMAIN for, say, an MX query would tell the client the
|
||||
/// name does not exist while an A query for the same name says it does.
|
||||
/// `.nxdomain`: RCODE = NXDOMAIN, no answer, for every qtype.
|
||||
///
|
||||
/// Only class `IN` is answered with addresses; any other class takes the
|
||||
/// NODATA path, because `0.0.0.0` is an IN-class address and means nothing in
|
||||
/// CH or HS.
|
||||
///
|
||||
/// `request_opt` echoes EDNS exactly as `handler.zig` does: a query that
|
||||
/// carried an OPT record gets a reply carrying one with the same payload size
|
||||
/// and the DO bit passed through.
|
||||
pub fn writeBlocked(
|
||||
buf: []u8,
|
||||
request: header.Header,
|
||||
q: question.Question,
|
||||
request_opt: ?edns.OptRecord,
|
||||
do_bit: bool,
|
||||
options: Options,
|
||||
) Error![]u8 {
|
||||
var b = try packet.ResponseBuilder.init(buf, request, q);
|
||||
|
||||
switch (options.mode) {
|
||||
.nxdomain => b.setRcode(.nx_domain),
|
||||
.zero => if (q.qclass == .in) switch (q.qtype) {
|
||||
.a => try b.addAnswer(q.name, .a, .in, options.ttl, &zero_a),
|
||||
.aaaa => try b.addAnswer(q.name, .aaaa, .in, options.ttl, &zero_aaaa),
|
||||
else => {},
|
||||
},
|
||||
}
|
||||
|
||||
if (request_opt) |opt| try b.addOptEcho(opt, do_bit);
|
||||
return b.finish();
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
const name = @import("../dns/name.zig");
|
||||
const record = @import("../dns/record.zig");
|
||||
|
||||
/// A query for example.com A with an EDNS(0) OPT record advertising 4096
|
||||
/// bytes: id 0x1234, RD set, one question, one additional.
|
||||
const query_bytes =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
"\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00";
|
||||
|
||||
const blocked_name = "ads.example.com";
|
||||
const ttl: u32 = 5;
|
||||
|
||||
fn requestHeader() header.Header {
|
||||
return (packet.parse(query_bytes) catch unreachable).header;
|
||||
}
|
||||
|
||||
fn requestOpt() edns.OptRecord {
|
||||
const p = packet.parse(query_bytes) catch unreachable;
|
||||
return edns.parseOpt(query_bytes, packet.findOptRecord(p).?) catch unreachable;
|
||||
}
|
||||
|
||||
fn blockedQuestion(qtype: types.Type, qclass: types.Class) !question.Question {
|
||||
return .{ .name = try name.fromText(blocked_name), .qtype = qtype, .qclass = qclass };
|
||||
}
|
||||
|
||||
/// Builds a blocked reply and re-parses it, asserting the parts every case
|
||||
/// shares: the echoed id, the QR and RA flags, the echoed question, an empty
|
||||
/// authority section, and an additional section that holds the OPT record only
|
||||
/// when the query carried one.
|
||||
fn expectBlocked(
|
||||
buf: []u8,
|
||||
mode: model.BlockResponse,
|
||||
qtype: types.Type,
|
||||
qclass: types.Class,
|
||||
with_opt: bool,
|
||||
) !packet.Packet {
|
||||
const q = try blockedQuestion(qtype, qclass);
|
||||
const bytes = try writeBlocked(
|
||||
buf,
|
||||
requestHeader(),
|
||||
q,
|
||||
if (with_opt) requestOpt() else null,
|
||||
false,
|
||||
.{ .mode = mode, .ttl = ttl },
|
||||
);
|
||||
|
||||
const p = try packet.parse(bytes);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expect(p.header.flags.qr);
|
||||
try testing.expect(p.header.flags.ra);
|
||||
try testing.expect(p.header.flags.rd);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.nscount);
|
||||
try testing.expectEqual(@as(u16, if (with_opt) 1 else 0), p.header.arcount);
|
||||
|
||||
const echoed = packet.firstQuestion(p).?;
|
||||
try testing.expectEqualSlices(u8, q.name.wire(), echoed.name.wire());
|
||||
try testing.expectEqual(qtype, echoed.qtype);
|
||||
try testing.expectEqual(qclass, echoed.qclass);
|
||||
|
||||
if (with_opt) {
|
||||
const opt = try edns.parseOpt(bytes, packet.findOptRecord(p).?);
|
||||
try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size);
|
||||
try testing.expectEqual(false, opt.do_bit);
|
||||
} else {
|
||||
try testing.expect(packet.findOptRecord(p) == null);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
fn expectNodata(mode: model.BlockResponse, qtype: types.Type, qclass: types.Class) !void {
|
||||
for ([_]bool{ false, true }) |with_opt| {
|
||||
var buf: [512]u8 = undefined;
|
||||
const p = try expectBlocked(&buf, mode, qtype, qclass, with_opt);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.ancount);
|
||||
}
|
||||
}
|
||||
|
||||
fn expectNxdomain(qtype: types.Type) !void {
|
||||
for ([_]bool{ false, true }) |with_opt| {
|
||||
var buf: [512]u8 = undefined;
|
||||
const p = try expectBlocked(&buf, .nxdomain, qtype, .in, with_opt);
|
||||
try testing.expectEqual(types.Rcode.nx_domain, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.ancount);
|
||||
}
|
||||
}
|
||||
|
||||
test "zero mode answers A with 0.0.0.0" {
|
||||
for ([_]bool{ false, true }) |with_opt| {
|
||||
var buf: [512]u8 = undefined;
|
||||
const p = try expectBlocked(&buf, .zero, .a, .in, with_opt);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||
|
||||
var it = packet.answers(p);
|
||||
const answer = (try it.next()).?;
|
||||
try testing.expectEqual(types.Type.a, answer.rtype);
|
||||
try testing.expectEqual(@as(u16, @intFromEnum(types.Class.in)), answer.class);
|
||||
try testing.expectEqual(ttl, answer.ttl);
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
(try name.fromText(blocked_name)).wire(),
|
||||
answer.name.wire(),
|
||||
);
|
||||
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));
|
||||
try testing.expect((try it.next()) == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "zero mode answers AAAA with ::" {
|
||||
for ([_]bool{ false, true }) |with_opt| {
|
||||
var buf: [512]u8 = undefined;
|
||||
const p = try expectBlocked(&buf, .zero, .aaaa, .in, with_opt);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||
|
||||
var it = packet.answers(p);
|
||||
const answer = (try it.next()).?;
|
||||
try testing.expectEqual(types.Type.aaaa, answer.rtype);
|
||||
try testing.expectEqual(ttl, answer.ttl);
|
||||
try testing.expectEqual(zero_aaaa, try record.rdataAaaa(p.bytes, answer));
|
||||
try testing.expect((try it.next()) == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "zero mode answers MX with NODATA" {
|
||||
try expectNodata(.zero, .mx, .in);
|
||||
}
|
||||
|
||||
test "zero mode answers HTTPS with NODATA" {
|
||||
try expectNodata(.zero, .https, .in);
|
||||
}
|
||||
|
||||
test "zero mode answers a non-IN class with NODATA" {
|
||||
try expectNodata(.zero, .a, .ch);
|
||||
try expectNodata(.zero, .aaaa, .any);
|
||||
}
|
||||
|
||||
test "nxdomain mode answers A with NXDOMAIN" {
|
||||
try expectNxdomain(.a);
|
||||
}
|
||||
|
||||
test "nxdomain mode answers AAAA with NXDOMAIN" {
|
||||
try expectNxdomain(.aaaa);
|
||||
}
|
||||
|
||||
test "nxdomain mode answers MX with NXDOMAIN" {
|
||||
try expectNxdomain(.mx);
|
||||
}
|
||||
|
||||
test "nxdomain mode answers HTTPS with NXDOMAIN" {
|
||||
try expectNxdomain(.https);
|
||||
}
|
||||
|
||||
test "the DO bit passes through" {
|
||||
for ([_]bool{ false, true }) |do_bit| {
|
||||
var buf: [512]u8 = undefined;
|
||||
const bytes = try writeBlocked(
|
||||
&buf,
|
||||
requestHeader(),
|
||||
try blockedQuestion(.a, .in),
|
||||
requestOpt(),
|
||||
do_bit,
|
||||
.{ .mode = .zero, .ttl = ttl },
|
||||
);
|
||||
const p = try packet.parse(bytes);
|
||||
const opt = try edns.parseOpt(bytes, packet.findOptRecord(p).?);
|
||||
try testing.expectEqual(do_bit, opt.do_bit);
|
||||
}
|
||||
}
|
||||
|
||||
test "a ttl of zero survives the round trip" {
|
||||
var buf: [512]u8 = undefined;
|
||||
const bytes = try writeBlocked(
|
||||
&buf,
|
||||
requestHeader(),
|
||||
try blockedQuestion(.a, .in),
|
||||
null,
|
||||
false,
|
||||
.{ .mode = .zero, .ttl = 0 },
|
||||
);
|
||||
const p = try packet.parse(bytes);
|
||||
var it = packet.answers(p);
|
||||
try testing.expectEqual(@as(u32, 0), (try it.next()).?.ttl);
|
||||
}
|
||||
|
||||
test "a buffer too small reports a write failure instead of truncating" {
|
||||
const q = try blockedQuestion(.a, .in);
|
||||
const options: Options = .{ .mode = .zero, .ttl = ttl };
|
||||
|
||||
// Room for the header and the question, but not for the answer record.
|
||||
var no_room_for_answer: [40]u8 = undefined;
|
||||
try testing.expectError(
|
||||
error.WriteFailed,
|
||||
writeBlocked(&no_room_for_answer, requestHeader(), q, null, false, options),
|
||||
);
|
||||
|
||||
// Room for the header and the question and the answer, but not the OPT.
|
||||
var no_room_for_opt: [72]u8 = undefined;
|
||||
try testing.expectError(
|
||||
error.WriteFailed,
|
||||
writeBlocked(&no_room_for_opt, requestHeader(), q, requestOpt(), false, options),
|
||||
);
|
||||
|
||||
// Not even room for the header.
|
||||
var tiny: [8]u8 = undefined;
|
||||
try testing.expectError(
|
||||
error.WriteFailed,
|
||||
writeBlocked(&tiny, requestHeader(), q, null, false, options),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! One group's explicit rules (PLAN §3.10 levels 1–4), 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
|
||||
//! array of strings and are scanned linearly. Operator-authored wildcards are
|
||||
//! few — `max_wildcards_per_group` caps them at 4096 — and a linear scan over
|
||||
//! 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
|
||||
//! source. The hash seed arrives as a parameter.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
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 wildcard = @import("wildcard.zig");
|
||||
|
||||
pub const Error = error{ OutOfMemory, BadPattern, TooManyWildcards } || 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;
|
||||
|
||||
pub const RuleSet = struct {
|
||||
exact_allow: domain_set.DomainSet = .empty,
|
||||
exact_block: domain_set.DomainSet = .empty,
|
||||
/// Normalized and sorted, so a rebuild of the same rows produces the same
|
||||
/// 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 = &.{},
|
||||
|
||||
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
|
||||
/// 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
|
||||
/// would block a domain the operator unblocked.
|
||||
pub fn build(gpa: 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 wildcards: 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;
|
||||
wildcards += 1;
|
||||
if (wildcards > max_wildcards_per_group) return error.TooManyWildcards;
|
||||
},
|
||||
}
|
||||
const bucket = &spans[bucketOf(row.kind, row.action)];
|
||||
try bucket.append(gpa, .{ .offset = scratch.items.len, .len = pattern.len });
|
||||
try scratch.appendSlice(gpa, 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);
|
||||
for (&spans, &sorted) |*bucket, *out| {
|
||||
try out.ensureTotalCapacityPrecise(gpa, bucket.items.len);
|
||||
for (bucket.items) |span| {
|
||||
out.appendAssumeCapacity(scratch.items[span.offset..][0..span.len]);
|
||||
}
|
||||
std.mem.sort([]const u8, out.items, {}, lessThanBytes);
|
||||
dedupSorted(out);
|
||||
}
|
||||
|
||||
var self: RuleSet = .empty;
|
||||
errdefer self.deinit(gpa);
|
||||
|
||||
self.exact_allow = try buildSet(gpa, sorted[bucketOf(.exact, .allow)].items, seed);
|
||||
self.exact_block = try buildSet(gpa, sorted[bucketOf(.exact, .block)].items, seed);
|
||||
|
||||
const allow = sorted[bucketOf(.wildcard, .allow)].items;
|
||||
const block = sorted[bucketOf(.wildcard, .block)].items;
|
||||
var total: usize = 0;
|
||||
for (allow) |pattern| total += pattern.len;
|
||||
for (block) |pattern| total += pattern.len;
|
||||
|
||||
const bytes = try gpa.alloc(u8, total);
|
||||
self.wildcard_bytes = bytes;
|
||||
var at: usize = 0;
|
||||
self.wildcard_allow = try copyPatterns(gpa, allow, bytes, &at);
|
||||
self.wildcard_block = try copyPatterns(gpa, block, bytes, &at);
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *RuleSet, gpa: Allocator) void {
|
||||
self.exact_allow.deinit(gpa);
|
||||
self.exact_block.deinit(gpa);
|
||||
gpa.free(self.wildcard_allow);
|
||||
gpa.free(self.wildcard_block);
|
||||
gpa.free(self.wildcard_bytes);
|
||||
self.* = .empty;
|
||||
}
|
||||
|
||||
pub fn memoryBytes(self: *const RuleSet) usize {
|
||||
return self.exact_allow.memoryBytes() +
|
||||
self.exact_block.memoryBytes() +
|
||||
self.wildcard_bytes.len +
|
||||
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Patterns are recorded as offsets because `scratch` reallocates while it
|
||||
/// grows; they become slices only after the collecting pass ends.
|
||||
const Span = struct { offset: usize, len: usize };
|
||||
|
||||
fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize {
|
||||
const kind_bit: usize = switch (kind) {
|
||||
.exact => 0,
|
||||
.wildcard => 2,
|
||||
};
|
||||
const action_bit: usize = switch (action) {
|
||||
.allow => 0,
|
||||
.block => 1,
|
||||
};
|
||||
return kind_bit + action_bit;
|
||||
}
|
||||
|
||||
fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool {
|
||||
return std.mem.order(u8, a, b) == .lt;
|
||||
}
|
||||
|
||||
/// Duplicate rows are removed rather than rejected: two identical block rules
|
||||
/// are not a corrupt database, and `DomainSet.build` requires a strictly
|
||||
/// ascending body.
|
||||
fn dedupSorted(list: *std.ArrayList([]const u8)) void {
|
||||
var kept: usize = 0;
|
||||
for (list.items) |item| {
|
||||
if (kept > 0 and std.mem.eql(u8, list.items[kept - 1], item)) continue;
|
||||
list.items[kept] = item;
|
||||
kept += 1;
|
||||
}
|
||||
list.shrinkRetainingCapacity(kept);
|
||||
}
|
||||
|
||||
fn buildSet(gpa: 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);
|
||||
for (patterns) |pattern| {
|
||||
try body.appendSlice(gpa, pattern);
|
||||
try body.append(gpa, '\n');
|
||||
}
|
||||
return domain_set.DomainSet.build(gpa, body.items, seed);
|
||||
}
|
||||
|
||||
fn copyPatterns(
|
||||
gpa: 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);
|
||||
for (out, patterns) |*slot, pattern| {
|
||||
@memcpy(bytes[at.*..][0..pattern.len], pattern);
|
||||
slot.* = bytes[at.*..][0..pattern.len];
|
||||
at.* += pattern.len;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const NameError = error{BadName};
|
||||
|
||||
/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
|
||||
/// rejected: query names reach the matcher ASCII-lowercased, so a pattern
|
||||
/// carrying a high byte could never match anything.
|
||||
fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
|
||||
var rest = text;
|
||||
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
|
||||
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
|
||||
|
||||
for (rest, 0..) |byte, i| {
|
||||
if (byte >= 0x80 or byte < 0x21) return error.BadName;
|
||||
buf[i] = std.ascii.toLower(byte);
|
||||
}
|
||||
return buf[0..rest.len];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn rule(pattern: []const u8, kind: model.RuleKind, action: model.RuleAction) model.Rule {
|
||||
return .{ .group = "default", .pattern = pattern, .kind = kind, .action = action };
|
||||
}
|
||||
|
||||
test "exact rules land in the matching set" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("ads.example.com", .exact, .block),
|
||||
rule("good.example.com", .exact, .allow),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(set.exact_block.contains("ads.example.com"));
|
||||
try testing.expect(!set.exact_block.contains("good.example.com"));
|
||||
try testing.expect(set.exact_allow.contains("good.example.com"));
|
||||
try testing.expectEqual(@as(usize, 0), set.wildcard_allow.len);
|
||||
try testing.expectEqual(@as(usize, 0), set.wildcard_block.len);
|
||||
}
|
||||
|
||||
test "wildcard rules land in the matching list, sorted" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("*.z.example.com", .wildcard, .block),
|
||||
rule("*.a.example.com", .wildcard, .block),
|
||||
rule("*.allowed.example.com", .wildcard, .allow),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), set.wildcard_block.len);
|
||||
try testing.expectEqualStrings("*.a.example.com", set.wildcard_block[0]);
|
||||
try testing.expectEqualStrings("*.z.example.com", set.wildcard_block[1]);
|
||||
try testing.expectEqual(@as(usize, 1), set.wildcard_allow.len);
|
||||
try testing.expectEqualStrings("*.allowed.example.com", set.wildcard_allow[0]);
|
||||
}
|
||||
|
||||
test "patterns are normalized to lowercase without a trailing dot" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("ADS.Example.COM.", .exact, .block),
|
||||
rule("*.Tracker.NET.", .wildcard, .block),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(set.exact_block.contains("ads.example.com"));
|
||||
try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
|
||||
}
|
||||
|
||||
test "duplicate rows collapse to one entry" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("ads.example.com", .exact, .block),
|
||||
rule("ads.example.com.", .exact, .block),
|
||||
rule("*.x.example.com", .wildcard, .block),
|
||||
rule("*.x.example.com", .wildcard, .block),
|
||||
};
|
||||
var set = try RuleSet.build(testing.allocator, &rows, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), set.exact_block.count);
|
||||
try testing.expectEqual(@as(usize, 1), set.wildcard_block.len);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
test "too many wildcards is an error" {
|
||||
const gpa = testing.allocator;
|
||||
const rows = try gpa.alloc(model.Rule, max_wildcards_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}.example.com", .{i});
|
||||
try patterns.append(gpa, pattern);
|
||||
row.* = rule(pattern, .wildcard, .block);
|
||||
}
|
||||
|
||||
try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, rows, 0));
|
||||
}
|
||||
|
||||
test "an empty rule list builds the empty set" {
|
||||
var set = try RuleSet.build(testing.allocator, &[_]model.Rule{}, 0);
|
||||
defer set.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(!set.exact_block.contains("ads.example.com"));
|
||||
try testing.expectEqual(@as(usize, 0), set.memoryBytes());
|
||||
}
|
||||
|
||||
test "the empty rule set owns nothing" {
|
||||
var set: RuleSet = .empty;
|
||||
try testing.expect(!set.exact_allow.contains("x.example.com"));
|
||||
try testing.expectEqual(@as(usize, 0), set.memoryBytes());
|
||||
set.deinit(testing.allocator);
|
||||
}
|
||||
|
||||
test "memoryBytes counts every part" {
|
||||
const rows = [_]model.Rule{
|
||||
rule("ads.example.com", .exact, .block),
|
||||
rule("*.tracker.net", .wildcard, .block),
|
||||
};
|
||||
var set = try RuleSet.build(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);
|
||||
}
|
||||
|
||||
fn buildUnderFailure(gpa: Allocator) !void {
|
||||
const rows = [_]model.Rule{
|
||||
rule("ads.example.com", .exact, .block),
|
||||
rule("good.example.com", .exact, .allow),
|
||||
rule("*.tracker.net", .wildcard, .block),
|
||||
rule("*.ok.tracker.net", .wildcard, .allow),
|
||||
};
|
||||
var set = try RuleSet.build(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]);
|
||||
}
|
||||
|
||||
test "build leaks nothing under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Per-group safe search (PLAN §7.4): the table of search and video domains
|
||||
//! whose queries are rewritten to a provider-operated restricted hostname.
|
||||
//! Pure: no allocation, no `std.Io`, no clock.
|
||||
//!
|
||||
//! Every target is a name the operator's upstream resolves. nxdns never
|
||||
//! hardcodes an address for one: the providers move these hosts, and a stale
|
||||
//! literal would send a household's search traffic to somebody else's server.
|
||||
//!
|
||||
//! Google's country domains (`google.de`, `google.co.uk`, …) are deliberately
|
||||
//! not enumerated. The list is unbounded, it goes stale, and
|
||||
//! `forcesafesearch.google.com` is the documented target for every one of them.
|
||||
//! An operator who needs a country domain adds a rule.
|
||||
|
||||
const std = @import("std");
|
||||
const name = @import("../dns/name.zig");
|
||||
|
||||
pub const Entry = struct { domain: []const u8, target: []const u8 };
|
||||
|
||||
/// Sorted ascending by `domain`, so the table is searchable and diffable. The
|
||||
/// order is a correctness precondition of `lookup`, so it is asserted below at
|
||||
/// comptime rather than left to review.
|
||||
pub const table = [_]Entry{
|
||||
.{ .domain = "bing.com", .target = "strict.bing.com" },
|
||||
.{ .domain = "duckduckgo.com", .target = "safe.duckduckgo.com" },
|
||||
.{ .domain = "google.com", .target = "forcesafesearch.google.com" },
|
||||
.{ .domain = "m.youtube.com", .target = "restrictmoderate.youtube.com" },
|
||||
.{ .domain = "pixabay.com", .target = "safesearch.pixabay.com" },
|
||||
.{ .domain = "www.bing.com", .target = "strict.bing.com" },
|
||||
.{ .domain = "www.duckduckgo.com", .target = "safe.duckduckgo.com" },
|
||||
.{ .domain = "www.google.com", .target = "forcesafesearch.google.com" },
|
||||
.{ .domain = "www.youtube-nocookie.com", .target = "restrictmoderate.youtube.com" },
|
||||
.{ .domain = "www.youtube.com", .target = "restrictmoderate.youtube.com" },
|
||||
.{ .domain = "youtube.com", .target = "restrictmoderate.youtube.com" },
|
||||
.{ .domain = "youtube.googleapis.com", .target = "restrictmoderate.youtube.com" },
|
||||
.{ .domain = "youtubei.googleapis.com", .target = "restrictmoderate.youtube.com" },
|
||||
};
|
||||
|
||||
comptime {
|
||||
for (table[1..], table[0 .. table.len - 1]) |entry, previous| {
|
||||
if (std.mem.order(u8, previous.domain, entry.domain) != .lt) {
|
||||
@compileError("safesearch.table must be sorted ascending by domain and duplicate-free");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn orderDomain(query: []const u8, entry: Entry) std.math.Order {
|
||||
return std.mem.order(u8, query, entry.domain);
|
||||
}
|
||||
|
||||
/// Exact match. `domain` must be normalized: lowercase, no trailing dot.
|
||||
pub fn lookup(domain: []const u8) ?[]const u8 {
|
||||
const index = std.sort.binarySearch(Entry, &table, domain, orderDomain) orelse return null;
|
||||
return table[index].target;
|
||||
}
|
||||
|
||||
/// The rewritten question name for a matched query. Applying it — sending the
|
||||
/// rewritten question upstream and prefixing the answer with a CNAME from the
|
||||
/// original name to the target — is the handler's job (Phase 7). Nothing here
|
||||
/// builds a response.
|
||||
pub fn rewrite(domain: []const u8) ?name.Name {
|
||||
const target = lookup(domain) orelse return null;
|
||||
// Every target in `table` is a syntactically valid name, which the
|
||||
// "every table entry is a valid domain name" test below asserts.
|
||||
return name.fromText(target) catch unreachable;
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "every table entry is a valid domain name" {
|
||||
for (table) |entry| {
|
||||
_ = try name.fromText(entry.domain);
|
||||
_ = try name.fromText(entry.target);
|
||||
}
|
||||
}
|
||||
|
||||
test "lookup finds every table entry" {
|
||||
for (table) |entry| {
|
||||
try testing.expectEqualStrings(entry.target, lookup(entry.domain).?);
|
||||
}
|
||||
}
|
||||
|
||||
test "lookup matches exactly and nothing else" {
|
||||
try testing.expect(lookup("example.com") == null);
|
||||
try testing.expect(lookup("") == null);
|
||||
try testing.expect(lookup("com") == null);
|
||||
// A subdomain of a listed name is not listed: the table is exact.
|
||||
try testing.expect(lookup("images.google.com") == null);
|
||||
// Nor is a parent, nor a name a listed one is a prefix of.
|
||||
try testing.expect(lookup("google.com.evil.net") == null);
|
||||
// The caller normalizes; an uppercase spelling is a miss, not a hit.
|
||||
try testing.expect(lookup("GOOGLE.COM") == null);
|
||||
// A trailing dot is not stripped here either.
|
||||
try testing.expect(lookup("google.com.") == null);
|
||||
// A country domain is deliberately absent.
|
||||
try testing.expect(lookup("google.de") == null);
|
||||
}
|
||||
|
||||
test "lookup maps the youtube family to one target" {
|
||||
const youtube = "restrictmoderate.youtube.com";
|
||||
for ([_][]const u8{
|
||||
"m.youtube.com",
|
||||
"www.youtube-nocookie.com",
|
||||
"www.youtube.com",
|
||||
"youtube.com",
|
||||
"youtube.googleapis.com",
|
||||
"youtubei.googleapis.com",
|
||||
}) |domain| {
|
||||
try testing.expectEqualStrings(youtube, lookup(domain).?);
|
||||
}
|
||||
}
|
||||
|
||||
test "rewrite returns the target as a wire name" {
|
||||
const rewritten = rewrite("www.google.com").?;
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
(try name.fromText("forcesafesearch.google.com")).wire(),
|
||||
rewritten.wire(),
|
||||
);
|
||||
try testing.expect(rewrite("example.com") == null);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Label-pattern wildcards for filtering rules (PLAN §3.9). Pure: no
|
||||
//! allocation, no `std.Io`, no recursion.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const max_labels = 128;
|
||||
|
||||
/// The longest domain name is 255 wire bytes, which is 253 bytes of text.
|
||||
const max_pattern_len = 253;
|
||||
const max_label_len = 63;
|
||||
|
||||
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.
|
||||
PartialWildcardLabel,
|
||||
EmptyLabel,
|
||||
LabelTooLong,
|
||||
PatternTooLong,
|
||||
TooManyLabels,
|
||||
};
|
||||
|
||||
/// Syntax only. A valid pattern has at least one label that is exactly "*",
|
||||
/// every other label is 1–63 bytes with no '*' inside it, and the whole
|
||||
/// pattern is at most 253 bytes over at most `max_labels` labels.
|
||||
pub fn validate(pattern: []const u8) PatternError!void {
|
||||
// The label count is checked before the byte length so that both bounds
|
||||
// stay individually reportable: any pattern with more than `max_labels`
|
||||
// labels also exceeds `max_pattern_len`.
|
||||
if (std.mem.count(u8, pattern, ".") + 1 > max_labels) return error.TooManyLabels;
|
||||
if (pattern.len > max_pattern_len) return error.PatternTooLong;
|
||||
|
||||
var star = false;
|
||||
var it = std.mem.splitScalar(u8, pattern, '.');
|
||||
while (it.next()) |label| {
|
||||
if (label.len == 0) return error.EmptyLabel;
|
||||
if (label.len > max_label_len) return error.LabelTooLong;
|
||||
if (std.mem.eql(u8, label, "*")) {
|
||||
star = true;
|
||||
} else if (std.mem.findScalar(u8, label, '*') != null) {
|
||||
return error.PartialWildcardLabel;
|
||||
}
|
||||
}
|
||||
if (!star) return error.NoWildcard;
|
||||
}
|
||||
|
||||
/// `domain` is already normalized: lowercase, no trailing dot. `pattern` is
|
||||
/// lowercase. Each "*" label matches ONE OR MORE labels.
|
||||
/// Allocation-free; the backtracking is bounded by `max_labels` on both sides.
|
||||
pub fn matches(pattern: []const u8, domain: []const u8) bool {
|
||||
var pattern_labels: [max_labels][]const u8 = undefined;
|
||||
var domain_labels: [max_labels][]const u8 = undefined;
|
||||
|
||||
// `validate` rejects a pattern above the label bound and the 253-byte name
|
||||
// limit bounds the domain, so neither overflow can reach here from the
|
||||
// matcher. Both are re-checked so that unvalidated input still terminates.
|
||||
const pattern_len = split(pattern, &pattern_labels) orelse return false;
|
||||
const domain_len = split(domain, &domain_labels) orelse return false;
|
||||
|
||||
var d: usize = 0;
|
||||
var p: usize = 0;
|
||||
var star: ?usize = null;
|
||||
var star_end: usize = 0;
|
||||
|
||||
while (d < domain_len) {
|
||||
if (p < pattern_len and isStar(pattern_labels[p])) {
|
||||
// A '*' takes one label now and grows by one on each backtrack.
|
||||
star = p;
|
||||
p += 1;
|
||||
d += 1;
|
||||
star_end = d;
|
||||
} else if (p < pattern_len and std.mem.eql(u8, pattern_labels[p], domain_labels[d])) {
|
||||
p += 1;
|
||||
d += 1;
|
||||
} else if (star) |s| {
|
||||
p = s + 1;
|
||||
star_end += 1;
|
||||
d = star_end;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// A trailing '*' has already consumed its label; nothing may be left over.
|
||||
return p == pattern_len;
|
||||
}
|
||||
|
||||
fn isStar(label: []const u8) bool {
|
||||
return label.len == 1 and label[0] == '*';
|
||||
}
|
||||
|
||||
/// Null when `text` holds more than `max_labels` labels.
|
||||
fn split(text: []const u8, out: *[max_labels][]const u8) ?usize {
|
||||
var n: usize = 0;
|
||||
var it = std.mem.splitScalar(u8, text, '.');
|
||||
while (it.next()) |label| {
|
||||
if (n == max_labels) return null;
|
||||
out[n] = label;
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "validate accepts a leading wildcard label" {
|
||||
try validate("*.doubleclick.net");
|
||||
}
|
||||
|
||||
test "validate accepts an interior wildcard label" {
|
||||
try validate("ads.*.example.com");
|
||||
}
|
||||
|
||||
test "validate rejects a pattern with no wildcard label" {
|
||||
try testing.expectError(error.NoWildcard, validate("example.com"));
|
||||
}
|
||||
|
||||
test "validate rejects a partial wildcard label" {
|
||||
try testing.expectError(error.PartialWildcardLabel, validate("a*b.com"));
|
||||
}
|
||||
|
||||
test "validate rejects an empty label" {
|
||||
try testing.expectError(error.EmptyLabel, validate("a..b"));
|
||||
}
|
||||
|
||||
test "validate rejects an oversize label" {
|
||||
const pattern = "*." ++ ("a" ** 64);
|
||||
try testing.expectError(error.LabelTooLong, validate(pattern));
|
||||
}
|
||||
|
||||
test "validate rejects an oversize pattern" {
|
||||
const label = "a" ** 60;
|
||||
const pattern = "*." ++ label ++ "." ++ label ++ "." ++ label ++ "." ++ label ++ "." ++ label;
|
||||
try testing.expect(pattern.len > 253);
|
||||
try testing.expectError(error.PatternTooLong, validate(pattern));
|
||||
}
|
||||
|
||||
test "validate rejects too many labels" {
|
||||
const pattern = "*." ++ ("a." ** 199) ++ "com";
|
||||
try testing.expectError(error.TooManyLabels, validate(pattern));
|
||||
}
|
||||
|
||||
test "matches one label under a leading wildcard" {
|
||||
try testing.expect(matches("*.doubleclick.net", "a.doubleclick.net"));
|
||||
}
|
||||
|
||||
test "matches several labels under a leading wildcard" {
|
||||
try testing.expect(matches("*.doubleclick.net", "a.b.doubleclick.net"));
|
||||
}
|
||||
|
||||
test "a leading wildcard does not match the apex" {
|
||||
try testing.expect(!matches("*.doubleclick.net", "doubleclick.net"));
|
||||
}
|
||||
|
||||
test "matches one label at an interior wildcard" {
|
||||
try testing.expect(matches("ads.*.example.com", "ads.eu.example.com"));
|
||||
}
|
||||
|
||||
test "matches several labels at an interior wildcard" {
|
||||
try testing.expect(matches("ads.*.example.com", "ads.eu.west.example.com"));
|
||||
}
|
||||
|
||||
test "an interior wildcard requires at least one label" {
|
||||
try testing.expect(!matches("ads.*.example.com", "ads.example.com"));
|
||||
}
|
||||
|
||||
test "a pattern does not match a name that only contains it" {
|
||||
try testing.expect(!matches("*.example.com", "example.com.evil.net"));
|
||||
}
|
||||
|
||||
test "a pathological pattern terminates" {
|
||||
const pattern = ("*." ** 8) ++ "example.com";
|
||||
const domain = ("a." ** 98) ++ "example.net";
|
||||
try testing.expect(!matches(pattern, domain));
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//! Plain UDP/TCP resolver client for conditional forward zones (PLAN §6.5).
|
||||
//!
|
||||
//! A forward zone points at a box on the LAN — a router, a NAS, an internal
|
||||
//! resolver — which speaks port 53 and nothing else. `transport.Endpoint` knows
|
||||
//! only `https://` and `tls://` by design, so the configuration for this client
|
||||
//! comes from `validate.Resolver` instead. The interface it implements is the
|
||||
//! same `transport.Client` every upstream implements, so the Phase 7 handler
|
||||
//! treats a forward zone exactly like any other exchange.
|
||||
//!
|
||||
//! No health tracking and no backoff live here. `upstream/health.zig` and
|
||||
//! `upstream/pool.zig` model the upstream *pool*, where failing over to a second
|
||||
//! endpoint is the whole point. A forward zone has exactly one designated
|
||||
//! resolver and no failover partner, so a backoff would only add latency to a
|
||||
//! failure the caller already sees. Their absence is a decision, not an
|
||||
//! oversight.
|
||||
//!
|
||||
//! One `ForwardClient` is used by one task at a time: `stats` is a plain struct
|
||||
//! and `frame_buf` is not shared.
|
||||
|
||||
const std = @import("std");
|
||||
const net = std.Io.net;
|
||||
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const validate = @import("../config/validate.zig");
|
||||
const dns_header = @import("../dns/header.zig");
|
||||
|
||||
const log = std.log.scoped(.forward_client);
|
||||
|
||||
/// RFC 1035 §4.2.2 length prefix for DNS over TCP.
|
||||
/// The TCP path splits `frame_buf` between the socket writer and the socket
|
||||
/// reader. Neither half has to hold a whole message — the reply is read
|
||||
/// straight into the caller's `response_buf` — so this is a floor that keeps
|
||||
/// each half large enough to frame a query in one write, not a capacity.
|
||||
pub const min_frame_buf: usize = 1024;
|
||||
|
||||
pub const ForwardClient = struct {
|
||||
resolver: validate.Resolver,
|
||||
/// Caller-owned scratch for the TCP length-prefixed path.
|
||||
frame_buf: []u8,
|
||||
/// On the `.awake` clock at the caller's choosing, so a suspended host does
|
||||
/// not burn the budget while it sleeps.
|
||||
read_timeout: std.Io.Clock.Duration,
|
||||
stats: Stats = .{},
|
||||
|
||||
pub const Stats = struct {
|
||||
queries: u64 = 0,
|
||||
/// TC=1 over UDP, so the exchange was retried over TCP.
|
||||
udp_truncated: u64 = 0,
|
||||
/// A datagram arrived from an address other than the resolver's. It was
|
||||
/// discarded and the receive retried within the remaining budget, which
|
||||
/// is invisible to the caller and would otherwise be an unrecorded
|
||||
/// failure mode.
|
||||
foreign_datagrams: u64 = 0,
|
||||
/// Exchanges that returned a peer fault or a local resource error.
|
||||
/// A cancellation is neither, so it is not counted.
|
||||
failures: u64 = 0,
|
||||
};
|
||||
|
||||
/// An undersized `frame_buf` is a wiring bug in this process, not a runtime
|
||||
/// condition, so it is an assertion.
|
||||
pub fn init(
|
||||
resolver: validate.Resolver,
|
||||
frame_buf: []u8,
|
||||
read_timeout: std.Io.Clock.Duration,
|
||||
) ForwardClient {
|
||||
std.debug.assert(frame_buf.len >= min_frame_buf);
|
||||
return .{
|
||||
.resolver = resolver,
|
||||
.frame_buf = frame_buf,
|
||||
.read_timeout = read_timeout,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn client(self: *ForwardClient) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
const self: *ForwardClient = @ptrCast(@alignCast(ptr));
|
||||
return self.exchange(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// `.udp` resolvers send one datagram and fall back to TCP when the answer
|
||||
/// comes back with TC=1. `.tcp` resolvers skip straight to the TCP path.
|
||||
pub fn exchange(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
// The TCP length prefix is 16-bit, so a longer query cannot be framed.
|
||||
if (query.len > transport.max_message_len) return error.BufferTooSmall;
|
||||
if (response_buf.len == 0) return error.BufferTooSmall;
|
||||
|
||||
self.stats.queries += 1;
|
||||
return self.route(io, query, response_buf) catch |err| {
|
||||
switch (transport.group(err)) {
|
||||
.peer_fault, .local_resource => self.stats.failures += 1,
|
||||
.cancellation => {},
|
||||
}
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn route(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
if (self.resolver.scheme == .udp) {
|
||||
if (try self.exchangeUdp(io, query, response_buf)) |reply| return reply;
|
||||
}
|
||||
return self.exchangeTcp(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// `null` means the resolver set TC=1 and the caller must retry over TCP.
|
||||
///
|
||||
/// The socket is bound to the wildcard address of the resolver's family on
|
||||
/// an ephemeral port, so the kernel picks the source port for every
|
||||
/// exchange rather than this process reusing one.
|
||||
fn exchangeUdp(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError!?[]u8 {
|
||||
const dest = self.destination();
|
||||
const local = wildcardFor(dest);
|
||||
|
||||
const socket = local.bind(io, .{ .mode = .dgram }) catch |err| {
|
||||
log.debug("forward resolver: udp bind failed: {s}", .{@errorName(err)});
|
||||
return mapPhase(err, error.ConnectFailed);
|
||||
};
|
||||
defer closeSocket(io, &socket);
|
||||
|
||||
socket.send(io, &dest, query) catch |err| {
|
||||
log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)});
|
||||
return mapPhase(err, error.SendFailed);
|
||||
};
|
||||
|
||||
// A deadline, not a duration: a discarded foreign datagram restarts the
|
||||
// receive, and a duration would hand each retry the full budget again.
|
||||
const deadline = (std.Io.Timeout{ .duration = self.read_timeout }).toDeadline(io);
|
||||
|
||||
while (true) {
|
||||
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
|
||||
error.Timeout => return error.Timeout,
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
else => return mapPhase(err, error.ReceiveFailed),
|
||||
};
|
||||
|
||||
// Off-path spoofing is the reason the source address is checked at
|
||||
// all: the first datagram to arrive is not necessarily the
|
||||
// resolver's.
|
||||
if (!msg.from.eql(&dest)) {
|
||||
self.stats.foreign_datagrams += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The kernel threw the tail away because `response_buf` was too
|
||||
// small, so the message cannot be parsed and TC=1 cannot be read
|
||||
// out of it.
|
||||
if (msg.flags.trunc) return error.ResponseTooLarge;
|
||||
|
||||
const reply = response_buf[0..msg.data.len];
|
||||
try transport.validateResponse(query, reply);
|
||||
|
||||
// Read after validation: acting on the TC bit of a message that has
|
||||
// not been matched to the query would let anything that reaches the
|
||||
// socket force a TCP connection.
|
||||
const parsed = dns_header.parse(reply) catch return error.BadResponse;
|
||||
if (parsed.flags.tc) {
|
||||
self.stats.udp_truncated += 1;
|
||||
return null;
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
|
||||
/// No stream read or write in 0.16.0 takes a timeout, so the budget is a
|
||||
/// second task and the loser is canceled. `ConnectOptions.timeout` is never
|
||||
/// set: the Threaded backend panics on it (Threaded.zig:12076).
|
||||
fn exchangeTcp(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
var outcomes: [2]Outcome = undefined;
|
||||
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
|
||||
defer race.cancelDiscard();
|
||||
|
||||
race.concurrent(.exchange, tcpOnce, .{ self, io, query, response_buf }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
};
|
||||
race.concurrent(.expiry, expire, .{ io, self.read_timeout }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
};
|
||||
|
||||
switch (try race.await()) {
|
||||
.exchange => |result| return result,
|
||||
.expiry => |result| {
|
||||
// A canceled sleep means this whole task is being torn down,
|
||||
// not that the resolver is slow.
|
||||
try result;
|
||||
return error.Timeout;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn tcpOnce(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
const dest = self.destination();
|
||||
|
||||
const stream = dest.connect(io, .{ .mode = .stream }) catch |err| {
|
||||
log.debug("forward resolver: tcp connect failed: {s}", .{@errorName(err)});
|
||||
return mapPhase(err, error.ConnectFailed);
|
||||
};
|
||||
defer closeStream(io, &stream);
|
||||
|
||||
const split = self.frame_buf.len / 2;
|
||||
var stream_writer = stream.writer(io, self.frame_buf[0..split]);
|
||||
var stream_reader = stream.reader(io, self.frame_buf[split..]);
|
||||
|
||||
const w = &stream_writer.interface;
|
||||
const prefix = transport.framePrefix(@intCast(query.len));
|
||||
w.writeAll(&prefix) catch |err| return sendFailure(&stream_writer, err);
|
||||
w.writeAll(query) catch |err| return sendFailure(&stream_writer, err);
|
||||
w.flush() catch |err| return sendFailure(&stream_writer, err);
|
||||
|
||||
const r = &stream_reader.interface;
|
||||
var prefix_bytes: [transport.prefix_len]u8 = undefined;
|
||||
r.readSliceAll(&prefix_bytes) catch |err| return receiveFailure(&stream_reader, err);
|
||||
|
||||
// RFC 1035 §4.2.2 gives no meaning to a zero-length message.
|
||||
const len = transport.parsePrefix(prefix_bytes);
|
||||
if (len == 0) return error.BadResponse;
|
||||
if (len > response_buf.len) return error.ResponseTooLarge;
|
||||
r.readSliceAll(response_buf[0..len]) catch |err| return receiveFailure(&stream_reader, err);
|
||||
|
||||
try transport.validateResponse(query, response_buf[0..len]);
|
||||
return response_buf[0..len];
|
||||
}
|
||||
|
||||
fn destination(self: *const ForwardClient) net.IpAddress {
|
||||
return self.resolver.addr.toIp(self.resolver.port);
|
||||
}
|
||||
};
|
||||
|
||||
const Outcome = union(enum) {
|
||||
exchange: transport.ExchangeError![]u8,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return duration.sleep(io);
|
||||
}
|
||||
|
||||
/// The local address a datagram to `dest` is sent from: same family, port
|
||||
/// chosen by the kernel.
|
||||
fn wildcardFor(dest: net.IpAddress) net.IpAddress {
|
||||
return switch (dest) {
|
||||
.ip4 => .{ .ip4 = .unspecified(0) },
|
||||
.ip6 => .{ .ip6 = .unspecified(0) },
|
||||
};
|
||||
}
|
||||
|
||||
/// The TCP budget cancels the exchange task. The next cancelable `Io` call in
|
||||
/// the `defer` chain would then return `error.Canceled` and skip the close,
|
||||
/// leaking the descriptor, so both closes run with cancellation blocked.
|
||||
fn closeStream(io: std.Io, stream: *const net.Stream) void {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
stream.close(io);
|
||||
}
|
||||
|
||||
fn closeSocket(io: std.Io, socket: *const net.Socket) void {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
socket.close(io);
|
||||
}
|
||||
|
||||
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
|
||||
return transport.mapLocal(err) orelse phase;
|
||||
}
|
||||
|
||||
/// `Io.Writer` collapses everything to `error.WriteFailed` and stashes the
|
||||
/// cause. Unwrapping it is what keeps `error.Canceled` and the local resource
|
||||
/// errors out of the peer fault group.
|
||||
fn sendFailure(stream_writer: *const net.Stream.Writer, err: anyerror) transport.ExchangeError {
|
||||
const cause: anyerror = if (err == error.WriteFailed and stream_writer.err != null)
|
||||
stream_writer.err.?
|
||||
else
|
||||
err;
|
||||
return mapPhase(cause, error.SendFailed);
|
||||
}
|
||||
|
||||
fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transport.ExchangeError {
|
||||
const cause: anyerror = if (err == error.ReadFailed and stream_reader.err != null)
|
||||
stream_reader.err.?
|
||||
else
|
||||
err;
|
||||
return mapPhase(cause, error.ReceiveFailed);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn testBuf() [min_frame_buf]u8 {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
test "ForwardClient satisfies the Client interface" {
|
||||
var buf = testBuf();
|
||||
var fc: ForwardClient = .init(
|
||||
try validate.parseResolver("udp://192.168.1.1:53"),
|
||||
&buf,
|
||||
.{ .raw = .fromMilliseconds(500), .clock = .awake },
|
||||
);
|
||||
|
||||
const iface: transport.Client = fc.client();
|
||||
try testing.expectEqual(@as(*anyopaque, @ptrCast(&fc)), iface.ptr);
|
||||
try testing.expectEqual(validate.ResolverScheme.udp, fc.resolver.scheme);
|
||||
try testing.expectEqual(@as(u16, 53), fc.resolver.port);
|
||||
}
|
||||
|
||||
test "the stats struct starts at zero" {
|
||||
const stats: ForwardClient.Stats = .{};
|
||||
try testing.expectEqual(@as(u64, 0), stats.queries);
|
||||
try testing.expectEqual(@as(u64, 0), stats.udp_truncated);
|
||||
try testing.expectEqual(@as(u64, 0), stats.foreign_datagrams);
|
||||
try testing.expectEqual(@as(u64, 0), stats.failures);
|
||||
}
|
||||
|
||||
test "init keeps a tcp resolver on the tcp path" {
|
||||
var buf = testBuf();
|
||||
const fc: ForwardClient = .init(
|
||||
try validate.parseResolver("tcp://[fd00::1]:5353"),
|
||||
&buf,
|
||||
.{ .raw = .fromSeconds(2), .clock = .awake },
|
||||
);
|
||||
try testing.expectEqual(validate.ResolverScheme.tcp, fc.resolver.scheme);
|
||||
try testing.expectEqual(@as(u16, 5353), fc.resolver.port);
|
||||
|
||||
const dest = fc.destination();
|
||||
try testing.expectEqual(net.IpAddress.Family.ip6, std.meta.activeTag(dest));
|
||||
try testing.expectEqual(@as(u16, 5353), dest.getPort());
|
||||
}
|
||||
|
||||
test "the destination carries the resolver's address and port" {
|
||||
var buf = testBuf();
|
||||
const fc: ForwardClient = .init(
|
||||
try validate.parseResolver("udp://192.168.1.1:5300"),
|
||||
&buf,
|
||||
.{ .raw = .fromSeconds(1), .clock = .awake },
|
||||
);
|
||||
const dest = fc.destination();
|
||||
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 1 }, &dest.ip4.bytes);
|
||||
try testing.expectEqual(@as(u16, 5300), dest.ip4.port);
|
||||
}
|
||||
|
||||
test "only the resolver's own address and port count as its datagram" {
|
||||
const dest: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 } };
|
||||
|
||||
const same: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 } };
|
||||
try testing.expect(same.eql(&dest));
|
||||
|
||||
// A different host, the right host on a different port, and the right
|
||||
// address in the wrong family are each a datagram this client discards.
|
||||
const other_host: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 2 }, .port = 53 } };
|
||||
try testing.expect(!other_host.eql(&dest));
|
||||
|
||||
const other_port: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 5353 } };
|
||||
try testing.expect(!other_port.eql(&dest));
|
||||
|
||||
const mapped: net.IpAddress = .{ .ip6 = .fromIp4(.{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 }) };
|
||||
try testing.expect(!mapped.eql(&dest));
|
||||
}
|
||||
|
||||
test "the local socket matches the resolver's family and takes an ephemeral port" {
|
||||
const v4 = wildcardFor(.{ .ip4 = .{ .bytes = .{ 1, 1, 1, 1 }, .port = 53 } });
|
||||
try testing.expectEqual(net.IpAddress.Family.ip4, std.meta.activeTag(v4));
|
||||
try testing.expectEqual(@as(u16, 0), v4.getPort());
|
||||
try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, &v4.ip4.bytes);
|
||||
|
||||
const v6 = wildcardFor(.{ .ip6 = .unspecified(53) });
|
||||
try testing.expectEqual(net.IpAddress.Family.ip6, std.meta.activeTag(v6));
|
||||
try testing.expectEqual(@as(u16, 0), v6.getPort());
|
||||
}
|
||||
|
||||
test "mapPhase keeps local resource and cancellation errors out of the peer fault group" {
|
||||
const local = [_]anyerror{
|
||||
error.OutOfMemory,
|
||||
error.SystemResources,
|
||||
error.ProcessFdQuotaExceeded,
|
||||
error.SystemFdQuotaExceeded,
|
||||
error.Unexpected,
|
||||
};
|
||||
for (local) |err| {
|
||||
try testing.expectEqual(
|
||||
transport.Group.local_resource,
|
||||
transport.group(mapPhase(err, error.ReceiveFailed)),
|
||||
);
|
||||
}
|
||||
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.Canceled,
|
||||
mapPhase(error.Canceled, error.ConnectFailed),
|
||||
);
|
||||
|
||||
// A refused connection is the resolver's side, so it stays a peer fault.
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.ConnectFailed,
|
||||
mapPhase(error.ConnectionRefused, error.ConnectFailed),
|
||||
);
|
||||
}
|
||||
|
||||
test "a stashed stream error is preferred over the collapsed one" {
|
||||
var stream_writer: net.Stream.Writer = undefined;
|
||||
stream_writer.err = error.Canceled;
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.Canceled,
|
||||
sendFailure(&stream_writer, error.WriteFailed),
|
||||
);
|
||||
|
||||
stream_writer.err = error.ConnectionResetByPeer;
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.SendFailed,
|
||||
sendFailure(&stream_writer, error.WriteFailed),
|
||||
);
|
||||
|
||||
var stream_reader: net.Stream.Reader = undefined;
|
||||
stream_reader.err = error.SystemResources;
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.SystemResources,
|
||||
receiveFailure(&stream_reader, error.ReadFailed),
|
||||
);
|
||||
|
||||
// A peer that closes mid-frame never reaches `err`, so the collapsed error
|
||||
// is what classifies it.
|
||||
stream_reader.err = null;
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.ReceiveFailed,
|
||||
receiveFailure(&stream_reader, error.EndOfStream),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Conditional forward zones (PLAN §6.5): an immutable, longest-suffix-first
|
||||
//! table built once from the `forward_zones` rows and read on the query path
|
||||
//! without allocating. Pure: an allocator and plain values, no `std.Io`, no
|
||||
//! clock.
|
||||
//!
|
||||
//! Resolver URLs are parsed by `config/validate.zig`'s `parseResolver`, whose
|
||||
//! doc comment names this file as its importer. There is no second resolver
|
||||
//! parser.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const model = @import("../config/model.zig");
|
||||
const validate = @import("../config/validate.zig");
|
||||
const name = @import("../dns/name.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
|
||||
pub const Zone = struct {
|
||||
/// Normalized: lowercase, no trailing dot.
|
||||
zone: []const u8,
|
||||
resolver: validate.Resolver,
|
||||
};
|
||||
|
||||
pub const Error = error{ OutOfMemory, BadZone, BadResolver, TooManyZones };
|
||||
pub const max_zones: usize = 1_000;
|
||||
|
||||
pub const Zones = struct {
|
||||
/// Sorted by descending label count then by name, so the first match found
|
||||
/// by a forward scan is the longest one.
|
||||
items: []const Zone,
|
||||
/// One block holding every `Zone.zone`; freed as a unit.
|
||||
names: []const u8,
|
||||
|
||||
pub const empty: Zones = .{ .items = &.{}, .names = &.{} };
|
||||
|
||||
/// `gpa` owns the result; `deinit` frees it. A row that fails to parse is
|
||||
/// an error, not a skipped row — `validate.zig` already rejects these, so
|
||||
/// reaching one here means the database was edited behind nxdns's back and
|
||||
/// silence would send a zone's queries to the wrong resolver.
|
||||
pub fn build(gpa: Allocator, rows: []const model.ForwardZone) Error!Zones {
|
||||
if (rows.len == 0) return .empty;
|
||||
if (rows.len > max_zones) return error.TooManyZones;
|
||||
|
||||
var names: std.ArrayList(u8) = .empty;
|
||||
defer names.deinit(gpa);
|
||||
var spans: std.ArrayList(Span) = .empty;
|
||||
defer spans.deinit(gpa);
|
||||
|
||||
var buf: [types.max_name_len]u8 = undefined;
|
||||
for (rows) |row| {
|
||||
const zone = normalizeName(row.zone, &buf) catch return error.BadZone;
|
||||
const resolver = validate.parseResolver(row.resolver) catch return error.BadResolver;
|
||||
try spans.append(gpa, .{
|
||||
.offset = names.items.len,
|
||||
.len = zone.len,
|
||||
.resolver = resolver,
|
||||
});
|
||||
try names.appendSlice(gpa, zone);
|
||||
}
|
||||
|
||||
const name_bytes = try names.toOwnedSlice(gpa);
|
||||
errdefer gpa.free(name_bytes);
|
||||
|
||||
const items = try gpa.alloc(Zone, spans.items.len);
|
||||
for (items, spans.items) |*item, span| item.* = .{
|
||||
.zone = name_bytes[span.offset..][0..span.len],
|
||||
.resolver = span.resolver,
|
||||
};
|
||||
std.mem.sort(Zone, items, {}, lessThan);
|
||||
|
||||
return .{ .items = items, .names = name_bytes };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Zones, gpa: Allocator) void {
|
||||
gpa.free(self.items);
|
||||
gpa.free(self.names);
|
||||
self.* = .empty;
|
||||
}
|
||||
|
||||
/// Longest-suffix match on label boundaries: `lan.home` matches
|
||||
/// `nas.lan.home` and `lan.home`, and does not match `notlan.home`.
|
||||
/// `domain` must be normalized (lowercase, no trailing dot).
|
||||
/// Allocation-free.
|
||||
pub fn match(self: *const Zones, domain: []const u8) ?*const Zone {
|
||||
for (self.items) |*zone| {
|
||||
if (suffixMatches(zone.zone, domain)) return zone;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `Zone.zone` slices are cut only after the name block stops growing, so the
|
||||
/// build pass records offsets instead of pointers.
|
||||
const Span = struct {
|
||||
offset: usize,
|
||||
len: usize,
|
||||
resolver: validate.Resolver,
|
||||
};
|
||||
|
||||
fn labelCount(zone: []const u8) usize {
|
||||
return std.mem.count(u8, zone, ".") + 1;
|
||||
}
|
||||
|
||||
fn lessThan(_: void, a: Zone, b: Zone) bool {
|
||||
const a_labels = labelCount(a.zone);
|
||||
const b_labels = labelCount(b.zone);
|
||||
if (a_labels != b_labels) return a_labels > b_labels;
|
||||
return std.mem.order(u8, a.zone, b.zone) == .lt;
|
||||
}
|
||||
|
||||
fn suffixMatches(zone: []const u8, domain: []const u8) bool {
|
||||
if (domain.len == zone.len) return std.mem.eql(u8, domain, zone);
|
||||
if (domain.len < zone.len + 1) return false;
|
||||
const start = domain.len - zone.len;
|
||||
return domain[start - 1] == '.' and std.mem.eql(u8, domain[start..], zone);
|
||||
}
|
||||
|
||||
const NameError = error{BadName};
|
||||
|
||||
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
|
||||
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected because query
|
||||
/// names arrive ASCII-lowercased, so a high byte could never match. The root
|
||||
/// zone is rejected too: a zone that forwards everything would bypass the
|
||||
/// upstream pool entirely, which is not what conditional forwarding means.
|
||||
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
|
||||
var rest = text;
|
||||
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
|
||||
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
|
||||
|
||||
for (rest, 0..) |byte, i| {
|
||||
if (byte >= 0x80) return error.BadName;
|
||||
buf[i] = std.ascii.toLower(byte);
|
||||
}
|
||||
const normalized = buf[0..rest.len];
|
||||
_ = name.fromText(normalized) catch return error.BadName;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const lan_resolver = "udp://192.168.1.1:53";
|
||||
const home_resolver = "tcp://[fd00::1]:5353";
|
||||
|
||||
test "a zone matches itself and its subdomains on label boundaries" {
|
||||
const rows = [_]model.ForwardZone{
|
||||
.{ .zone = "lan.home", .resolver = lan_resolver },
|
||||
};
|
||||
var zones = try Zones.build(testing.allocator, &rows);
|
||||
defer zones.deinit(testing.allocator);
|
||||
|
||||
for ([_][]const u8{ "lan.home", "nas.lan.home", "a.b.lan.home" }) |domain| {
|
||||
const found = zones.match(domain) orelse return error.TestExpectedMatch;
|
||||
try testing.expectEqualStrings("lan.home", found.zone);
|
||||
}
|
||||
|
||||
for ([_][]const u8{ "notlan.home", "home", "lan.home.evil.net", "" }) |domain| {
|
||||
try testing.expect(zones.match(domain) == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "the longest configured zone wins" {
|
||||
const rows = [_]model.ForwardZone{
|
||||
.{ .zone = "home", .resolver = home_resolver },
|
||||
.{ .zone = "lan.home", .resolver = lan_resolver },
|
||||
};
|
||||
var zones = try Zones.build(testing.allocator, &rows);
|
||||
defer zones.deinit(testing.allocator);
|
||||
|
||||
const nas = zones.match("nas.lan.home") orelse return error.TestExpectedMatch;
|
||||
try testing.expectEqualStrings("lan.home", nas.zone);
|
||||
try testing.expectEqual(validate.ResolverScheme.udp, nas.resolver.scheme);
|
||||
|
||||
const printer = zones.match("printer.home") orelse return error.TestExpectedMatch;
|
||||
try testing.expectEqualStrings("home", printer.zone);
|
||||
try testing.expectEqual(validate.ResolverScheme.tcp, printer.resolver.scheme);
|
||||
try testing.expectEqual(@as(u16, 5353), printer.resolver.port);
|
||||
}
|
||||
|
||||
test "a reverse zone matches every name under it" {
|
||||
const rows = [_]model.ForwardZone{
|
||||
.{ .zone = "10.in-addr.arpa", .resolver = lan_resolver },
|
||||
};
|
||||
var zones = try Zones.build(testing.allocator, &rows);
|
||||
defer zones.deinit(testing.allocator);
|
||||
|
||||
const found = zones.match("5.4.3.10.in-addr.arpa") orelse return error.TestExpectedMatch;
|
||||
try testing.expectEqualStrings("10.in-addr.arpa", found.zone);
|
||||
try testing.expect(zones.match("5.4.3.11.in-addr.arpa") == null);
|
||||
}
|
||||
|
||||
test "uppercase and trailing-dot zones normalize to one key" {
|
||||
const rows = [_]model.ForwardZone{
|
||||
.{ .zone = "LAN.Home.", .resolver = lan_resolver },
|
||||
};
|
||||
var zones = try Zones.build(testing.allocator, &rows);
|
||||
defer zones.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqualStrings("lan.home", zones.items[0].zone);
|
||||
try testing.expect(zones.match("nas.lan.home") != null);
|
||||
}
|
||||
|
||||
test "the resolver comes from parseResolver" {
|
||||
const rows = [_]model.ForwardZone{
|
||||
.{ .zone = "lan.home", .resolver = lan_resolver },
|
||||
};
|
||||
var zones = try Zones.build(testing.allocator, &rows);
|
||||
defer zones.deinit(testing.allocator);
|
||||
|
||||
const expected = try validate.parseResolver(lan_resolver);
|
||||
const found = zones.match("lan.home") orelse return error.TestExpectedMatch;
|
||||
try testing.expectEqual(expected.scheme, found.resolver.scheme);
|
||||
try testing.expectEqual(expected.port, found.resolver.port);
|
||||
try testing.expect(expected.addr.eql(found.resolver.addr));
|
||||
}
|
||||
|
||||
test "a bad resolver URL is an error" {
|
||||
for ([_][]const u8{ "https://dns.example/dns-query", "udp://192.168.1.1", "udp://nas.lan:53" }) |url| {
|
||||
const rows = [_]model.ForwardZone{.{ .zone = "lan.home", .resolver = url }};
|
||||
try testing.expectError(error.BadResolver, Zones.build(testing.allocator, &rows));
|
||||
}
|
||||
}
|
||||
|
||||
test "a bad zone is an error" {
|
||||
for ([_][]const u8{ "lan..home", ".", "" }) |zone| {
|
||||
const rows = [_]model.ForwardZone{.{ .zone = zone, .resolver = lan_resolver }};
|
||||
try testing.expectError(error.BadZone, Zones.build(testing.allocator, &rows));
|
||||
}
|
||||
}
|
||||
|
||||
test "too many rows is an error" {
|
||||
const rows = try testing.allocator.alloc(model.ForwardZone, max_zones + 1);
|
||||
defer testing.allocator.free(rows);
|
||||
for (rows) |*row| row.* = .{ .zone = "lan.home", .resolver = lan_resolver };
|
||||
try testing.expectError(error.TooManyZones, Zones.build(testing.allocator, rows));
|
||||
}
|
||||
|
||||
test "an empty row set builds the empty table" {
|
||||
var zones = try Zones.build(testing.allocator, &[_]model.ForwardZone{});
|
||||
defer zones.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), zones.items.len);
|
||||
try testing.expect(zones.match("lan.home") == null);
|
||||
}
|
||||
|
||||
fn buildUnderFailure(gpa: Allocator) !void {
|
||||
const rows = [_]model.ForwardZone{
|
||||
.{ .zone = "home", .resolver = home_resolver },
|
||||
.{ .zone = "lan.home", .resolver = lan_resolver },
|
||||
};
|
||||
var zones = try Zones.build(gpa, &rows);
|
||||
defer zones.deinit(gpa);
|
||||
try testing.expectEqualStrings("lan.home", zones.items[0].zone);
|
||||
}
|
||||
|
||||
test "build leaks nothing under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
//! Local DNS records (PLAN §6.4): an immutable, sorted lookup table built once
|
||||
//! from the `local_records` rows and read on the query path without allocating.
|
||||
//! Pure: an allocator and plain values, no `std.Io`, no clock.
|
||||
//!
|
||||
//! Local records are group-independent and are matched before filtering, so a
|
||||
//! name that has a record here never reaches the blocklists.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const model = @import("../config/model.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const name = @import("../dns/name.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const question = @import("../dns/question.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const address = @import("../platform/address.zig");
|
||||
|
||||
pub const Value = union(enum) { a: [4]u8, aaaa: [16]u8, cname: name.Name };
|
||||
|
||||
pub const Record = struct {
|
||||
/// Normalized owner name: lowercase, no trailing dot.
|
||||
owner: []const u8,
|
||||
value: Value,
|
||||
ttl: u32,
|
||||
};
|
||||
|
||||
pub const Error = error{ OutOfMemory, BadRecordValue, BadRecordName, TooManyRecords };
|
||||
pub const max_records: usize = 10_000;
|
||||
|
||||
pub const Records = struct {
|
||||
/// Sorted by (owner, rtype) so lookup is a binary search and the answer
|
||||
/// order for one name is stable across restarts.
|
||||
items: []const Record,
|
||||
/// One block holding every `Record.owner`; freed as a unit.
|
||||
owners: []const u8,
|
||||
|
||||
pub const empty: Records = .{ .items = &.{}, .owners = &.{} };
|
||||
|
||||
/// `gpa` owns the result; `deinit` frees it. Values are parsed here, once.
|
||||
/// A bad value is an error, not a skipped row — `validate.zig` already
|
||||
/// rejects these, so reaching one here means the database was edited behind
|
||||
/// nxdns's back and silence would make a record vanish with no signal.
|
||||
pub fn build(gpa: Allocator, rows: []const model.LocalRecord) Error!Records {
|
||||
if (rows.len == 0) return .empty;
|
||||
if (rows.len > max_records) return error.TooManyRecords;
|
||||
|
||||
var owners: std.ArrayList(u8) = .empty;
|
||||
defer owners.deinit(gpa);
|
||||
var spans: std.ArrayList(Span) = .empty;
|
||||
defer spans.deinit(gpa);
|
||||
|
||||
var buf: [types.max_name_len]u8 = undefined;
|
||||
for (rows) |row| {
|
||||
const owner = normalizeName(row.name, &buf) catch return error.BadRecordName;
|
||||
const value = try parseValue(row.rtype, row.value);
|
||||
try spans.append(gpa, .{
|
||||
.offset = owners.items.len,
|
||||
.len = owner.len,
|
||||
.value = value,
|
||||
.ttl = row.ttl,
|
||||
});
|
||||
try owners.appendSlice(gpa, owner);
|
||||
}
|
||||
|
||||
const owner_bytes = try owners.toOwnedSlice(gpa);
|
||||
errdefer gpa.free(owner_bytes);
|
||||
|
||||
const items = try gpa.alloc(Record, spans.items.len);
|
||||
for (items, spans.items) |*item, span| item.* = .{
|
||||
.owner = owner_bytes[span.offset..][0..span.len],
|
||||
.value = span.value,
|
||||
.ttl = span.ttl,
|
||||
};
|
||||
std.mem.sort(Record, items, {}, lessThan);
|
||||
|
||||
return .{ .items = items, .owners = owner_bytes };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Records, gpa: Allocator) void {
|
||||
gpa.free(self.items);
|
||||
gpa.free(self.owners);
|
||||
self.* = .empty;
|
||||
}
|
||||
|
||||
/// All records for `domain` whose type matches `qtype`. `domain` must be
|
||||
/// normalized (lowercase, no trailing dot). An empty slice means the name
|
||||
/// has no local record of that type. Allocation-free.
|
||||
///
|
||||
/// A CNAME answers every qtype and excludes every other type at the same
|
||||
/// name (RFC 1034 §3.6.2), so a name carrying one answers with the CNAME
|
||||
/// alone whatever else the row set holds.
|
||||
pub fn lookup(self: *const Records, domain: []const u8, qtype: types.Type) []const Record {
|
||||
const at_name = self.ownerRange(domain);
|
||||
if (at_name.len == 0) return at_name;
|
||||
|
||||
const cnames = rankRun(at_name, rank_cname);
|
||||
if (cnames.len != 0) return cnames;
|
||||
|
||||
return switch (qtype) {
|
||||
.a => rankRun(at_name, rank_a),
|
||||
.aaaa => rankRun(at_name, rank_aaaa),
|
||||
.any => at_name,
|
||||
else => at_name[0..0],
|
||||
};
|
||||
}
|
||||
|
||||
/// True when the name has any local record of any type. The handler needs
|
||||
/// this to answer NODATA instead of forwarding a name nxdns owns.
|
||||
pub fn hasName(self: *const Records, domain: []const u8) bool {
|
||||
return self.ownerRange(domain).len != 0;
|
||||
}
|
||||
|
||||
fn ownerRange(self: *const Records, domain: []const u8) []const Record {
|
||||
var low: usize = 0;
|
||||
var high: usize = self.items.len;
|
||||
while (low < high) {
|
||||
const mid = low + (high - low) / 2;
|
||||
if (std.mem.order(u8, self.items[mid].owner, domain) == .lt) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
var end = low;
|
||||
while (end < self.items.len and std.mem.eql(u8, self.items[end].owner, domain)) end += 1;
|
||||
return self.items[low..end];
|
||||
}
|
||||
};
|
||||
|
||||
/// Writes `records` as answers into a builder the caller has already
|
||||
/// initialized with the request header and question. Mechanism only.
|
||||
pub fn writeAnswers(
|
||||
b: *packet.ResponseBuilder,
|
||||
owner: name.Name,
|
||||
records: []const Record,
|
||||
) packet.ResponseBuilder.Error!void {
|
||||
for (records) |rec| {
|
||||
switch (rec.value) {
|
||||
.a => |bytes| try b.addAnswer(owner, .a, .in, rec.ttl, &bytes),
|
||||
.aaaa => |bytes| try b.addAnswer(owner, .aaaa, .in, rec.ttl, &bytes),
|
||||
.cname => |target| try b.addAnswer(owner, .cname, .in, rec.ttl, target.wire()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `Record.owner` slices are cut only after the owner block stops growing, so
|
||||
/// the build pass records offsets instead of pointers.
|
||||
const Span = struct {
|
||||
offset: usize,
|
||||
len: usize,
|
||||
value: Value,
|
||||
ttl: u32,
|
||||
};
|
||||
|
||||
const rank_a: u2 = 0;
|
||||
const rank_aaaa: u2 = 1;
|
||||
const rank_cname: u2 = 2;
|
||||
|
||||
fn rank(value: Value) u2 {
|
||||
return switch (value) {
|
||||
.a => rank_a,
|
||||
.aaaa => rank_aaaa,
|
||||
.cname => rank_cname,
|
||||
};
|
||||
}
|
||||
|
||||
fn lessThan(_: void, a: Record, b: Record) bool {
|
||||
return switch (std.mem.order(u8, a.owner, b.owner)) {
|
||||
.lt => true,
|
||||
.gt => false,
|
||||
.eq => rank(a.value) < rank(b.value),
|
||||
};
|
||||
}
|
||||
|
||||
/// The run of one rank inside a single name's records, which the (owner, rtype)
|
||||
/// sort makes contiguous.
|
||||
fn rankRun(records: []const Record, wanted: u2) []const Record {
|
||||
var start: usize = 0;
|
||||
while (start < records.len and rank(records[start].value) < wanted) start += 1;
|
||||
var end = start;
|
||||
while (end < records.len and rank(records[end].value) == wanted) end += 1;
|
||||
return records[start..end];
|
||||
}
|
||||
|
||||
const NameError = error{BadName};
|
||||
|
||||
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
|
||||
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected: query names
|
||||
/// arrive ASCII-lowercased, so a high byte here could never be matched and a
|
||||
/// record that can never answer is a configuration error worth reporting. The
|
||||
/// root name is rejected for the same reason — nothing can match it.
|
||||
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
|
||||
var rest = text;
|
||||
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
|
||||
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
|
||||
|
||||
for (rest, 0..) |byte, i| {
|
||||
if (byte >= 0x80) return error.BadName;
|
||||
buf[i] = std.ascii.toLower(byte);
|
||||
}
|
||||
const normalized = buf[0..rest.len];
|
||||
_ = name.fromText(normalized) catch return error.BadName;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!Value {
|
||||
switch (rtype) {
|
||||
.a => {
|
||||
const addr = address.NetAddress.parse(text) catch return error.BadRecordValue;
|
||||
return switch (addr) {
|
||||
.ip4 => |bytes| Value{ .a = bytes },
|
||||
.ip6 => return error.BadRecordValue,
|
||||
};
|
||||
},
|
||||
.aaaa => {
|
||||
const addr = address.NetAddress.parse(text) catch return error.BadRecordValue;
|
||||
return switch (addr) {
|
||||
.ip4 => return error.BadRecordValue,
|
||||
.ip6 => |bytes| Value{ .aaaa = bytes },
|
||||
};
|
||||
},
|
||||
.cname => {
|
||||
var buf: [types.max_name_len]u8 = undefined;
|
||||
const target = normalizeName(text, &buf) catch return error.BadRecordValue;
|
||||
return .{ .cname = name.fromText(target) catch return error.BadRecordValue };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const sample_rows = [_]model.LocalRecord{
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5", .ttl = 120 },
|
||||
.{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::1", .ttl = 240 },
|
||||
.{ .name = "printer.lan", .rtype = .a, .value = "192.168.1.6", .ttl = 60 },
|
||||
};
|
||||
|
||||
test "lookup returns the records of the queried type" {
|
||||
var records = try Records.build(testing.allocator, &sample_rows);
|
||||
defer records.deinit(testing.allocator);
|
||||
|
||||
const a = records.lookup("nas.lan", .a);
|
||||
try testing.expectEqual(@as(usize, 1), a.len);
|
||||
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 5 }, &a[0].value.a);
|
||||
try testing.expectEqual(@as(u32, 120), a[0].ttl);
|
||||
|
||||
const aaaa = records.lookup("nas.lan", .aaaa);
|
||||
try testing.expectEqual(@as(usize, 1), aaaa.len);
|
||||
try testing.expectEqual(@as(u32, 240), aaaa[0].ttl);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), records.lookup("nas.lan", .mx).len);
|
||||
try testing.expectEqual(@as(usize, 0), records.lookup("other.lan", .a).len);
|
||||
}
|
||||
|
||||
test "lookup returns the CNAME for every qtype" {
|
||||
const rows = [_]model.LocalRecord{
|
||||
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan", .ttl = 300 },
|
||||
};
|
||||
var records = try Records.build(testing.allocator, &rows);
|
||||
defer records.deinit(testing.allocator);
|
||||
|
||||
for ([_]types.Type{ .a, .aaaa, .mx, .https, .any }) |qtype| {
|
||||
const found = records.lookup("www.lan", qtype);
|
||||
try testing.expectEqual(@as(usize, 1), found.len);
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
(try name.fromText("nas.lan")).wire(),
|
||||
found[0].value.cname.wire(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "hasName covers every type at the name" {
|
||||
var records = try Records.build(testing.allocator, &sample_rows);
|
||||
defer records.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(records.hasName("nas.lan"));
|
||||
try testing.expect(records.hasName("printer.lan"));
|
||||
try testing.expect(!records.hasName("lan"));
|
||||
try testing.expect(!records.hasName("nas.lan.evil.net"));
|
||||
}
|
||||
|
||||
test "two A records for one name come back in a stable order" {
|
||||
const rows = [_]model.LocalRecord{
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.6" },
|
||||
};
|
||||
|
||||
var first = try Records.build(testing.allocator, &rows);
|
||||
defer first.deinit(testing.allocator);
|
||||
var second = try Records.build(testing.allocator, &rows);
|
||||
defer second.deinit(testing.allocator);
|
||||
|
||||
const a = first.lookup("nas.lan", .a);
|
||||
const b = second.lookup("nas.lan", .a);
|
||||
try testing.expectEqual(@as(usize, 2), a.len);
|
||||
try testing.expectEqual(@as(usize, 2), b.len);
|
||||
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 5 }, &a[0].value.a);
|
||||
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 6 }, &a[1].value.a);
|
||||
for (a, b) |lhs, rhs| try testing.expectEqualSlices(u8, &lhs.value.a, &rhs.value.a);
|
||||
}
|
||||
|
||||
test "uppercase and trailing-dot owners normalize to one key" {
|
||||
const rows = [_]model.LocalRecord{
|
||||
.{ .name = "NAS.Lan.", .rtype = .a, .value = "192.168.1.5" },
|
||||
};
|
||||
var records = try Records.build(testing.allocator, &rows);
|
||||
defer records.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqualStrings("nas.lan", records.items[0].owner);
|
||||
try testing.expectEqual(@as(usize, 1), records.lookup("nas.lan", .a).len);
|
||||
}
|
||||
|
||||
test "a bad A value is an error" {
|
||||
const rows = [_]model.LocalRecord{
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "::1" },
|
||||
};
|
||||
try testing.expectError(error.BadRecordValue, Records.build(testing.allocator, &rows));
|
||||
}
|
||||
|
||||
test "a bad AAAA value is an error" {
|
||||
const rows = [_]model.LocalRecord{
|
||||
.{ .name = "nas.lan", .rtype = .aaaa, .value = "192.168.1.5" },
|
||||
};
|
||||
try testing.expectError(error.BadRecordValue, Records.build(testing.allocator, &rows));
|
||||
}
|
||||
|
||||
test "a bad CNAME target is an error" {
|
||||
const rows = [_]model.LocalRecord{
|
||||
.{ .name = "www.lan", .rtype = .cname, .value = "nas..lan" },
|
||||
};
|
||||
try testing.expectError(error.BadRecordValue, Records.build(testing.allocator, &rows));
|
||||
}
|
||||
|
||||
test "an unparseable owner is an error" {
|
||||
const rows = [_]model.LocalRecord{
|
||||
.{ .name = "nas..lan", .rtype = .a, .value = "192.168.1.5" },
|
||||
};
|
||||
try testing.expectError(error.BadRecordName, Records.build(testing.allocator, &rows));
|
||||
|
||||
const root = [_]model.LocalRecord{
|
||||
.{ .name = ".", .rtype = .a, .value = "192.168.1.5" },
|
||||
};
|
||||
try testing.expectError(error.BadRecordName, Records.build(testing.allocator, &root));
|
||||
}
|
||||
|
||||
test "too many rows is an error" {
|
||||
const rows = try testing.allocator.alloc(model.LocalRecord, max_records + 1);
|
||||
defer testing.allocator.free(rows);
|
||||
for (rows) |*row| row.* = .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" };
|
||||
try testing.expectError(error.TooManyRecords, Records.build(testing.allocator, rows));
|
||||
}
|
||||
|
||||
test "an empty row set builds the empty table" {
|
||||
var records = try Records.build(testing.allocator, &[_]model.LocalRecord{});
|
||||
defer records.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), records.items.len);
|
||||
try testing.expect(!records.hasName("nas.lan"));
|
||||
try testing.expectEqual(@as(usize, 0), records.lookup("nas.lan", .a).len);
|
||||
}
|
||||
|
||||
fn requestHeader() header.Header {
|
||||
return .{
|
||||
.id = 0x4242,
|
||||
.flags = .{
|
||||
.rcode = .no_error,
|
||||
.z = 0,
|
||||
.ra = false,
|
||||
.rd = true,
|
||||
.tc = false,
|
||||
.aa = false,
|
||||
.opcode = .query,
|
||||
.qr = false,
|
||||
},
|
||||
.qdcount = 1,
|
||||
.ancount = 0,
|
||||
.nscount = 0,
|
||||
.arcount = 0,
|
||||
};
|
||||
}
|
||||
|
||||
test "writeAnswers emits records the parser reads back" {
|
||||
var records = try Records.build(testing.allocator, &sample_rows);
|
||||
defer records.deinit(testing.allocator);
|
||||
|
||||
const owner = try name.fromText("nas.lan");
|
||||
const q: question.Question = .{ .name = owner, .qtype = .any, .qclass = .in };
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var builder = try packet.ResponseBuilder.init(&buf, requestHeader(), q);
|
||||
try writeAnswers(&builder, owner, records.lookup("nas.lan", .any));
|
||||
const message = builder.finish();
|
||||
|
||||
const parsed = try packet.parse(message);
|
||||
try testing.expectEqual(@as(u16, 2), parsed.header.ancount);
|
||||
|
||||
var it = packet.answers(parsed);
|
||||
const first = (try it.next()).?;
|
||||
try testing.expectEqual(types.Type.a, first.rtype);
|
||||
try testing.expectEqual(@as(u16, @intFromEnum(types.Class.in)), first.class);
|
||||
try testing.expectEqual(@as(u32, 120), first.ttl);
|
||||
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 5 }, first.rdata.slice(parsed.bytes));
|
||||
|
||||
const second = (try it.next()).?;
|
||||
try testing.expectEqual(types.Type.aaaa, second.rtype);
|
||||
try testing.expectEqual(@as(u32, 240), second.ttl);
|
||||
try testing.expectEqual(@as(usize, 16), second.rdata.len);
|
||||
|
||||
try testing.expect((try it.next()) == null);
|
||||
}
|
||||
|
||||
test "writeAnswers emits a CNAME in wire form" {
|
||||
const rows = [_]model.LocalRecord{
|
||||
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan", .ttl = 300 },
|
||||
};
|
||||
var records = try Records.build(testing.allocator, &rows);
|
||||
defer records.deinit(testing.allocator);
|
||||
|
||||
const owner = try name.fromText("www.lan");
|
||||
const q: question.Question = .{ .name = owner, .qtype = .a, .qclass = .in };
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var builder = try packet.ResponseBuilder.init(&buf, requestHeader(), q);
|
||||
try writeAnswers(&builder, owner, records.lookup("www.lan", .a));
|
||||
const message = builder.finish();
|
||||
|
||||
const parsed = try packet.parse(message);
|
||||
try testing.expectEqual(@as(u16, 1), parsed.header.ancount);
|
||||
|
||||
var it = packet.answers(parsed);
|
||||
const answer = (try it.next()).?;
|
||||
try testing.expectEqual(types.Type.cname, answer.rtype);
|
||||
try testing.expectEqual(@as(u32, 300), answer.ttl);
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
(try name.fromText("nas.lan")).wire(),
|
||||
answer.rdata.slice(parsed.bytes),
|
||||
);
|
||||
}
|
||||
|
||||
fn buildUnderFailure(gpa: Allocator) !void {
|
||||
var records = try Records.build(gpa, &sample_rows);
|
||||
defer records.deinit(gpa);
|
||||
try testing.expectEqual(@as(usize, 3), records.items.len);
|
||||
}
|
||||
|
||||
test "build leaks nothing under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
|
||||
}
|
||||
@@ -199,11 +199,11 @@ fn tcpQuery(io: std.Io, address: net.IpAddress, id: u16) anyerror!void {
|
||||
var query_buf: [query_bytes.len]u8 = undefined;
|
||||
const query = queryWithId(&query_buf, id);
|
||||
|
||||
try writer.interface.writeAll(&tcp_server.framePrefix(@intCast(query.len)));
|
||||
try writer.interface.writeAll(&transport.framePrefix(@intCast(query.len)));
|
||||
try writer.interface.writeAll(query);
|
||||
try writer.interface.flush();
|
||||
|
||||
const len = tcp_server.parsePrefix((try reader.interface.takeArray(tcp_server.prefix_len)).*);
|
||||
const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
|
||||
try expectAnswer(try reader.interface.take(len), id);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,6 @@ const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const log = std.log.scoped(.tcp_server);
|
||||
|
||||
/// RFC 1035 §4.2.2: the message length prefix is two bytes, big-endian.
|
||||
pub const prefix_len = 2;
|
||||
|
||||
/// The stream buffers only stage the framing bytes. A message longer than this
|
||||
/// is read straight into `Conn.query` and written straight from `Conn.reply`,
|
||||
/// so making them larger would buy nothing.
|
||||
@@ -220,7 +217,7 @@ pub const TcpServer = struct {
|
||||
const budget = self.options.idle_timeout;
|
||||
|
||||
while (true) {
|
||||
var prefix: [prefix_len]u8 = undefined;
|
||||
var prefix: [transport.prefix_len]u8 = undefined;
|
||||
var got: usize = 0;
|
||||
switch (race(io, budget, readPrefix, .{ &reader.interface, &prefix, &got })) {
|
||||
.ok => {},
|
||||
@@ -238,14 +235,14 @@ pub const TcpServer = struct {
|
||||
// A client that closes between messages has finished asking, which
|
||||
// is the normal end of a connection, not a failure.
|
||||
if (got == 0) return;
|
||||
if (got != prefix_len) {
|
||||
if (got != transport.prefix_len) {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 1035 §4.2.2 gives no meaning to a zero-length message, and
|
||||
// the prefix is a u16 so it can never exceed `max_message_len`.
|
||||
const len = parsePrefix(prefix);
|
||||
const len = transport.parsePrefix(prefix);
|
||||
if (len == 0) {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
@@ -267,7 +264,7 @@ pub const TcpServer = struct {
|
||||
.reply => |b| b,
|
||||
};
|
||||
|
||||
const out = framePrefix(@intCast(bytes.len));
|
||||
const out = transport.framePrefix(@intCast(bytes.len));
|
||||
switch (race(io, budget, writeReply, .{ &writer.interface, &out, bytes })) {
|
||||
.ok => {},
|
||||
.canceled => return,
|
||||
@@ -333,17 +330,6 @@ pub const TcpServer = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// RFC 1035 §4.2.2: the message length as a 2-byte big-endian integer.
|
||||
pub fn framePrefix(len: u16) [prefix_len]u8 {
|
||||
var out: [prefix_len]u8 = undefined;
|
||||
std.mem.writeInt(u16, &out, len, .big);
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn parsePrefix(bytes: [prefix_len]u8) u16 {
|
||||
return std.mem.readInt(u16, &bytes, .big);
|
||||
}
|
||||
|
||||
/// The capacity rule, without the mutex, so it is testable without a backend.
|
||||
fn firstFree(conns: []const TcpServer.Conn) ?usize {
|
||||
for (conns, 0..) |*conn, index| {
|
||||
@@ -402,7 +388,7 @@ fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
|
||||
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
|
||||
/// that closed cleanly between messages, and only a partial prefix is an error.
|
||||
fn readPrefix(reader: *std.Io.Reader, buf: *[prefix_len]u8, out_len: *usize) anyerror!void {
|
||||
fn readPrefix(reader: *std.Io.Reader, buf: *[transport.prefix_len]u8, out_len: *usize) anyerror!void {
|
||||
out_len.* = try reader.readSliceShort(buf);
|
||||
}
|
||||
|
||||
@@ -410,7 +396,7 @@ fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
|
||||
return reader.readSliceAll(buf);
|
||||
}
|
||||
|
||||
fn writeReply(writer: *std.Io.Writer, prefix: *const [prefix_len]u8, bytes: []const u8) anyerror!void {
|
||||
fn writeReply(writer: *std.Io.Writer, prefix: *const [transport.prefix_len]u8, bytes: []const u8) anyerror!void {
|
||||
try writer.writeAll(prefix);
|
||||
try writer.writeAll(bytes);
|
||||
try writer.flush();
|
||||
@@ -422,20 +408,6 @@ fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "the length prefix is big-endian and round-trips" {
|
||||
try testing.expectEqualSlices(u8, &.{ 0x00, 0x00 }, &framePrefix(0));
|
||||
try testing.expectEqualSlices(u8, &.{ 0x01, 0x00 }, &framePrefix(256));
|
||||
try testing.expectEqualSlices(u8, &.{ 0xff, 0xff }, &framePrefix(65535));
|
||||
|
||||
for ([_]u16{ 0, 1, 12, 512, 4096, 65534, 65535 }) |len| {
|
||||
try testing.expectEqual(len, parsePrefix(framePrefix(len)));
|
||||
}
|
||||
}
|
||||
|
||||
test "the prefix ceiling is the message ceiling" {
|
||||
try testing.expectEqual(@as(u16, transport.max_message_len), parsePrefix(.{ 0xff, 0xff }));
|
||||
}
|
||||
|
||||
fn testConns(count: usize) ![]TcpServer.Conn {
|
||||
const conns = try testing.allocator.alloc(TcpServer.Conn, count);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
|
||||
@@ -117,11 +117,11 @@ fn twoQueriesOnOneConnection(io: std.Io, address: net.IpAddress) anyerror!void {
|
||||
var writer = stream.writer(io, &write_buf);
|
||||
|
||||
for (0..2) |_| {
|
||||
try writer.interface.writeAll(&tcp_server.framePrefix(@intCast(query_bytes.len)));
|
||||
try writer.interface.writeAll(&transport.framePrefix(@intCast(query_bytes.len)));
|
||||
try writer.interface.writeAll(query_bytes);
|
||||
try writer.interface.flush();
|
||||
|
||||
const len = tcp_server.parsePrefix((try reader.interface.takeArray(tcp_server.prefix_len)).*);
|
||||
const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
|
||||
try expectAnswersQuery(try reader.interface.take(len));
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ fn sendZeroLength(io: std.Io, address: net.IpAddress) anyerror!void {
|
||||
|
||||
var write_buf: [64]u8 = undefined;
|
||||
var writer = stream.writer(io, &write_buf);
|
||||
try writer.interface.writeAll(&tcp_server.framePrefix(0));
|
||||
try writer.interface.writeAll(&transport.framePrefix(0));
|
||||
try writer.interface.flush();
|
||||
|
||||
var read_buf: [64]u8 = undefined;
|
||||
|
||||
@@ -65,6 +65,17 @@ pub fn countGroups(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM groups");
|
||||
}
|
||||
|
||||
/// The row id of one group by name, or null. `listGroups` returns model values
|
||||
/// without row ids by design (ids are not stable across an import); the filter
|
||||
/// snapshot needs them to map a decision back to a row.
|
||||
pub fn groupId(database: *db.Db, group_name: []const u8) db.Error!?i64 {
|
||||
var stmt = try database.prepare("SELECT id FROM groups WHERE name = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, group_name);
|
||||
if (!try stmt.step()) return null;
|
||||
return stmt.columnInt(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// group_sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -77,6 +77,106 @@ pub fn countBlocklistSources(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT count(*) FROM blocklist_sources");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runtime columns (milestone 5 S8.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The blocklist manager needs the row id (the compiled files are named after
|
||||
// it) and the counters the refresh writes. Neither belongs in `model`: an
|
||||
// export carries configuration, and these are facts a running server produces.
|
||||
// Both functions below are additive; no export path reads them.
|
||||
|
||||
pub const SourceRow = struct {
|
||||
id: i64,
|
||||
url: []const u8,
|
||||
name: []const u8,
|
||||
enabled: bool,
|
||||
last_updated: ?i64,
|
||||
domain_count: i64,
|
||||
wildcard_count: i64,
|
||||
skipped_regex_count: i64,
|
||||
checksum: ?[]const u8,
|
||||
};
|
||||
|
||||
pub const SourceStats = struct {
|
||||
last_updated: i64,
|
||||
domain_count: i64,
|
||||
wildcard_count: i64,
|
||||
skipped_regex_count: i64,
|
||||
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body.
|
||||
checksum: []const u8,
|
||||
};
|
||||
|
||||
const list_rows_sql =
|
||||
\\SELECT id, url, name, enabled, last_updated,
|
||||
\\ domain_count, wildcard_count, skipped_regex_count, checksum
|
||||
\\ FROM blocklist_sources ORDER BY url
|
||||
;
|
||||
|
||||
/// Every source with its row id and its runtime columns, in the same `url`
|
||||
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
|
||||
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
|
||||
pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow) {
|
||||
var stmt = try database.prepare(list_rows_sql);
|
||||
defer stmt.deinit();
|
||||
|
||||
var out: std.ArrayList(SourceRow) = .empty;
|
||||
// `errdefer`s run in reverse: the free pass is declared last so it runs
|
||||
// before the backing array is released.
|
||||
errdefer out.deinit(gpa);
|
||||
errdefer freeSourceRows(gpa, out.items);
|
||||
|
||||
while (try stmt.step()) {
|
||||
const url = try stmt.columnTextAlloc(gpa, 1);
|
||||
errdefer gpa.free(url);
|
||||
const name = try stmt.columnTextAlloc(gpa, 2);
|
||||
errdefer gpa.free(name);
|
||||
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
|
||||
errdefer if (checksum) |value| gpa.free(value);
|
||||
try out.append(gpa, .{
|
||||
.id = stmt.columnInt(0),
|
||||
.url = url,
|
||||
.name = name,
|
||||
.enabled = stmt.columnBool(3),
|
||||
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
|
||||
.domain_count = stmt.columnInt(5),
|
||||
.wildcard_count = stmt.columnInt(6),
|
||||
.skipped_regex_count = stmt.columnInt(7),
|
||||
.checksum = checksum,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
|
||||
for (items) |item| {
|
||||
gpa.free(item.url);
|
||||
gpa.free(item.name);
|
||||
if (item.checksum) |value| gpa.free(value);
|
||||
}
|
||||
}
|
||||
|
||||
const update_stats_sql =
|
||||
\\UPDATE blocklist_sources
|
||||
\\ SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4,
|
||||
\\ skipped_regex_count = ?5, checksum = ?6
|
||||
\\ WHERE id = ?1
|
||||
;
|
||||
|
||||
/// Writes the runtime columns for one source after a compile. The configuration
|
||||
/// columns (`url`, `name`, `enabled`, `is_suggested`) are untouched.
|
||||
pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error!void {
|
||||
var stmt = try database.prepare(update_stats_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
try stmt.bindInt(2, stats.last_updated);
|
||||
try stmt.bindInt(3, stats.domain_count);
|
||||
try stmt.bindInt(4, stats.wildcard_count);
|
||||
try stmt.bindInt(5, stats.skipped_regex_count);
|
||||
try stmt.bindText(6, stats.checksum);
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -175,3 +275,89 @@ fn listBlocklistSourcesUnderFailure(gpa: Allocator) !void {
|
||||
test "listBlocklistSources is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listBlocklistSourcesUnderFailure, .{});
|
||||
}
|
||||
|
||||
test "listSourceRows returns row ids and the runtime columns in url order" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSources(&database);
|
||||
|
||||
var rows = try listSourceRows(&database, testing.allocator);
|
||||
defer rows.deinit(testing.allocator);
|
||||
defer freeSourceRows(testing.allocator, rows.items);
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), rows.items.len);
|
||||
try testing.expectEqualStrings("https://a.example/list.txt", rows.items[0].url);
|
||||
try testing.expectEqualStrings("https://b.example/list.txt", rows.items[1].url);
|
||||
try testing.expectEqualStrings("https://c.example/list.txt", rows.items[2].url);
|
||||
try testing.expectEqualStrings("A list", rows.items[0].name);
|
||||
try testing.expect(!rows.items[0].enabled);
|
||||
try testing.expect(rows.items[1].enabled);
|
||||
|
||||
for (rows.items) |row| {
|
||||
try testing.expect(row.id > 0);
|
||||
try testing.expectEqual(@as(?i64, null), row.last_updated);
|
||||
try testing.expectEqual(@as(?[]const u8, null), row.checksum);
|
||||
try testing.expectEqual(@as(i64, 0), row.domain_count);
|
||||
try testing.expectEqual(@as(i64, 0), row.wildcard_count);
|
||||
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
|
||||
}
|
||||
}
|
||||
|
||||
test "updateSourceStats writes the runtime columns of one source only" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSources(&database);
|
||||
|
||||
var before = try listSourceRows(&database, testing.allocator);
|
||||
defer before.deinit(testing.allocator);
|
||||
defer freeSourceRows(testing.allocator, before.items);
|
||||
|
||||
const target = before.items[1];
|
||||
try updateSourceStats(&database, target.id, .{
|
||||
.last_updated = 1_700_000_000,
|
||||
.domain_count = 4321,
|
||||
.wildcard_count = 21,
|
||||
.skipped_regex_count = 7,
|
||||
.checksum = "a" ** 64,
|
||||
});
|
||||
|
||||
var after = try listSourceRows(&database, testing.allocator);
|
||||
defer after.deinit(testing.allocator);
|
||||
defer freeSourceRows(testing.allocator, after.items);
|
||||
|
||||
const updated = after.items[1];
|
||||
try testing.expectEqual(target.id, updated.id);
|
||||
try testing.expectEqualStrings("https://b.example/list.txt", updated.url);
|
||||
try testing.expectEqualStrings("B list", updated.name);
|
||||
try testing.expect(updated.enabled);
|
||||
try testing.expectEqual(@as(?i64, 1_700_000_000), updated.last_updated);
|
||||
try testing.expectEqual(@as(i64, 4321), updated.domain_count);
|
||||
try testing.expectEqual(@as(i64, 21), updated.wildcard_count);
|
||||
try testing.expectEqual(@as(i64, 7), updated.skipped_regex_count);
|
||||
try testing.expectEqualStrings("a" ** 64, updated.checksum.?);
|
||||
|
||||
// The two untouched rows kept their defaults.
|
||||
try testing.expectEqual(@as(?i64, null), after.items[0].last_updated);
|
||||
try testing.expectEqual(@as(?[]const u8, null), after.items[2].checksum);
|
||||
}
|
||||
|
||||
fn listSourceRowsUnderFailure(gpa: Allocator) !void {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seedSources(&database);
|
||||
try updateSourceStats(&database, 1, .{
|
||||
.last_updated = 1,
|
||||
.domain_count = 2,
|
||||
.wildcard_count = 3,
|
||||
.skipped_regex_count = 4,
|
||||
.checksum = "b" ** 64,
|
||||
});
|
||||
|
||||
var rows = try listSourceRows(&database, gpa);
|
||||
defer rows.deinit(gpa);
|
||||
defer freeSourceRows(gpa, rows.items);
|
||||
}
|
||||
|
||||
test "listSourceRows is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{});
|
||||
}
|
||||
|
||||
@@ -46,6 +46,23 @@ comptime {
|
||||
_ = @import("config/bootstrap.zig");
|
||||
_ = @import("cli.zig");
|
||||
_ = @import("storage/storage_integration_test.zig");
|
||||
_ = @import("filter/parsers.zig");
|
||||
_ = @import("filter/parser_hosts.zig");
|
||||
_ = @import("filter/parser_domains.zig");
|
||||
_ = @import("filter/parser_abp.zig");
|
||||
_ = @import("filter/wildcard.zig");
|
||||
_ = @import("filter/domain_set.zig");
|
||||
_ = @import("filter/rules.zig");
|
||||
_ = @import("filter/matcher.zig");
|
||||
_ = @import("filter/compiler.zig");
|
||||
_ = @import("filter/safesearch.zig");
|
||||
_ = @import("filter/response.zig");
|
||||
_ = @import("filter/fetcher.zig");
|
||||
_ = @import("filter/manager.zig");
|
||||
_ = @import("filter/filter_integration_test.zig");
|
||||
_ = @import("local/records.zig");
|
||||
_ = @import("local/forward_zones.zig");
|
||||
_ = @import("local/forward_client.zig");
|
||||
}
|
||||
|
||||
extern fn sqlite3_libversion() [*:0]const u8;
|
||||
|
||||
@@ -21,19 +21,6 @@ const tls_client = @import("../platform/tls_client.zig");
|
||||
|
||||
const log = std.log.scoped(.dot_client);
|
||||
|
||||
/// RFC 1035 §4.2.2 length prefix, shared by DNS over TCP and DNS over TLS.
|
||||
pub const prefix_len = 2;
|
||||
|
||||
pub fn framePrefix(len: u16) [prefix_len]u8 {
|
||||
var out: [prefix_len]u8 = undefined;
|
||||
std.mem.writeInt(u16, &out, len, .big);
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn parsePrefix(bytes: [prefix_len]u8) u16 {
|
||||
return std.mem.readInt(u16, &bytes, .big);
|
||||
}
|
||||
|
||||
pub const ResolveError = error{ConnectFailed};
|
||||
|
||||
/// DoT endpoints take IP literals. Name resolution for upstreams is out of
|
||||
@@ -179,7 +166,7 @@ pub const DotClient = struct {
|
||||
defer closeTls(io, &tls_stream);
|
||||
|
||||
const writer = tls_stream.writer();
|
||||
const prefix = framePrefix(@intCast(query.len));
|
||||
const prefix = transport.framePrefix(@intCast(query.len));
|
||||
writer.writeAll(&prefix) catch |err| return sendFailure(&tls_stream, err);
|
||||
writer.writeAll(query) catch |err| return sendFailure(&tls_stream, err);
|
||||
// `TlsStream.flush`, not `writer.flush`: the latter leaves the encrypted
|
||||
@@ -188,10 +175,10 @@ pub const DotClient = struct {
|
||||
tls_stream.flush() catch |err| return sendFailure(&tls_stream, err);
|
||||
|
||||
const reader = tls_stream.reader();
|
||||
var prefix_bytes: [prefix_len]u8 = undefined;
|
||||
var prefix_bytes: [transport.prefix_len]u8 = undefined;
|
||||
reader.readSliceAll(&prefix_bytes) catch |err| return receiveFailure(&tls_stream, err);
|
||||
|
||||
const len = parsePrefix(prefix_bytes);
|
||||
const len = transport.parsePrefix(prefix_bytes);
|
||||
if (len == 0) return error.BadResponse;
|
||||
if (len > response_buf.len) return error.ResponseTooLarge;
|
||||
reader.readSliceAll(response_buf[0..len]) catch |err|
|
||||
@@ -299,27 +286,6 @@ fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.Exchan
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "framePrefix writes the length big-endian" {
|
||||
try testing.expectEqualSlices(u8, &.{ 0x00, 0x00 }, &framePrefix(0));
|
||||
try testing.expectEqualSlices(u8, &.{ 0x00, 0x1d }, &framePrefix(29));
|
||||
try testing.expectEqualSlices(u8, &.{ 0x01, 0x00 }, &framePrefix(256));
|
||||
try testing.expectEqualSlices(u8, &.{ 0xff, 0xff }, &framePrefix(65535));
|
||||
}
|
||||
|
||||
test "parsePrefix reads the length big-endian" {
|
||||
try testing.expectEqual(@as(u16, 0), parsePrefix(.{ 0x00, 0x00 }));
|
||||
try testing.expectEqual(@as(u16, 29), parsePrefix(.{ 0x00, 0x1d }));
|
||||
try testing.expectEqual(@as(u16, 256), parsePrefix(.{ 0x01, 0x00 }));
|
||||
try testing.expectEqual(@as(u16, 65535), parsePrefix(.{ 0xff, 0xff }));
|
||||
}
|
||||
|
||||
test "framePrefix and parsePrefix round-trip" {
|
||||
const cases = [_]u16{ 0, 1, 12, 512, 4096, 65534, 65535 };
|
||||
for (cases) |len| {
|
||||
try testing.expectEqual(len, parsePrefix(framePrefix(len)));
|
||||
}
|
||||
}
|
||||
|
||||
test "resolveAddress accepts IP literals" {
|
||||
const v4 = try resolveAddress(try .parse("tls://1.1.1.1:853"));
|
||||
try testing.expectEqual(@as(u16, 853), v4.ip4.port);
|
||||
|
||||
@@ -20,6 +20,21 @@ const name = @import("../dns/name.zig");
|
||||
/// RFC 1035 §4.2.2: the TCP length prefix is 16-bit, so no DNS message can be
|
||||
/// larger than this on any transport nxdns speaks.
|
||||
pub const max_message_len = 65535;
|
||||
|
||||
/// RFC 1035 §4.2.2 two-byte big-endian length prefix, shared by every
|
||||
/// stream transport (DoT, plain TCP server, forward-zone TCP fallback).
|
||||
pub const prefix_len = 2;
|
||||
|
||||
pub fn framePrefix(len: u16) [prefix_len]u8 {
|
||||
var out: [prefix_len]u8 = undefined;
|
||||
std.mem.writeInt(u16, &out, len, .big);
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn parsePrefix(bytes: [prefix_len]u8) u16 {
|
||||
return std.mem.readInt(u16, &bytes, .big);
|
||||
}
|
||||
|
||||
pub const doh_default_port = 443;
|
||||
pub const dot_default_port = 853; // RFC 7858 §3.1
|
||||
pub const doh_default_path = "/dns-query"; // RFC 8484 §4.1 well-known template
|
||||
@@ -264,6 +279,30 @@ pub fn validateResponse(query: []const u8, response: []const u8) ValidateError!v
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "framePrefix writes the length big-endian" {
|
||||
try testing.expectEqualSlices(u8, &.{ 0x00, 0x00 }, &framePrefix(0));
|
||||
try testing.expectEqualSlices(u8, &.{ 0x00, 0x1d }, &framePrefix(29));
|
||||
try testing.expectEqualSlices(u8, &.{ 0x01, 0x00 }, &framePrefix(256));
|
||||
try testing.expectEqualSlices(u8, &.{ 0xff, 0xff }, &framePrefix(65535));
|
||||
}
|
||||
|
||||
test "parsePrefix reads the length big-endian" {
|
||||
try testing.expectEqual(@as(u16, 0), parsePrefix(.{ 0x00, 0x00 }));
|
||||
try testing.expectEqual(@as(u16, 29), parsePrefix(.{ 0x00, 0x1d }));
|
||||
try testing.expectEqual(@as(u16, 256), parsePrefix(.{ 0x01, 0x00 }));
|
||||
try testing.expectEqual(@as(u16, 65535), parsePrefix(.{ 0xff, 0xff }));
|
||||
}
|
||||
|
||||
test "framePrefix and parsePrefix round-trip" {
|
||||
for ([_]u16{ 0, 1, 12, 512, 4096, 65534, 65535 }) |len| {
|
||||
try testing.expectEqual(len, parsePrefix(framePrefix(len)));
|
||||
}
|
||||
}
|
||||
|
||||
test "the prefix ceiling is the message ceiling" {
|
||||
try testing.expectEqual(@as(u16, max_message_len), parsePrefix(.{ 0xff, 0xff }));
|
||||
}
|
||||
|
||||
test "parse a DoH url with an explicit path" {
|
||||
const e = try Endpoint.parse("https://cloudflare-dns.com/dns-query");
|
||||
try testing.expectEqual(Scheme.doh, e.scheme);
|
||||
|
||||
Reference in New Issue
Block a user