//! `local_records` and `forward_zones`. //! //! The import path is list / insert / deleteAll / count. The REST surface //! follows each table's section: it speaks row ids, because that is what an //! `/api/local-records/{id}` or `/api/forward-zones/{id}` request names. 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; // --------------------------------------------------------------------------- // local_records // --------------------------------------------------------------------------- const list_local_records_sql = \\SELECT name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value ; /// Every string in the result is a heap copy owned by `gpa`. pub fn listLocalRecords(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.LocalRecord) { return crud.listRows(model.LocalRecord, database, gpa, list_local_records_sql, readLocalRecord); } fn readLocalRecord(stmt: *db.Stmt, gpa: Allocator) db.Error!model.LocalRecord { // The DDL's CHECK constraint makes the decode total for any row nxdns // wrote; `error.Mismatch` covers a row that something else wrote. const rtype = model.RecordType.fromDb(stmt.columnText(1)) orelse return error.Mismatch; const ttl = std.math.cast(u32, stmt.columnInt(3)) orelse return error.Mismatch; const name = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(name); const value = try stmt.columnTextAlloc(gpa, 2); errdefer gpa.free(value); return .{ .name = name, .rtype = rtype, .value = value, .ttl = ttl }; } pub fn freeLocalRecords(gpa: Allocator, items: []const model.LocalRecord) void { crud.freeRows(model.LocalRecord, gpa, items); } const insert_local_record_sql = \\INSERT INTO local_records (name, rtype, value, ttl) VALUES (?1, ?2, ?3, ?4) ; pub fn insertLocalRecord(database: *db.Db, item: model.LocalRecord, ctx: InsertContext) db.Error!void { _ = ctx; var stmt = try database.prepare(insert_local_record_sql); defer stmt.deinit(); try stmt.bindText(1, item.name); try stmt.bindText(2, item.rtype.toDb()); try stmt.bindText(3, item.value); try stmt.bindInt(4, item.ttl); try stmt.exec(); } pub fn deleteAllLocalRecords(database: *db.Db) db.Error!void { return database.exec("DELETE FROM local_records;"); } pub fn countLocalRecords(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM local_records"); } // --------------------------------------------------------------------------- // forward_zones // --------------------------------------------------------------------------- pub fn listForwardZones(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ForwardZone) { return crud.listRows( model.ForwardZone, database, gpa, "SELECT zone, resolver FROM forward_zones ORDER BY zone", readForwardZone, ); } fn readForwardZone(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ForwardZone { const zone = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(zone); const resolver = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(resolver); return .{ .zone = zone, .resolver = resolver }; } pub fn freeForwardZones(gpa: Allocator, items: []const model.ForwardZone) void { crud.freeRows(model.ForwardZone, gpa, items); } pub fn insertForwardZone(database: *db.Db, item: model.ForwardZone, ctx: InsertContext) db.Error!void { _ = ctx; var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)"); defer stmt.deinit(); try stmt.bindText(1, item.zone); try stmt.bindText(2, item.resolver); try stmt.exec(); } pub fn deleteAllForwardZones(database: *db.Db) db.Error!void { return database.exec("DELETE FROM forward_zones;"); } pub fn countForwardZones(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM forward_zones"); } // --------------------------------------------------------------------------- // REST surface (milestone 8) // --------------------------------------------------------------------------- // // Neither table references another, so the write shapes are `model.LocalRecord` // and `model.ForwardZone` unchanged: their fields are exactly the columns. pub const LocalRecordRow = struct { id: i64, name: []const u8, rtype: model.RecordType, value: []const u8, ttl: u32, }; const list_local_record_rows_sql = \\SELECT id, name, rtype, value, ttl FROM local_records ORDER BY name, rtype, value ; /// Same order as `listLocalRecords`; every string is a heap copy owned by `gpa`. pub fn listLocalRecordRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(LocalRecordRow) { return crud.listRows(LocalRecordRow, database, gpa, list_local_record_rows_sql, readLocalRecordRow); } pub fn freeLocalRecordRow(gpa: Allocator, row: LocalRecordRow) void { crud.freeRow(LocalRecordRow, gpa, row); } pub fn freeLocalRecordRows(gpa: Allocator, items: []const LocalRecordRow) void { crud.freeRows(LocalRecordRow, gpa, items); } pub fn getLocalRecord(database: *db.Db, gpa: Allocator, id: i64) db.Error!?LocalRecordRow { var stmt = try database.prepare("SELECT id, name, rtype, value, ttl FROM local_records WHERE id = ?1"); defer stmt.deinit(); try stmt.bindInt(1, id); if (!try stmt.step()) return null; return try readLocalRecordRow(&stmt, gpa); } fn readLocalRecordRow(stmt: *db.Stmt, gpa: Allocator) db.Error!LocalRecordRow { // The DDL's CHECK constraint makes the decode total for any row nxdns // wrote; `error.Mismatch` covers a row that something else wrote, and the // same goes for a `ttl` outside `u32`. const rtype = model.RecordType.fromDb(stmt.columnText(2)) orelse return error.Mismatch; const ttl = std.math.cast(u32, stmt.columnInt(4)) orelse return error.Mismatch; const name = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(name); const value = try stmt.columnTextAlloc(gpa, 3); errdefer gpa.free(value); return .{ .id = stmt.columnInt(0), .name = name, .rtype = rtype, .value = value, .ttl = ttl }; } /// `error.Constraint`: `local_records` declares `UNIQUE(name, rtype, value)`, so /// the same answer cannot be stored twice — a second TTL for one record would be /// two truths. pub fn insertLocalRecordRow(database: *db.Db, item: model.LocalRecord) db.Error!i64 { var stmt = try database.prepare(insert_local_record_sql); defer stmt.deinit(); try stmt.bindText(1, item.name); try stmt.bindText(2, item.rtype.toDb()); try stmt.bindText(3, item.value); try stmt.bindInt(4, item.ttl); try stmt.exec(); return database.lastInsertRowid(); } /// `error.NotFound`: no record holds `id`. `error.Constraint`: /// `UNIQUE(name, rtype, value)`. pub fn updateLocalRecord(database: *db.Db, id: i64, item: model.LocalRecord) db.Error!void { var stmt = try database.prepare( "UPDATE local_records SET name = ?2, rtype = ?3, value = ?4, ttl = ?5 WHERE id = ?1", ); defer stmt.deinit(); try stmt.bindInt(1, id); try stmt.bindText(2, item.name); try stmt.bindText(3, item.rtype.toDb()); try stmt.bindText(4, item.value); try stmt.bindInt(5, item.ttl); return crud.execStrict(database, &stmt); } /// `error.NotFound`: no record holds `id`. Nothing references `local_records`, /// so a delete cannot violate a constraint. pub fn deleteLocalRecord(database: *db.Db, id: i64) db.Error!void { var stmt = try database.prepare("DELETE FROM local_records WHERE id = ?1"); defer stmt.deinit(); try stmt.bindInt(1, id); return crud.execStrict(database, &stmt); } pub const ForwardZoneRow = struct { id: i64, zone: []const u8, resolver: []const u8 }; /// Same order as `listForwardZones`; every string is a heap copy owned by `gpa`. pub fn listForwardZoneRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ForwardZoneRow) { return crud.listRows( ForwardZoneRow, database, gpa, "SELECT id, zone, resolver FROM forward_zones ORDER BY zone", readForwardZoneRow, ); } pub fn freeForwardZoneRow(gpa: Allocator, row: ForwardZoneRow) void { crud.freeRow(ForwardZoneRow, gpa, row); } pub fn freeForwardZoneRows(gpa: Allocator, items: []const ForwardZoneRow) void { crud.freeRows(ForwardZoneRow, gpa, items); } pub fn getForwardZone(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ForwardZoneRow { var stmt = try database.prepare("SELECT id, zone, resolver FROM forward_zones WHERE id = ?1"); defer stmt.deinit(); try stmt.bindInt(1, id); if (!try stmt.step()) return null; return try readForwardZoneRow(&stmt, gpa); } fn readForwardZoneRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ForwardZoneRow { const zone = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(zone); const resolver = try stmt.columnTextAlloc(gpa, 2); errdefer gpa.free(resolver); return .{ .id = stmt.columnInt(0), .zone = zone, .resolver = resolver }; } /// `error.Constraint`: `forward_zones.zone` is UNIQUE — one zone has one /// resolver. pub fn insertForwardZoneRow(database: *db.Db, item: model.ForwardZone) db.Error!i64 { var stmt = try database.prepare("INSERT INTO forward_zones (zone, resolver) VALUES (?1, ?2)"); defer stmt.deinit(); try stmt.bindText(1, item.zone); try stmt.bindText(2, item.resolver); try stmt.exec(); return database.lastInsertRowid(); } /// `error.NotFound`: no zone holds `id`. `error.Constraint`: /// `forward_zones.zone` is UNIQUE. pub fn updateForwardZone(database: *db.Db, id: i64, item: model.ForwardZone) db.Error!void { var stmt = try database.prepare("UPDATE forward_zones SET zone = ?2, resolver = ?3 WHERE id = ?1"); defer stmt.deinit(); try stmt.bindInt(1, id); try stmt.bindText(2, item.zone); try stmt.bindText(3, item.resolver); return crud.execStrict(database, &stmt); } /// `error.NotFound`: no zone holds `id`. Nothing references `forward_zones`, so /// a delete cannot violate a constraint. pub fn deleteForwardZone(database: *db.Db, id: i64) db.Error!void { var stmt = try database.prepare("DELETE FROM forward_zones 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 seedLocalRecords(database: *db.Db) !void { const ctx: InsertContext = .{}; try insertLocalRecord(database, .{ .name = "nas.home.arpa", .rtype = .aaaa, .value = "fd00::5", .ttl = 60, }, ctx); try insertLocalRecord(database, .{ .name = "nas.home.arpa", .rtype = .a, .value = "192.168.1.5", }, ctx); try insertLocalRecord(database, .{ .name = "alias.home.arpa", .rtype = .cname, .value = "nas.home.arpa", .ttl = 120, }, ctx); } fn seedForwardZones(database: *db.Db) !void { const ctx: InsertContext = .{}; try insertForwardZone(database, .{ .zone = "work.example", .resolver = "udp://10.0.0.1:53" }, ctx); try insertForwardZone(database, .{ .zone = "home.arpa", .resolver = "udp://192.168.1.1:53" }, ctx); try insertForwardZone(database, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" }, ctx); } test "local_records round-trip in name, rtype, value order" { var database = try openMigrated(); defer database.close(); try seedLocalRecords(&database); var items = try listLocalRecords(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeLocalRecords(testing.allocator, items.items); // `rtype` is compared as stored text, so 'A' sorts before 'AAAA'. try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("alias.home.arpa", items.items[0].name); try testing.expectEqual(model.RecordType.cname, items.items[0].rtype); try testing.expectEqualStrings("nas.home.arpa", items.items[0].value); try testing.expectEqual(@as(u32, 120), items.items[0].ttl); try testing.expectEqualStrings("nas.home.arpa", items.items[1].name); try testing.expectEqual(model.RecordType.a, items.items[1].rtype); try testing.expectEqualStrings("192.168.1.5", items.items[1].value); try testing.expectEqual(@as(u32, 300), items.items[1].ttl); try testing.expectEqualStrings("nas.home.arpa", items.items[2].name); try testing.expectEqual(model.RecordType.aaaa, items.items[2].rtype); try testing.expectEqualStrings("fd00::5", items.items[2].value); try testing.expectEqual(@as(u32, 60), items.items[2].ttl); } test "deleteAllLocalRecords empties the table and countLocalRecords reflects it" { var database = try openMigrated(); defer database.close(); try seedLocalRecords(&database); try testing.expectEqual(@as(i64, 3), try countLocalRecords(&database)); try deleteAllLocalRecords(&database); try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database)); } fn listLocalRecordsUnderFailure(gpa: Allocator) !void { var database = try openMigrated(); defer database.close(); try seedLocalRecords(&database); var items = try listLocalRecords(&database, gpa); defer items.deinit(gpa); defer freeLocalRecords(gpa, items.items); } test "listLocalRecords is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, listLocalRecordsUnderFailure, .{}); } test "forward_zones round-trip in zone order" { var database = try openMigrated(); defer database.close(); try seedForwardZones(&database); var items = try listForwardZones(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeForwardZones(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("home.arpa", items.items[0].zone); try testing.expectEqualStrings("udp://192.168.1.1:53", items.items[0].resolver); try testing.expectEqualStrings("lab.example", items.items[1].zone); try testing.expectEqualStrings("tcp://[fd00::1]:53", items.items[1].resolver); try testing.expectEqualStrings("work.example", items.items[2].zone); try testing.expectEqualStrings("udp://10.0.0.1:53", items.items[2].resolver); } test "deleteAllForwardZones empties the table and countForwardZones reflects it" { var database = try openMigrated(); defer database.close(); try seedForwardZones(&database); try testing.expectEqual(@as(i64, 3), try countForwardZones(&database)); try deleteAllForwardZones(&database); try testing.expectEqual(@as(i64, 0), try countForwardZones(&database)); } fn listForwardZonesUnderFailure(gpa: Allocator) !void { var database = try openMigrated(); defer database.close(); try seedForwardZones(&database); var items = try listForwardZones(&database, gpa); defer items.deinit(gpa); defer freeForwardZones(gpa, items.items); } test "listForwardZones is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, listForwardZonesUnderFailure, .{}); } // --- REST surface ---------------------------------------------------------- test "a local record round-trips through insert, get, list, update and delete" { var database = try openMigrated(); defer database.close(); const id = try insertLocalRecordRow(&database, .{ .name = "nas.home.arpa", .rtype = .a, .value = "192.168.1.5", }); const fetched = (try getLocalRecord(&database, testing.allocator, id)).?; defer freeLocalRecordRow(testing.allocator, fetched); try testing.expectEqual(id, fetched.id); try testing.expectEqualStrings("nas.home.arpa", fetched.name); try testing.expectEqual(model.RecordType.a, fetched.rtype); try testing.expectEqualStrings("192.168.1.5", fetched.value); try testing.expectEqual(@as(u32, 300), fetched.ttl); try updateLocalRecord(&database, id, .{ .name = "nas.home.arpa", .rtype = .a, .value = "192.168.1.6", .ttl = 60, }); const updated = (try getLocalRecord(&database, testing.allocator, id)).?; defer freeLocalRecordRow(testing.allocator, updated); try testing.expectEqualStrings("192.168.1.6", updated.value); try testing.expectEqual(@as(u32, 60), updated.ttl); var rows = try listLocalRecordRows(&database, testing.allocator); defer rows.deinit(testing.allocator); defer freeLocalRecordRows(testing.allocator, rows.items); try testing.expectEqual(@as(usize, 1), rows.items.len); try testing.expectEqual(id, rows.items[0].id); try deleteLocalRecord(&database, id); try testing.expectEqual(@as(?LocalRecordRow, null), try getLocalRecord(&database, testing.allocator, id)); try testing.expectEqual(@as(i64, 0), try countLocalRecords(&database)); } test "local record update and delete report NotFound for an id no row holds" { var database = try openMigrated(); defer database.close(); const item: model.LocalRecord = .{ .name = "x.home.arpa", .rtype = .a, .value = "10.0.0.1" }; try testing.expectError(error.NotFound, updateLocalRecord(&database, 404, item)); try testing.expectError(error.NotFound, deleteLocalRecord(&database, 404)); try testing.expectEqual(@as(?LocalRecordRow, null), try getLocalRecord(&database, testing.allocator, 404)); } test "a duplicate name, rtype and value surfaces as error.Constraint" { var database = try openMigrated(); defer database.close(); const first: model.LocalRecord = .{ .name = "nas.home.arpa", .rtype = .a, .value = "192.168.1.5" }; _ = try insertLocalRecordRow(&database, first); // The TTL is not part of the UNIQUE key, so a second TTL is still a clash. try testing.expectError(error.Constraint, insertLocalRecordRow(&database, .{ .name = "nas.home.arpa", .rtype = .a, .value = "192.168.1.5", .ttl = 60, })); const other = try insertLocalRecordRow(&database, .{ .name = "nas.home.arpa", .rtype = .aaaa, .value = "fd00::5", }); try testing.expectError(error.Constraint, updateLocalRecord(&database, other, first)); try testing.expectEqual(@as(i64, 2), try countLocalRecords(&database)); } test "a forward zone round-trips through insert, get, list, update and delete" { var database = try openMigrated(); defer database.close(); const id = try insertForwardZoneRow(&database, .{ .zone = "home.arpa", .resolver = "udp://192.168.1.1:53", }); const fetched = (try getForwardZone(&database, testing.allocator, id)).?; defer freeForwardZoneRow(testing.allocator, fetched); try testing.expectEqual(id, fetched.id); try testing.expectEqualStrings("home.arpa", fetched.zone); try testing.expectEqualStrings("udp://192.168.1.1:53", fetched.resolver); try updateForwardZone(&database, id, .{ .zone = "lab.example", .resolver = "tcp://[fd00::1]:53" }); const updated = (try getForwardZone(&database, testing.allocator, id)).?; defer freeForwardZoneRow(testing.allocator, updated); try testing.expectEqualStrings("lab.example", updated.zone); try testing.expectEqualStrings("tcp://[fd00::1]:53", updated.resolver); var rows = try listForwardZoneRows(&database, testing.allocator); defer rows.deinit(testing.allocator); defer freeForwardZoneRows(testing.allocator, rows.items); try testing.expectEqual(@as(usize, 1), rows.items.len); try testing.expectEqual(id, rows.items[0].id); try deleteForwardZone(&database, id); try testing.expectEqual(@as(?ForwardZoneRow, null), try getForwardZone(&database, testing.allocator, id)); try testing.expectEqual(@as(i64, 0), try countForwardZones(&database)); } test "forward zone update and delete report NotFound for an id no row holds" { var database = try openMigrated(); defer database.close(); const item: model.ForwardZone = .{ .zone = "home.arpa", .resolver = "udp://10.0.0.1:53" }; try testing.expectError(error.NotFound, updateForwardZone(&database, 404, item)); try testing.expectError(error.NotFound, deleteForwardZone(&database, 404)); try testing.expectEqual(@as(?ForwardZoneRow, null), try getForwardZone(&database, testing.allocator, 404)); } test "a duplicate zone surfaces as error.Constraint on insert and on update" { var database = try openMigrated(); defer database.close(); _ = try insertForwardZoneRow(&database, .{ .zone = "home.arpa", .resolver = "udp://10.0.0.1:53" }); const other = try insertForwardZoneRow(&database, .{ .zone = "lab.example", .resolver = "udp://10.0.0.2:53" }); try testing.expectError(error.Constraint, insertForwardZoneRow(&database, .{ .zone = "home.arpa", .resolver = "udp://10.0.0.3:53", })); try testing.expectError(error.Constraint, updateForwardZone(&database, other, .{ .zone = "home.arpa", .resolver = "udp://10.0.0.2:53", })); try testing.expectEqual(@as(i64, 2), try countForwardZones(&database)); } fn localRowsUnderFailure(gpa: Allocator) !void { var database = try openMigrated(); defer database.close(); try seedLocalRecords(&database); try seedForwardZones(&database); var records = try listLocalRecordRows(&database, gpa); defer records.deinit(gpa); defer freeLocalRecordRows(gpa, records.items); const record = (try getLocalRecord(&database, gpa, records.items[0].id)).?; defer freeLocalRecordRow(gpa, record); var zones = try listForwardZoneRows(&database, gpa); defer zones.deinit(gpa); defer freeForwardZoneRows(gpa, zones.items); const zone = (try getForwardZone(&database, gpa, zones.items[0].id)).?; defer freeForwardZoneRow(gpa, zone); } test "the local read surface is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, localRowsUnderFailure, .{}); }