//! What every mutation handler shares: the collaborator checks, the database //! error mapping, the per-row validation, and the two ways a change is applied //! to the running server. //! //! Three conventions hold across `web/handlers/`: //! //! - Every repository call in this layer allocates from the per-request arena, //! so the repositories' `freeX` helpers are deliberately not called: the //! arena is reset when the response is written. Nothing read here outlives //! the request. //! - A collaborator this layer needs and does not have is a 503, never a crash //! and never a silent success. `web.enabled = false` opens no database at //! all, and a half-wired `WebState` must fail the same way. //! - Domain outcomes are status codes (ruling 8): `error.NotFound` is 404, //! `error.Constraint` is 409 with the constraint named in words, a value the //! validator rejects is 400, and everything else is a 500 whose cause is //! logged at `warn` and never sent to the client (PLAN §19). //! //! `WebState.config_lock` exists because `std.http.Server` connections are served //! concurrently while all of them share one config connection. SQLite is built //! in serialized mode, so the connection is safe — but `changes()` and //! `lastInsertRowid()` describe *the connection's* last statement, and those //! are exactly what `crud.execStrict` and every `insertXRow` read. Two //! concurrent writers would read each other's answer. One lock around the //! database work of a mutation makes the read-back belong to the writer that //! caused it. const std = @import("std"); const Allocator = std.mem.Allocator; const db = @import("../../storage/db.zig"); const forward_zones = @import("../../local/forward_zones.zig"); const local_repo = @import("../../storage/repositories/local_repo.zig"); const local_records = @import("../../local/records.zig"); const local_tables = @import("../../server/local_tables.zig"); const migrations = @import("../../storage/migrations.zig"); const http_util = @import("../http_util.zig"); const model = @import("../../config/model.zig"); const server = @import("../server.zig"); const settings_repo = @import("../../storage/repositories/settings_repo.zig"); const validate = @import("../../config/validate.zig"); const clients_repo = @import("../../storage/repositories/clients_repo.zig"); const groups_repo = @import("../../storage/repositories/groups_repo.zig"); const rules_repo = @import("../../storage/repositories/rules_repo.zig"); const sources_repo = @import("../../storage/repositories/sources_repo.zig"); const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig"); const log = std.log.scoped(.web_api); pub const Request = http_util.Request; pub const HandlerError = http_util.HandlerError; /// Why a request did not succeed. Every handler in this directory decides in a /// function that takes no `std.http.Server.Request`, returns one of these, and /// leaves the response to `respondFailure` — so the decision is testable /// against an in-memory database, with no socket anywhere. pub const Failure = union(enum) { /// The id names no row: 404. not_found, /// A constraint of the schema or of the configuration: 409. The text names /// which one, because the client can only fix what it is told. conflict: []const u8, /// A value the validator refused: 400, with the validator's own text. invalid: []const u8, /// A collaborator this request needs is not wired: 503. unavailable: []const u8, /// Anything else the database reported: 500, cause logged, not sent. internal: db.Error, /// The write landed and the running server could not be told about it. /// A 500 that says exactly that, because retrying the write would not help /// and reporting success would leave the operator with a stale server. not_applied, }; pub fn respondFailure(request: *Request, failure: Failure, what: []const u8) HandlerError!void { return switch (failure) { .not_found => http_util.respondError(request, .not_found, "not found"), .conflict => |message| http_util.respondError(request, .conflict, message), .invalid => |message| http_util.respondError(request, .bad_request, message), .unavailable => |message| http_util.respondError(request, .service_unavailable, message), .internal => |err| { log.warn("{s} failed: {t}", .{ what, err }); return http_util.respondError(request, .internal_server_error, "internal error"); }, .not_applied => http_util.respondError( request, .internal_server_error, "the change was saved but could not be applied; restart nxdns", ), }; } /// Turns a repository error into a `Failure`. `conflict` names the constraint /// that can fire for this statement (W2 documents one per function). pub fn dbFailure(err: db.Error, conflict: []const u8) Failure { return switch (err) { error.NotFound => .not_found, error.Constraint => .{ .conflict = conflict }, else => .{ .internal = err }, }; } /// The 503 a state with no config connection earns. One constant, because every /// caller reports the same missing collaborator in the same words. pub const no_config_db: Failure = .{ .unavailable = "no configuration database" }; /// The config connection, or `error.NoConfigDb` for the caller to turn into /// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`, /// or a written response. pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db { return state.config_db orelse error.NoConfigDb; } pub fn nowSeconds(io: std.Io) i64 { return std.Io.Clock.real.now(io).toSeconds(); } // --------------------------------------------------------------------------- // the identical half of an id-addressed resource // --------------------------------------------------------------------------- /// The `list`, `get` and `remove` handlers every id-addressed resource in this /// directory writes the same way: take the config connection or answer 503, call /// one repository function, and turn what comes back into the response. Nothing /// a resource decides for itself is here — the create and update bodies, the /// constraint texts, and the four reload flavors stay hand-written beside the /// descriptor that names these three. /// /// `desc` is an anonymous struct literal rather than a typed struct because /// `anytype` is not legal as a struct *field* type and the members are functions /// of five signatures. Every member is checked below, so a descriptor that is /// missing one or spells one wrong is a compile error that names it. /// /// Members: /// /// - `Row: type` — what the repository returns for one row. /// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`. /// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource /// with no `/{id}` route. /// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an /// `Allocator` before the id for a delete decision that reads rows, or `null`. /// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as /// their object in the log context a 500 carries. /// - `plural: []const u8` — "upstreams": what "listing" takes as its object. /// - `envelope: []const u8` — the JSON key the list arrives under. /// - `view: fn (Row) View` — optional. A resource whose wire shape is not its /// row spells the difference here; without it the row is serialised as it is. pub fn Resource(comptime desc: anytype) type { const Desc = @TypeOf(desc); for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| { if (!@hasField(Desc, name)) { @compileError("resource descriptor has no `" ++ name ++ "`"); } } if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type"); const Row = desc.Row; expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)); if (!isNull(@TypeOf(desc.get))) { expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row); } if (!isNull(@TypeOf(desc.remove))) { const Remove = @TypeOf(desc.remove); if (removeTakesArena(Remove)) { if (removeIsFallible(Remove)) { expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) error{OutOfMemory}!?Failure); } else { expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure); } } else { expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure); } } _ = @as([]const u8, desc.label); _ = @as([]const u8, desc.plural); _ = @as([]const u8, desc.envelope); const has_view = @hasField(Desc, "view"); if (has_view) { const info = @typeInfo(@TypeOf(desc.view)).@"fn"; if (info.params.len != 1 or info.params[0].type.? != Row) { @compileError("resource descriptor `view` must take one " ++ @typeName(Row)); } } const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row; const names: [1][:0]const u8 = .{desc.envelope}; const types: [1]type = .{[]const View}; const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}}; const Envelope = @Struct(.auto, null, &names, &types, &attrs); const list_what = "listing " ++ desc.plural; const get_what = "reading " ++ desc.label; const remove_what = "deleting " ++ desc.label; return struct { pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { _ = io; const database = requireConfigDb(state) catch return respondFailure(request, no_config_db, list_what); const rows = desc.list(database, request.arena) catch |err| return respondFailure(request, .{ .internal = err }, list_what); const items: []const View = if (has_view) views: { const views = try request.arena.alloc(View, rows.items.len); for (views, rows.items) |*view, row| view.* = desc.view(row); break :views views; } else rows.items; var payload: Envelope = undefined; @field(payload, desc.envelope) = items; return http_util.respondJson(request, .ok, payload, &.{}); } pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow; pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow; fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { _ = io; const database = requireConfigDb(state) catch return respondFailure(request, no_config_db, get_what); const row = desc.get(database, request.arena, request.id.?) catch |err| return respondFailure(request, .{ .internal = err }, get_what); const found = row orelse return respondFailure(request, .not_found, ""); const body: View = if (has_view) desc.view(found) else found; return http_util.respondJson(request, .ok, body, &.{}); } fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const failure = if (comptime !removeTakesArena(@TypeOf(desc.remove))) desc.remove(state, io, request.id.?) else if (comptime removeIsFallible(@TypeOf(desc.remove))) try desc.remove(state, io, request.arena, request.id.?) else desc.remove(state, io, request.arena, request.id.?); if (failure) |value| return respondFailure(request, value, remove_what); return http_util.respondEmpty(request, .no_content); } }; } fn isNull(comptime T: type) bool { return T == @TypeOf(null); } fn removeTakesArena(comptime T: type) bool { return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4; } /// A delete decision that builds a candidate for the running server allocates /// while it does so, so it may run out of memory. The two shapes are checked /// exactly, so a `remove` that returns some other error set is still a compile /// error naming what it should have been. fn removeIsFallible(comptime T: type) bool { return @typeInfo(@typeInfo(T).@"fn".return_type.?) == .error_union; } fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void { if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++ @typeName(Expected) ++ ", found " ++ @typeName(Actual)); } // --------------------------------------------------------------------------- // applying a change to the running server (ruling 12) // --------------------------------------------------------------------------- /// Rebuilds the blocklist snapshot so the change is live on the next query. /// /// A state with no `reload_fn` has nothing to reload — that is the shape of a /// web layer under test, and of one whose composition root wired no manager. /// /// Locking contract, and it is the reason every one of the fourteen call sites /// in `groups.zig`, `rules.zig`, `clients.zig` and `blocklists.zig` may call /// this *after* releasing `state.config_lock`: `reload_fn` must re-read all of /// the state it publishes from the database itself, under the manager's own /// writer lock. It must never accept rows the caller read. Rows read under /// `config_lock` and passed across its release are already stale, so a /// signature change that adds a row parameter here silently breaks the /// correctness of all fourteen sites — every one of them would have to move /// the call back inside the lock. /// /// `swapLocalTables` below is the pre-read shape and is exactly the contrast: /// it takes the rows, so `local.zig`'s `publish` calls it while it still holds /// `config_lock`, and the ordering rationale lives on that function. pub fn reload(state: *server.WebState, io: std.Io) ?Failure { const reload_fn = state.reload_fn orelse return null; reload_fn(state, io) catch |err| { log.warn("applying a configuration change failed: {s}", .{@errorName(err)}); return .not_applied; }; return null; } /// Rebuilds the local records and the forward zones from the database and /// publishes both (ruling 12). Local answers therefore change live, without the /// blocklist snapshot being rebuilt. /// /// Both tables are built before either is published, so a failure leaves the /// running server with the generation it already had. pub fn swapLocalTables( state: *server.WebState, io: std.Io, arena: Allocator, database: *db.Db, ) ?Failure { const tables = state.local_tables orelse return null; const gpa = state.gpa; const record_rows = local_repo.listLocalRecords(database, arena) catch |err| return rebuildFailed("reading the local records", @errorName(err)); const zone_rows = local_repo.listForwardZones(database, arena) catch |err| return rebuildFailed("reading the forward zones", @errorName(err)); var built_records = local_records.Records.build(gpa, record_rows.items) catch |err| return rebuildFailed("building the local records", @errorName(err)); errdefer built_records.deinit(gpa); const built_zones = forward_zones.Zones.build(gpa, zone_rows.items) catch |err| return rebuildFailed("building the forward zones", @errorName(err)); tables.swap(io, gpa, built_records, built_zones); return null; } fn rebuildFailed(what: []const u8, cause: []const u8) Failure { log.warn("{s} after a change failed: {s}", .{ what, cause }); return .not_applied; } // --------------------------------------------------------------------------- // per-row validation // --------------------------------------------------------------------------- // // `config/validate.zig` validates a whole configuration and is not this // session's to split, so a candidate row is checked by handing the real // validator a configuration that holds the skeleton it insists on (one default // group, one upstream) plus the one row under test. The row's own rules — // domain syntax, record values, rule patterns, CIDR prefixes, source urls, TTL // ranges — are then exactly the shipped ones, with no second copy to drift. // // Cross-row facts are deliberately NOT checked here: a duplicate is the // database's UNIQUE constraint and answers 409 (ruling 9), and a group that // does not exist is a foreign key and answers 409 too. Reporting either as a // 400 would be a second, weaker opinion about the same fact. const skeleton_group = "default"; const skeleton_upstream: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" }; /// Runs the shipped validator over `cfg` and returns the first failure's text, /// or null when the candidate is valid. The text is arena-allocated. /// /// Failures only: a warning describes a configuration that is legal, and a row /// this API is about to store cannot be rejected for one. The blocklist source /// a POST creates is in no group yet — a warning by design, and never a 400. pub fn firstProblem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 { var diags: validate.Diagnostics = .init(arena); defer diags.deinit(); validate.validate(cfg, &diags) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => {}, }; const problem = diags.firstFailure() orelse return null; return try std.fmt.allocPrint(arena, "{s}: {s}", .{ problem.path, problem.message }); } /// The configuration skeleton every candidate is validated inside. fn skeleton(groups: []const model.Group) model.Config { return .{ .upstreams = &.{skeleton_upstream}, .groups = groups, }; } const default_groups = [_]model.Group{.{ .name = skeleton_group }}; pub fn checkLocalRecord(arena: Allocator, record: model.LocalRecord) error{OutOfMemory}!?[]const u8 { var cfg = skeleton(&default_groups); cfg.local_records = &.{record}; return firstProblem(arena, cfg); } pub fn checkForwardZone(arena: Allocator, zone: model.ForwardZone) error{OutOfMemory}!?[]const u8 { var cfg = skeleton(&default_groups); cfg.forward_zones = &.{zone}; return firstProblem(arena, cfg); } pub fn checkRule(arena: Allocator, pattern: []const u8, kind: model.RuleKind) error{OutOfMemory}!?[]const u8 { var cfg = skeleton(&default_groups); cfg.rules = &.{.{ .group = skeleton_group, .pattern = pattern, .kind = kind, .action = .block }}; return firstProblem(arena, cfg); } pub fn checkSource(arena: Allocator, source: model.BlocklistSource) error{OutOfMemory}!?[]const u8 { var cfg = skeleton(&default_groups); cfg.blocklist_sources = &.{source}; return firstProblem(arena, cfg); } pub fn checkClientPrefix(arena: Allocator, prefix: []const u8, priority: i32) error{OutOfMemory}!?[]const u8 { var cfg = skeleton(&default_groups); cfg.client_prefixes = &.{.{ .prefix = prefix, .group = skeleton_group, .priority = priority }}; return firstProblem(arena, cfg); } /// A group name is checked inside a configuration that already holds the /// default group, so a candidate named anything else is still complete. pub fn checkGroupName(arena: Allocator, name: []const u8) error{OutOfMemory}!?[]const u8 { if (std.mem.eql(u8, name, skeleton_group)) return firstProblem(arena, skeleton(&default_groups)); const groups = [_]model.Group{ .{ .name = skeleton_group }, .{ .name = name } }; return firstProblem(arena, skeleton(&groups)); } /// An upstream candidate is validated next to one known-good enabled upstream, /// so a disabled candidate does not trip the whole-config rule that at least /// one upstream must be enabled — whether the stored set satisfies that rule is /// the handler's own guard, not this row check's. The companion's url moves out /// of the way of a candidate that holds the skeleton url, because a duplicate /// is the database's answer, not the validator's. pub fn checkUpstream(arena: Allocator, upstream: model.UpstreamServer) error{OutOfMemory}!?[]const u8 { const companion: model.UpstreamServer = if (std.mem.eql(u8, upstream.url, skeleton_upstream.url)) .{ .url = "https://dns-b.example/dns-query" } else skeleton_upstream; var cfg = skeleton(&default_groups); cfg.upstreams = &.{ upstream, companion }; return firstProblem(arena, cfg); } /// The 400 a malformed or unparseable body earns. pub fn respondBadBody(request: *Request, err: anyerror) HandlerError!void { return switch (err) { error.TooLarge => http_util.respondError(request, .payload_too_large, "request body too large"), error.OutOfMemory => error.OutOfMemory, error.WriteFailed => error.WriteFailed, error.HttpExpectationFailed => error.HttpExpectationFailed, // A vanished peer mid-body is the same event as a vanished peer // mid-response, and ends the connection the same way (ruling 28). error.ReadFailed => error.WriteFailed, else => http_util.respondError(request, .bad_request, "malformed request body"), }; } // --------------------------------------------------------------------------- // reading the stored configuration // --------------------------------------------------------------------------- /// Every settings row and every collection, as one `model.Config`. The settings /// PUT validates against this (ruling 16), so the check sees the same /// configuration the next start would. /// /// Every string belongs to `arena`. pub fn loadConfig(arena: Allocator, database: *db.Db) db.Error!model.Config { var cfg: model.Config = .{}; const pairs = try settings_repo.listSettings(database, arena); var unknown: usize = 0; model.fromSettings(pairs.items, &cfg, &unknown) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, // A stored value this build cannot decode is a corrupt row, not a // client error: the caller reports 500 and the operator sees the log. error.BadSettingValue => return error.Mismatch, }; const groups = try groups_repo.listGroups(database, arena); cfg.groups = groups.items; const upstreams = try upstreams_repo.listUpstreams(database, arena); cfg.upstreams = upstreams.items; const clients = try clients_repo.listClients(database, arena); cfg.clients = clients.items; const prefixes = try clients_repo.listClientPrefixes(database, arena); cfg.client_prefixes = prefixes.items; const sources = try sources_repo.listBlocklistSources(database, arena); cfg.blocklist_sources = sources.items; const group_sources = try groups_repo.listGroupSources(database, arena); cfg.group_sources = group_sources.items; const rules = try rules_repo.listRules(database, arena); cfg.rules = rules.items; const records = try local_repo.listLocalRecords(database, arena); cfg.local_records = records.items; const zones = try local_repo.listForwardZones(database, arena); cfg.forward_zones = zones.items; return cfg; } // --------------------------------------------------------------------------- // the test bench every handler in this directory shares // --------------------------------------------------------------------------- /// A running web layer with no sockets in it: an in-memory config database at /// the current schema, the local-table holder, a request arena, and a /// `reload_fn` that counts instead of rebuilding a snapshot. /// /// `state` is a field rather than a pointer so the reload seam can find the /// bench through `@fieldParentPtr` — a `WebState` carries no user data, and a /// global counter would make two tests in one binary share it. /// /// Referenced only by this directory's tests; nothing in a shipped build calls /// `init`, so it costs nothing there. pub const Bench = struct { threaded: std.Io.Threaded, database: db.Db, tables: local_tables.LocalTables, arena_state: std.heap.ArenaAllocator, state: server.WebState, reloads: usize, reload_fails: bool, /// Initialises in place: `state` points at fields of `self`. pub fn init(self: *Bench, gpa: Allocator) !void { self.threaded = .init(gpa, .{}); errdefer self.threaded.deinit(); self.database = try db.Db.open(":memory:", .{ .mode = .memory }); errdefer self.database.close(); try db.applyPragmas(&self.database, .{}); _ = try migrations.migrate(&self.database); self.tables = .empty; self.arena_state = .init(gpa); self.reloads = 0; self.reload_fails = false; self.state = .{ .gpa = gpa, .config_db = &self.database, .local_tables = &self.tables, .reload_fn = countingReload, }; } pub fn deinit(self: *Bench, gpa: Allocator) void { self.state.live_hash.deinit(gpa); self.tables.deinit(gpa); self.arena_state.deinit(); self.database.close(); self.threaded.deinit(); } pub fn io(self: *Bench) std.Io { return self.threaded.io(); } pub fn arena(self: *Bench) Allocator { return self.arena_state.allocator(); } /// One statement of setup, for the rows a case needs before it starts. pub fn exec(self: *Bench, sql: [:0]const u8) !void { try self.database.exec(sql); } pub fn queryInt(self: *Bench, sql: []const u8) !i64 { return self.database.queryInt(sql); } fn countingReload(state: *server.WebState, io_unused: std.Io) anyerror!void { _ = io_unused; const self: *Bench = @alignCast(@fieldParentPtr("state", state)); self.reloads += 1; if (self.reload_fails) return error.ReloadFailed; } }; // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; fn arenaFor(state: *std.heap.ArenaAllocator) Allocator { return state.allocator(); } test "the bench wires a state whose reload seam counts" { var bench: Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try testing.expect(bench.state.config_db != null); try testing.expectEqual(@as(?Failure, null), reload(&bench.state, bench.io())); try testing.expectEqual(@as(usize, 1), bench.reloads); bench.reload_fails = true; try testing.expectEqual(Failure.not_applied, reload(&bench.state, bench.io()).?); try testing.expectEqual(@as(usize, 2), bench.reloads); } test "the schema the bench opens already holds the default group" { var bench: Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'")); } test "a state with no configuration database is a 503, not a crash" { var bench: Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state)); var bare: server.WebState = .{ .gpa = testing.allocator }; try testing.expectError(error.NoConfigDb, requireConfigDb(&bare)); try testing.expectEqualStrings("no configuration database", no_config_db.unavailable); } test "a database error maps to the status its cause deserves" { try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x")); try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict); try testing.expectEqual(db.Error.Busy, dbFailure(error.Busy, "x").internal); } test "a valid candidate row reports no problem" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arenaFor(&arena_state); try testing.expectEqual( @as(?[]const u8, null), try checkLocalRecord(arena, .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }), ); try testing.expectEqual( @as(?[]const u8, null), try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }), ); try testing.expectEqual(@as(?[]const u8, null), try checkRule(arena, "*.ads.example", .wildcard)); try testing.expectEqual(@as(?[]const u8, null), try checkClientPrefix(arena, "192.168.1.0/24", 100)); try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "kids")); try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "default")); try testing.expectEqual( @as(?[]const u8, null), try checkSource(arena, .{ .url = "https://example.test/list.txt", .name = "list" }), ); try testing.expectEqual( @as(?[]const u8, null), try checkUpstream(arena, .{ .url = "tls://1.1.1.1:853", .tls_name = "one.one.one.one" }), ); } test "a disabled upstream candidate is valid on its own merits" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arenaFor(&arena_state); try testing.expectEqual( @as(?[]const u8, null), try checkUpstream(arena, .{ .url = "https://dns.other/dns-query", .enabled = false }), ); // The skeleton's own url must not read as a duplicate of the companion. try testing.expectEqual( @as(?[]const u8, null), try checkUpstream(arena, .{ .url = skeleton_upstream.url, .enabled = false }), ); // A disabled row's other fields are still judged. const bad = try checkUpstream(arena, .{ .url = "udp://1.1.1.1:53", .enabled = false }); try testing.expect(bad != null); } test "an invalid candidate row names the field that failed" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arenaFor(&arena_state); const bad_value = try checkLocalRecord( arena, .{ .name = "nas.lan", .rtype = .a, .value = "not-an-ip", .ttl = 60 }, ); try testing.expect(bad_value != null); try testing.expect(std.mem.startsWith(u8, bad_value.?, "local_records[0].value:")); const bad_ttl = try checkLocalRecord( arena, .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 0 }, ); try testing.expect(bad_ttl != null); const bad_resolver = try checkForwardZone(arena, .{ .zone = "lan", .resolver = "http://10.0.0.1" }); try testing.expect(bad_resolver != null); const bad_pattern = try checkRule(arena, "ads.*.example", .exact); try testing.expect(bad_pattern != null); const bad_prefix = try checkClientPrefix(arena, "192.168.1.0", 100); try testing.expect(bad_prefix != null); const empty_group = try checkGroupName(arena, ""); try testing.expect(empty_group != null); const bad_source = try checkSource(arena, .{ .url = "ftp://example.test/list", .name = "list" }); try testing.expect(bad_source != null); } test "a candidate is judged alone, so a duplicate is left to the database" { var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arenaFor(&arena_state); // The same zone twice would be `DuplicateForwardZone` in a whole config; // one candidate row cannot collide with itself, and the UNIQUE constraint // is what answers 409. try testing.expectEqual( @as(?[]const u8, null), try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }), ); }