//! `clients` and `client_prefixes`. //! //! `listClients` returns only `hand_edited = 1` rows. A client the server //! materialised from live traffic is runtime state, not configuration, and must //! not appear in an export. `countClients` counts **all** rows, because S5's //! "has this database ever been configured" predicate needs the true count. //! //! Only list / insert / deleteAll / count exist. 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 IdMap = context.IdMap; const InsertContext = context.InsertContext; // --------------------------------------------------------------------------- // clients // --------------------------------------------------------------------------- const list_clients_sql = \\SELECT c.ip, c.name, g.name FROM clients c \\ JOIN groups g ON g.id = c.group_id \\ WHERE c.hand_edited = 1 \\ ORDER BY c.ip ; /// Every string in the result is a heap copy owned by `gpa`. pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) { var stmt = try database.prepare(list_clients_sql); defer stmt.deinit(); var out: std.ArrayList(model.Client) = .empty; // `errdefer`s run in reverse: `freeClients` is declared last so it runs // before the backing array is released. errdefer out.deinit(gpa); errdefer freeClients(gpa, out.items); while (try stmt.step()) { const ip = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(ip); // `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which // is exactly the model's default. const name = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(name); const group = try stmt.columnTextAlloc(gpa, 2); errdefer gpa.free(group); try out.append(gpa, .{ .ip = ip, .name = name, .group = group }); } return out; } pub fn freeClients(gpa: Allocator, items: []const model.Client) void { for (items) |item| { gpa.free(item.ip); gpa.free(item.name); gpa.free(item.group); } } const insert_client_sql = \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) \\VALUES (?1, ?2, ?3, 1, ?4, ?4) ; /// `hand_edited` is 1: a client that reached a repository through the config /// model came from an operator's file, by definition. pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void { const group_id = try ctx.groupId(item.group); var stmt = try database.prepare(insert_client_sql); defer stmt.deinit(); try stmt.bindText(1, item.ip); try stmt.bindText(2, item.name); try stmt.bindInt(3, group_id); try stmt.bindInt(4, ctx.now); try stmt.exec(); } pub fn deleteAllClients(database: *db.Db) db.Error!void { return database.exec("DELETE FROM clients;"); } /// Counts every row, including the ones `listClients` filters out. pub fn countClients(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM clients"); } // --------------------------------------------------------------------------- // client_prefixes // --------------------------------------------------------------------------- const list_client_prefixes_sql = \\SELECT p.prefix, g.name, p.priority FROM client_prefixes p \\ JOIN groups g ON g.id = p.group_id \\ ORDER BY p.prefix ; pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) { var stmt = try database.prepare(list_client_prefixes_sql); defer stmt.deinit(); var out: std.ArrayList(model.ClientPrefix) = .empty; errdefer out.deinit(gpa); errdefer freeClientPrefixes(gpa, out.items); while (try stmt.step()) { const prefix = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(prefix); const group = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(group); // The column is a 64-bit integer; the model field is `i32`. A value // outside that range means something other than nxdns wrote the row. const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch; try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority }); } return out; } pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void { for (items) |item| { gpa.free(item.prefix); gpa.free(item.group); } } pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void { const group_id = try ctx.groupId(item.group); var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)"); defer stmt.deinit(); try stmt.bindText(1, item.prefix); try stmt.bindInt(2, group_id); try stmt.bindInt(3, item.priority); try stmt.exec(); } pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void { return database.exec("DELETE FROM client_prefixes;"); } pub fn countClientPrefixes(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM client_prefixes"); } // --------------------------------------------------------------------------- // 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; } /// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join /// has two distinct groups to resolve. fn seedGroups(database: *db.Db) !IdMap { try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); var ids: IdMap = .empty; errdefer ids.deinit(testing.allocator); try ids.put(testing.allocator, "default", 1); try ids.put(testing.allocator, "kids", 2); return ids; } fn seedClients(database: *db.Db, ids: *const IdMap) !void { const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids }; try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx); try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx); try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx); } fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void { const ctx: InsertContext = .{ .group_ids = ids }; try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx); try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx); try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx); } test "clients round-trip in ip order with group names resolved" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClients(&database, &ids); var items = try listClients(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeClients(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("192.168.1.10", items.items[0].ip); try testing.expectEqualStrings("desk", items.items[0].name); try testing.expectEqualStrings("default", items.items[0].group); try testing.expectEqualStrings("192.168.1.20", items.items[1].ip); try testing.expectEqualStrings("laptop", items.items[1].name); try testing.expectEqualStrings("kids", items.items[1].group); try testing.expectEqualStrings("fd00::1", items.items[2].ip); try testing.expectEqualStrings("", items.items[2].name); try testing.expectEqualStrings("kids", items.items[2].group); try testing.expectEqual( @as(i64, 1700000000), try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"), ); try testing.expectEqual( @as(i64, 1700000000), try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"), ); } test "a hand_edited = 0 client is absent from listClients but counted by countClients" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClients(&database, &ids); try database.exec( \\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen) \\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1); ); try testing.expectEqual(@as(i64, 4), try countClients(&database)); var items = try listClients(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeClients(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); for (items.items) |item| { try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5")); } } test "deleteAllClients empties the table and countClients reflects it" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClients(&database, &ids); try testing.expectEqual(@as(i64, 3), try countClients(&database)); try deleteAllClients(&database); try testing.expectEqual(@as(i64, 0), try countClients(&database)); } test "insertClient reports a group the caller's map does not hold" { var database = try openMigrated(); defer database.close(); const ctx: InsertContext = .{}; try testing.expectError( error.NotFound, insertClient(&database, .{ .ip = "192.168.1.1" }, ctx), ); } fn listClientsUnderFailure(gpa: Allocator, ids: *const IdMap) !void { var database = try openMigrated(); defer database.close(); try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); try seedClients(&database, ids); var items = try listClients(&database, gpa); defer items.deinit(gpa); defer freeClients(gpa, items.items); } test "listClients is leak-safe under allocation failure" { var ids: IdMap = .empty; defer ids.deinit(testing.allocator); try ids.put(testing.allocator, "default", 1); try ids.put(testing.allocator, "kids", 2); try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids}); } test "client_prefixes round-trip in prefix order with group names resolved" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClientPrefixes(&database, &ids); var items = try listClientPrefixes(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeClientPrefixes(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix); try testing.expectEqualStrings("default", items.items[0].group); try testing.expectEqual(@as(i32, 50), items.items[0].priority); try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix); try testing.expectEqualStrings("kids", items.items[1].group); try testing.expectEqual(@as(i32, 10), items.items[1].priority); try testing.expectEqualStrings("fd00::/48", items.items[2].prefix); try testing.expectEqualStrings("kids", items.items[2].group); try testing.expectEqual(@as(i32, 100), items.items[2].priority); } test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" { var database = try openMigrated(); defer database.close(); var ids = try seedGroups(&database); defer ids.deinit(testing.allocator); try seedClientPrefixes(&database, &ids); try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database)); try deleteAllClientPrefixes(&database); try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database)); } fn listClientPrefixesUnderFailure(gpa: Allocator, ids: *const IdMap) !void { var database = try openMigrated(); defer database.close(); try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); try seedClientPrefixes(&database, ids); var items = try listClientPrefixes(&database, gpa); defer items.deinit(gpa); defer freeClientPrefixes(gpa, items.items); } test "listClientPrefixes is leak-safe under allocation failure" { var ids: IdMap = .empty; defer ids.deinit(testing.allocator); try ids.put(testing.allocator, "default", 1); try ids.put(testing.allocator, "kids", 2); try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids}); }