milestone 5: blocklist filtering, local records and conditional forwarding

This commit is contained in:
2026-08-01 16:43:55 +02:00
parent 3baf5d6581
commit 59d94df722
29 changed files with 10257 additions and 81 deletions
+11
View File
@@ -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
// ---------------------------------------------------------------------------
+186
View File
@@ -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, .{});
}