milestone 21: abp list exceptions and a regex rule kind

This commit is contained in:
2026-08-13 19:14:47 +02:00
parent b340521716
commit 2ab7c1f1de
51 changed files with 4016 additions and 465 deletions
+111 -4
View File
@@ -44,6 +44,7 @@ const Writer = std.Io.Writer;
const model = @import("model.zig");
const address = @import("../platform/address.zig");
const dns_name = @import("../dns/name.zig");
const regex = @import("../filter/regex.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("../upstream/transport.zig");
@@ -890,13 +891,31 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
for (cfg.rules, 0..) |rule, i| {
try checkGroupRef(diags, &group_names, rule.group, "rules[{d}].group", .{i});
if (!try patternIsValid(scratch, rule.pattern, rule.kind)) {
// `null` is a good pattern; anything else is the sentence fragment that
// says which of the regex engine's limits refused it. The empty string
// is a plain syntax refusal, which is the only verdict the exact and
// wildcard kinds can reach.
const detail: ?[]const u8 = if (patternIsValid(scratch, rule.pattern, rule.kind)) |valid|
(if (valid) null else "")
else |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.BadPattern => "",
error.PatternTooLong => std.fmt.comptimePrint(
" (over {d} bytes)",
.{regex.max_pattern_len},
),
error.PatternTooComplex => std.fmt.comptimePrint(
" (over {d} compiled instructions)",
.{regex.max_program_len},
),
};
if (detail) |suffix| {
try diags.add(
error.BadRulePattern,
"rules[{d}].pattern",
.{i},
"{f} is not a valid {s} pattern",
.{ safe_url.quoteText(rule.pattern), rule.kind.toDb() },
"{f} is not a valid {s} pattern{s}",
.{ safe_url.quoteText(rule.pattern), rule.kind.toDb(), suffix },
);
}
}
@@ -1096,11 +1115,18 @@ fn sourceUrlIsValid(url: []const u8) bool {
/// Syntax only. Matching semantics are Phase 5's: an exact pattern carries no
/// `*` at all, a wildcard pattern carries at least one label that is exactly
/// `*`, and every remaining label must survive `dns.name.fromText`.
///
/// A regex pattern is validated by compiling it, and its three refusals arrive
/// as errors rather than as `false` so the caller can name the one that fired.
/// The distinction is the operator's, not the compiler's: "not a valid regex
/// pattern" sends someone hunting for a typo in a pattern whose only fault is
/// that it is longer than `regex.max_pattern_len` or wider than
/// `regex.max_program_len`, and neither limit is visible in the pattern text.
fn patternIsValid(
scratch: Allocator,
pattern: []const u8,
kind: model.RuleKind,
) error{OutOfMemory}!bool {
) regex.Error!bool {
switch (kind) {
.exact => {
if (std.mem.findScalar(u8, pattern, '*') != null) return false;
@@ -1126,6 +1152,11 @@ fn patternIsValid(
_ = dns_name.fromText(substituted.items) catch return false;
return true;
},
.regex => {
var program = try regex.compile(scratch, pattern);
program.deinit(scratch);
return true;
},
}
}
@@ -2105,6 +2136,82 @@ test "rule patterns accept wildcards only when the kind says so" {
try expectProblem(bad_label, error.BadRulePattern, "rules[0].pattern");
}
/// The message of the first failure, so a test can assert the sentence an
/// operator reads and not only the error tag.
fn expectMessage(cfg: Config, expected: ValidateError, expected_message: []const u8) !void {
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(expected, validate(cfg, &diags));
const failure = diags.firstFailure() orelse return error.TestExpectedFailure;
try testing.expectEqualStrings(expected_message, failure.message);
}
/// The tail of the first failure's message. `quoteText` truncates the value it
/// quotes at `safe_url.max_len`, so a diagnostic about an over-long pattern
/// cannot be matched whole.
fn expectMessageSuffix(cfg: Config, expected: ValidateError, expected_suffix: []const u8) !void {
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(expected, validate(cfg, &diags));
const failure = diags.firstFailure() orelse return error.TestExpectedFailure;
if (!std.mem.endsWith(u8, failure.message, expected_suffix)) {
std.debug.print("message {s} does not end with {s}\n", .{ failure.message, expected_suffix });
return error.TestExpectedEqual;
}
}
fn regexRule(pattern: []const u8) [1]model.Rule {
return .{.{ .group = "default", .pattern = pattern, .kind = .regex, .action = .block }};
}
test "a regex rule is validated by compiling it" {
var cfg = baseConfig();
const good = regexRule("^ad[0-9]+-\\.(example|test)\\.com$");
cfg.rules = &good;
try expectClean(cfg);
// A regex is not a name: the wildcard and exact kinds reject `*`, and this
// one has to accept the characters that make a pattern a pattern.
const starred = regexRule("ads.*\\.example");
cfg.rules = &starred;
try expectClean(cfg);
}
test "each of the regex engine's three refusals names itself in the diagnostic" {
var cfg = baseConfig();
const unclosed = regexRule("(");
cfg.rules = &unclosed;
try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern");
try expectMessage(cfg, error.BadRulePattern, "'(' is not a valid regex pattern");
// Too long and too complex are the two an operator cannot see by reading
// the pattern, so the message has to carry the limit that fired.
const too_long = regexRule("a" ** (regex.max_pattern_len + 1));
cfg.rules = &too_long;
try expectMessageSuffix(
cfg,
error.BadRulePattern,
"is not a valid regex pattern (over 256 bytes)",
);
// Well inside 256 bytes of pattern, well past 1024 instructions of program.
const too_complex = regexRule("(abcdefghij){200}");
cfg.rules = &too_complex;
try expectMessage(
cfg,
error.BadRulePattern,
"'(abcdefghij){200}' is not a valid regex pattern (over 1024 compiled instructions)",
);
}
test "an empty regex pattern is refused rather than matching every name" {
var cfg = baseConfig();
const empty = regexRule("");
cfg.rules = ∅
try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern");
}
test "parseResolver accepts udp and tcp with an IP literal and a port" {
const udp4 = try parseResolver("udp://192.168.1.1:53");
try testing.expectEqual(ResolverScheme.udp, udp4.scheme);