milestone 5: blocklist filtering, local records and conditional forwarding
This commit is contained in:
@@ -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, .{});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user