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
+5 -1
View File
@@ -59,6 +59,7 @@ pub const StatusView = struct {
last_error: []const u8,
domains: u32,
wildcards: u32,
exceptions: u32,
skipped_regex: u32,
pub fn from(status: *const manager_mod.SourceStatus) StatusView {
@@ -72,6 +73,7 @@ pub const StatusView = struct {
.last_error = status.errorText(),
.domains = status.counts.domains,
.wildcards = status.counts.wildcards,
.exceptions = status.counts.exceptions,
.skipped_regex = status.counts.skipped_regex,
};
}
@@ -317,6 +319,7 @@ test "editing a blocklist keeps the counters the refresh wrote" {
.last_updated = 1700,
.domain_count = 42,
.wildcard_count = 3,
.exception_count = 2,
.skipped_regex_count = 1,
.checksum = "abc",
});
@@ -380,7 +383,7 @@ test "a status becomes the flat shape the API answers with" {
const message = "connection refused";
@memcpy(status.last_error[0..message.len], message);
status.last_error_len = message.len;
status.counts = .{ .domains = 10, .wildcards = 2, .skipped_regex = 1 };
status.counts = .{ .domains = 10, .wildcards = 2, .exceptions = 4, .skipped_regex = 1 };
const view: StatusView = .from(&status);
try testing.expectEqual(@as(i64, 7), view.id);
@@ -389,4 +392,5 @@ test "a status becomes the flat shape the API answers with" {
try testing.expectEqualStrings(url, view.url);
try testing.expectEqualStrings(message, view.last_error);
try testing.expectEqual(@as(u32, 10), view.domains);
try testing.expectEqual(@as(u32, 4), view.exceptions);
}
+4 -1
View File
@@ -38,6 +38,8 @@ pub const Body = struct {
reason: []const u8,
/// The rule or list entry that decided it; "" when nothing matched.
matched: []const u8,
/// The list that decided it, which for `blocklist_exception` is the list
/// whose `@@` rule lifted the block rather than one that made it.
source_url: ?[]const u8,
safe_search_rewrite: ?[]const u8,
};
@@ -50,7 +52,8 @@ pub const Result = struct {
blocked: bool,
reason: matcher.Reason,
matched: []const u8,
/// `blocklist_sources` row id of the list that matched.
/// `blocklist_sources` row id of the list that matched, whether it blocked
/// the name or lifted it through an `@@` exception.
source_id: ?i64,
safe_search_rewrite: ?[]const u8,
};
+44 -2
View File
@@ -39,7 +39,7 @@ const Created = union(enum) { id: i64, fail: Failure };
/// not understood.
fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } {
const kind = model.RuleKind.fromDb(body.kind) orelse
return .{ .fail = .{ .invalid = "kind must be 'exact' or 'wildcard'" } };
return .{ .fail = .{ .invalid = "kind must be 'exact', 'wildcard' or 'regex'" } };
const action = model.RuleAction.fromDb(body.action) orelse
return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } };
return .{ .input = .{
@@ -308,7 +308,7 @@ test "an unknown kind or action is a 400 before anything is written" {
try testing.expect(toInput(.{
.group_id = 1,
.pattern = "ads.example",
.kind = "regex",
.kind = "glob",
.action = "block",
}).fail == .invalid);
@@ -327,4 +327,46 @@ test "an unknown kind or action is a 400 before anything is written" {
});
try testing.expectEqual(model.RuleKind.wildcard, good.input.kind);
try testing.expectEqual(model.RuleAction.allow, good.input.action);
const third = toInput(.{
.group_id = 1,
.pattern = "^ad[0-9]+-",
.kind = "regex",
.action = "block",
});
try testing.expectEqual(model.RuleKind.regex, third.input.kind);
}
test "a regex rule is stored, and a pattern the engine refuses is a 400 that names it" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.group_id = 1,
.pattern = "^ad[0-9]+-",
.kind = .regex,
.action = .block,
});
const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
try testing.expectEqual(model.RuleKind.regex, row.kind);
// Stored verbatim: a regex is not a name, so nothing lowercases or
// dot-strips it on the way to the table.
try testing.expectEqualStrings("^ad[0-9]+-", row.pattern);
const unclosed = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.group_id = 1,
.pattern = "(",
.kind = .regex,
.action = .block,
});
try testing.expectEqualStrings(
"rules[0].pattern: '(' is not a valid regex pattern",
unclosed.fail.invalid,
);
// The refused pattern reached no table, and the good one is still the only
// row: a 400 costs no write and no reload.
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM rules"));
try testing.expectEqual(@as(usize, 1), bench.reloads);
}
+7 -5
View File
@@ -1876,7 +1876,7 @@ components:
Blocklist:
type: object
required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, skipped_regex_count, checksum]
required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, checksum]
properties:
id: { type: integer }
url: { type: string }
@@ -1888,6 +1888,7 @@ components:
nullable: true
domain_count: { type: integer }
wildcard_count: { type: integer }
exception_count: { type: integer }
skipped_regex_count: { type: integer }
checksum:
type: string
@@ -1918,7 +1919,7 @@ components:
SourceStatus:
type: object
required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, skipped_regex]
required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex]
properties:
id: { type: integer }
state:
@@ -1933,6 +1934,7 @@ components:
description: Empty when the last attempt succeeded.
domains: { type: integer }
wildcards: { type: integer }
exceptions: { type: integer }
skipped_regex: { type: integer }
Rule:
@@ -1945,7 +1947,7 @@ components:
pattern: { type: string }
kind:
type: string
enum: [exact, wildcard]
enum: [exact, wildcard, regex]
action:
type: string
enum: [allow, block]
@@ -1959,7 +1961,7 @@ components:
pattern: { type: string }
kind:
type: string
enum: [exact, wildcard]
enum: [exact, wildcard, regex]
action:
type: string
enum: [allow, block]
@@ -1973,7 +1975,7 @@ components:
pattern: { type: string }
kind:
type: string
enum: [exact, wildcard]
enum: [exact, wildcard, regex]
action:
type: string
enum: [allow, block]
+12
View File
@@ -1665,6 +1665,9 @@ fn createdId(body: []const u8) !i64 {
return std.fmt.parseInt(i64, rest[0..end], 10);
}
/// The three files a refresh publishes for one source. The `.allow` file is
/// written here too: the delete path has to take every compiled body, and a
/// sweep that missed one would leave an orphan this test could not see.
fn writeCompiled(io: std.Io, dir: std.Io.Dir, id: i64, body: []const u8) !void {
var buf: [64]u8 = undefined;
try dir.writeFile(io, .{
@@ -1675,6 +1678,10 @@ fn writeCompiled(io: std.Io, dir: std.Io.Dir, id: i64, body: []const u8) !void {
.sub_path = try std.fmt.bufPrint(&buf, "{d}.wild", .{id}),
.data = "",
});
try dir.writeFile(io, .{
.sub_path = try std.fmt.bufPrint(&buf, "{d}.allow", .{id}),
.data = "",
});
}
fn accessCompiled(io: std.Io, dir: std.Io.Dir, id: i64) !void {
@@ -1726,6 +1733,11 @@ fn deleteSweepsCompiledFiles(io: std.Io, env: *Env) anyerror!void {
try std.fmt.bufPrint(&name_buf, "{d}.wild", .{doomed}),
.{},
));
try testing.expectError(error.FileNotFound, dir.access(
io,
try std.fmt.bufPrint(&name_buf, "{d}.allow", .{doomed}),
.{},
));
try accessCompiled(io, dir, kept);
}