//! `blocklist_sources`. //! //! Only the four configuration columns are read and written. `last_updated`, //! `domain_count`, `wildcard_count`, `skipped_regex_count` and `checksum` are //! facts a running server produces; an insert leaves them at their column //! defaults so two exports taken minutes apart stay identical. //! //! The import path is list / insert / deleteAll / count; the runtime columns and //! Phase 8's REST surface follow it, both keyed by row id. const std = @import("std"); const Allocator = std.mem.Allocator; const db = @import("../db.zig"); const migrations = @import("../migrations.zig"); const model = @import("../../config/model.zig"); const context = @import("context.zig"); const crud = @import("crud.zig"); const InsertContext = context.InsertContext; const list_sql = \\SELECT url, name, enabled, is_suggested FROM blocklist_sources ORDER BY url ; /// Every string in the result is a heap copy owned by `gpa`. pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) { return crud.listRows(model.BlocklistSource, database, gpa, list_sql, readBlocklistSource); } fn readBlocklistSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.BlocklistSource { const url = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(url); const name = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(name); return .{ .url = url, .name = name, .enabled = stmt.columnBool(2), .is_suggested = stmt.columnBool(3), }; } pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void { crud.freeRows(model.BlocklistSource, gpa, items); } const insert_sql = \\INSERT INTO blocklist_sources (url, name, enabled, is_suggested) VALUES (?1, ?2, ?3, ?4) ; pub fn insertBlocklistSource(database: *db.Db, item: model.BlocklistSource, ctx: InsertContext) db.Error!void { _ = ctx; var stmt = try database.prepare(insert_sql); defer stmt.deinit(); try stmt.bindText(1, item.url); try stmt.bindText(2, item.name); try stmt.bindBool(3, item.enabled); try stmt.bindBool(4, item.is_suggested); try stmt.exec(); } pub fn deleteAllBlocklistSources(database: *db.Db) db.Error!void { return database.exec("DELETE FROM blocklist_sources;"); } 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, /// Defaulted because the blocklist manager builds `SourceRow` values from /// the refresh columns alone; the REST layer is what reads this one. is_suggested: bool = false, 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 row_columns_sql = \\SELECT id, url, name, enabled, last_updated, \\ domain_count, wildcard_count, skipped_regex_count, checksum, \\ is_suggested \\ FROM blocklist_sources ; const list_rows_sql = row_columns_sql ++ " 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) { return crud.listRows(SourceRow, database, gpa, list_rows_sql, readSourceRow); } fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow { 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); return .{ .id = stmt.columnInt(0), .url = url, .name = name, .enabled = stmt.columnBool(3), .is_suggested = stmt.columnBool(9), .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, }; } /// Frees `url`, `name` and the `checksum` payload when it is not null. pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void { crud.freeRow(SourceRow, gpa, row); } pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void { crud.freeRows(SourceRow, gpa, items); } 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(); } // --------------------------------------------------------------------------- // REST surface (milestone 8) // --------------------------------------------------------------------------- // // `/api/blocklists` is this table. The read shape is `SourceRow` above — the // UI wants the counters next to the configuration — and the write shape is // `model.BlocklistSource`, whose four fields are the four columns an operator // may set. pub fn getSource(database: *db.Db, gpa: Allocator, id: i64) db.Error!?SourceRow { var stmt = try database.prepare(row_columns_sql ++ " WHERE id = ?1"); defer stmt.deinit(); try stmt.bindInt(1, id); if (!try stmt.step()) return null; return try readSourceRow(&stmt, gpa); } /// The runtime columns stay at their defaults, so a source added through the /// API looks exactly like an imported one until the first refresh. /// /// `error.Constraint`: `blocklist_sources.url` is UNIQUE. pub fn insertSourceRow(database: *db.Db, item: model.BlocklistSource) db.Error!i64 { var stmt = try database.prepare(insert_sql); defer stmt.deinit(); try stmt.bindText(1, item.url); try stmt.bindText(2, item.name); try stmt.bindBool(3, item.enabled); try stmt.bindBool(4, item.is_suggested); try stmt.exec(); return database.lastInsertRowid(); } const update_source_sql = \\UPDATE blocklist_sources \\ SET url = ?2, name = ?3, enabled = ?4, is_suggested = ?5 \\ WHERE id = ?1 ; /// Writes the four configuration columns. The runtime columns are the refresh /// path's and stay as they are — even when `url` changes, because the next /// refresh compares checksums and replaces them anyway. /// /// `error.NotFound`: no source holds `id`. `error.Constraint`: /// `blocklist_sources.url` is UNIQUE. pub fn updateSource(database: *db.Db, id: i64, item: model.BlocklistSource) db.Error!void { var stmt = try database.prepare(update_source_sql); defer stmt.deinit(); try stmt.bindInt(1, id); try stmt.bindText(2, item.url); try stmt.bindText(3, item.name); try stmt.bindBool(4, item.enabled); try stmt.bindBool(5, item.is_suggested); return crud.execStrict(database, &stmt); } /// `error.NotFound`: no source holds `id`. `group_sources` references it /// `ON DELETE CASCADE`, so every group's assignment loses it silently and no /// constraint can fire. pub fn deleteSource(database: *db.Db, id: i64) db.Error!void { var stmt = try database.prepare("DELETE FROM blocklist_sources WHERE id = ?1"); defer stmt.deinit(); try stmt.bindInt(1, id); return crud.execStrict(database, &stmt); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; fn openMigrated() !db.Db { var database = try db.Db.open(":memory:", .{ .mode = .memory }); errdefer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); return database; } fn seedSources(database: *db.Db) !void { const ctx: InsertContext = .{}; try insertBlocklistSource(database, .{ .url = "https://c.example/list.txt", .name = "C list", }, ctx); try insertBlocklistSource(database, .{ .url = "https://a.example/list.txt", .name = "A list", .enabled = false, }, ctx); try insertBlocklistSource(database, .{ .url = "https://b.example/list.txt", .name = "B list", .is_suggested = true, }, ctx); } test "blocklist_sources round-trip in url order" { var database = try openMigrated(); defer database.close(); try seedSources(&database); var items = try listBlocklistSources(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeBlocklistSources(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].url); try testing.expectEqualStrings("A list", items.items[0].name); try testing.expect(!items.items[0].enabled); try testing.expect(!items.items[0].is_suggested); try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].url); try testing.expectEqualStrings("B list", items.items[1].name); try testing.expect(items.items[1].enabled); try testing.expect(items.items[1].is_suggested); try testing.expectEqualStrings("https://c.example/list.txt", items.items[2].url); try testing.expectEqualStrings("C list", items.items[2].name); try testing.expect(items.items[2].enabled); try testing.expect(!items.items[2].is_suggested); } test "insertBlocklistSource leaves the runtime columns at their defaults" { var database = try openMigrated(); defer database.close(); try seedSources(&database); try testing.expectEqual( @as(i64, 3), try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE last_updated IS NULL"), ); try testing.expectEqual( @as(i64, 3), try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE checksum IS NULL"), ); try testing.expectEqual( @as(i64, 0), try database.queryInt("SELECT sum(domain_count + wildcard_count + skipped_regex_count) FROM blocklist_sources"), ); } test "deleteAllBlocklistSources empties the table and countBlocklistSources reflects it" { var database = try openMigrated(); defer database.close(); try seedSources(&database); try testing.expectEqual(@as(i64, 3), try countBlocklistSources(&database)); try deleteAllBlocklistSources(&database); try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database)); } fn listBlocklistSourcesUnderFailure(gpa: Allocator) !void { var database = try openMigrated(); defer database.close(); try seedSources(&database); var items = try listBlocklistSources(&database, gpa); defer items.deinit(gpa); defer freeBlocklistSources(gpa, items.items); } 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); // `checksum` is the one allocated optional in this directory. Without a // non-null one in the result the injection never reaches its allocation and // this test stops covering the shape it exists for. Row id 1 sorts last: // `seedSources` inserts `c.example` first and the list orders by url. try testing.expect(rows.items[2].checksum != null); } test "listSourceRows is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{}); } // --- REST surface ---------------------------------------------------------- test "a blocklist source round-trips through insert, get, list, update and delete" { var database = try openMigrated(); defer database.close(); const id = try insertSourceRow(&database, .{ .url = "https://lists.example/hosts.txt", .name = "Example", .is_suggested = true, }); const fetched = (try getSource(&database, testing.allocator, id)).?; defer freeSourceRow(testing.allocator, fetched); try testing.expectEqual(id, fetched.id); try testing.expectEqualStrings("https://lists.example/hosts.txt", fetched.url); try testing.expectEqualStrings("Example", fetched.name); try testing.expect(fetched.enabled); try testing.expect(fetched.is_suggested); try testing.expectEqual(@as(?i64, null), fetched.last_updated); try updateSource(&database, id, .{ .url = "https://lists.example/hosts.txt", .name = "Example list", .enabled = false, }); const updated = (try getSource(&database, testing.allocator, id)).?; defer freeSourceRow(testing.allocator, updated); try testing.expectEqualStrings("Example list", updated.name); try testing.expect(!updated.enabled); try testing.expect(!updated.is_suggested); try deleteSource(&database, id); try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, id)); try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database)); } test "updateSource leaves the runtime columns where the refresh path left them" { var database = try openMigrated(); defer database.close(); const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" }); try updateSourceStats(&database, id, .{ .last_updated = 1_700_000_000, .domain_count = 12, .wildcard_count = 3, .skipped_regex_count = 1, .checksum = "c" ** 64, }); try updateSource(&database, id, .{ .url = "https://lists.example/b.txt", .name = "B" }); const row = (try getSource(&database, testing.allocator, id)).?; defer freeSourceRow(testing.allocator, row); try testing.expectEqualStrings("https://lists.example/b.txt", row.url); try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated); try testing.expectEqual(@as(i64, 12), row.domain_count); try testing.expectEqualStrings("c" ** 64, row.checksum.?); } test "source update and delete report NotFound for an id no row holds" { var database = try openMigrated(); defer database.close(); try testing.expectError( error.NotFound, updateSource(&database, 404, .{ .url = "https://lists.example/a.txt", .name = "A" }), ); try testing.expectError(error.NotFound, deleteSource(&database, 404)); try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, 404)); } test "a duplicate source url surfaces as error.Constraint on insert and on update" { var database = try openMigrated(); defer database.close(); _ = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" }); const other = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" }); try testing.expectError( error.Constraint, insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "again" }), ); try testing.expectError( error.Constraint, updateSource(&database, other, .{ .url = "https://lists.example/a.txt", .name = "B" }), ); try testing.expectEqual(@as(i64, 2), try countBlocklistSources(&database)); } test "deleting a source drops it from every group assignment" { var database = try openMigrated(); defer database.close(); const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" }); const keep = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" }); try database.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1), (1, 2);"); try deleteSource(&database, id); try testing.expectEqual(@as(i64, 1), try countGroupSources(&database)); try testing.expectEqual( keep, try database.queryInt("SELECT source_id FROM group_sources"), ); } fn countGroupSources(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM group_sources"); } fn sourceRowUnderFailure(gpa: Allocator) !void { var database = try openMigrated(); defer database.close(); try seedSources(&database); const one = (try getSource(&database, gpa, 1)).?; defer freeSourceRow(gpa, one); } test "getSource is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, sourceRowUnderFailure, .{}); }