//! The one way a configuration file becomes a `model.Config`. //! //! `nxdns run --config ` and `nxdns check --config ` must grade the //! same file the same way, so the read, the error classification and the parse //! live here rather than once per subcommand. `config/faults.zig` exists for the //! same reason one layer up: two copies of a rule are two rules. //! //! **The classification.** `faults.isConfigFault` deliberately excludes //! `FileNotFound` and `AccessDenied` in general — a missing file is usually a //! broken box, not a wrong configuration. The managed file is the one place //! where the opposite holds: the operator named that path, so a path that does //! not resolve is a configuration fault (exit 2, `nxdns check` is the next //! step). Only the path class converts: //! //! `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`, //! `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable` //! //! Everything else `readFileAllocOptions` can return — `SystemResources`, the //! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and //! exits 1. Those are box faults a retry can clear, and the shipped unit carries //! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would //! stop the service permanently on a fault that would have cleared itself. //! //! The mapping is a named error set switched exhaustively with //! `else => |other| return other`, so an error a Zig upgrade adds to //! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2. const std = @import("std"); const Allocator = std.mem.Allocator; const model = @import("model.zig"); const validate = @import("validate.zig"); /// The ceiling on a configuration file. A file above it is a configuration /// fault, not a resource failure: nothing an operator writes by hand comes near /// 4 MiB, so this is a typo or a wrong path rather than a real config. pub const max_config_bytes = 4 * 1024 * 1024; /// Every error `readFileAllocOptions` can hand back, plus the parse. pub const ReadError = std.Io.Dir.ReadFileAllocError; /// The open failures that mean the operator's path is wrong rather than the box /// being broken. Spelled out as a set rather than as switch prongs so that the /// list is one thing a reader can find and a test can enumerate. pub const PathFault = error{ FileNotFound, AccessDenied, PermissionDenied, NotDir, IsDir, SymLinkLoop, NameTooLong, BadPathName, }; pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon }; /// The classification itself, pure and testable on its own: a path-class /// failure becomes `ManagedConfigUnreadable`, the size limit becomes /// `ConfigTooLarge`, and every other member travels unchanged. pub fn mapReadError(e: ReadError) Error { return switch (e) { error.StreamTooLong => error.ConfigTooLarge, error.FileNotFound, error.AccessDenied, error.PermissionDenied, error.NotDir, error.IsDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName, => error.ManagedConfigUnreadable, else => |other| other, }; } /// The file, NUL-terminated because `std.zon.parse` needs a sentinel and /// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem /// returned them: `nxdns import` reads an operator-supplied argument, not a /// managed file, and its exit codes are its own. pub fn readSource( io: std.Io, gpa: Allocator, dir: std.Io.Dir, path: []const u8, ) ReadError![:0]u8 { return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0); } /// `readSource` under the managed-file classification, with the reason recorded /// as a diagnostic. A Zig error carries no text, so the path an operator has to /// go and fix reaches them through `Diagnostics` — the same channel every other /// configuration problem travels down, and the reason `check` and `run` print /// these in one shape. pub fn readManaged( io: std.Io, gpa: Allocator, dir: std.Io.Dir, path: []const u8, diags: *validate.Diagnostics, ) Error![:0]u8 { return readSource(io, gpa, dir, path) catch |e| { switch (e) { error.StreamTooLong => try diags.add( error.ConfigTooLarge, "{s}", .{path}, "larger than {d} bytes", .{max_config_bytes}, ), error.FileNotFound => try diags.add( error.ManagedConfigUnreadable, "{s}", .{path}, "no such file", .{}, ), error.AccessDenied, error.PermissionDenied => try diags.add( error.ManagedConfigUnreadable, "{s}", .{path}, "not readable", .{}, ), error.IsDir => try diags.add( error.ManagedConfigUnreadable, "{s}", .{path}, "is a directory, not a configuration file", .{}, ), error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add( error.ManagedConfigUnreadable, "{s}", .{path}, "cannot be opened ({s})", .{@errorName(e)}, ), // A box fault. It exits 1 with its own name and records nothing: a // diagnostic would file it under "the configuration is wrong". else => {}, } return mapReadError(e); }; } /// The line and column of a ZON syntax error are the only thing the operator can /// act on, so they travel the same channel as every other config problem: the /// caller's `Diagnostics`, which `run`, `check` and `import` all render. The /// global log is not that channel — an operator reading command output would see /// a bare `ParseZon` and nothing else. /// /// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per /// problem, plus a "note:" line each, so each rendered line becomes one /// `Problem` and the list keeps the parser's order. Rendering them inline would /// put newlines inside a single `FAIL` record. pub fn reportParseFailure( diags: *validate.Diagnostics, zon_diag: *const std.zon.parse.Diagnostics, ) error{OutOfMemory}!void { const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag}); defer diags.gpa.free(rendered); var lines = std.mem.splitScalar(u8, rendered, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; try diags.add(error.ParseZon, "config", .{}, "{s}", .{line}); } } /// The parse, with its failure rendered. The result is arena-owned and /// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an /// absent field by copying the struct's default straight through /// (parse.zig:874), so a defaulted `[]const u8` — and this model has many /// non-empty string defaults — points into the binary's read-only data. /// `parse.free` keeps no record of which fields were parsed and which were /// defaulted, so it would `@memset` and free rodata. Freeing the arena is the /// only correct release. pub fn parse( arena: Allocator, source: [:0]const u8, diags: *validate.Diagnostics, ) error{ ParseZon, OutOfMemory }!model.Config { var zon_diag: std.zon.parse.Diagnostics = .{}; return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) { error.OutOfMemory => error.OutOfMemory, error.ParseZon => { try reportParseFailure(diags, &zon_diag); return error.ParseZon; }, }; } /// Read and parse the managed file, everything a caller needs before /// `validate.validate`. Validation is deliberately left to the caller: `check` /// runs it beside its certificate and upstream probes, `run` runs it alone, and /// both call the same validator on the same `Config`, which is what makes the /// two agree. /// /// `arena` owns both the source text and the returned configuration. pub fn load( io: std.Io, arena: Allocator, dir: std.Io.Dir, path: []const u8, diags: *validate.Diagnostics, ) Error!model.Config { const source = try readManaged(io, arena, dir, path, diags); return parse(arena, source, diags); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; test "every path-class open failure is a managed-config fault" { inline for (@typeInfo(PathFault).error_set.?) |member| { const e = @field(ReadError, member.name); try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e)); } } test "a box fault outside the path class propagates unmapped" { // Each of these exits 1: a retry can clear them, and the shipped unit's // `RestartPreventExitStatus=2 64` would make exit 2 permanent. try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources)); try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded)); try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded)); try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory)); try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput)); } test "the size limit is its own fault, not an unreadable path" { try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong)); } test "the path class is exactly the eight members the ruling names" { // A member added to `PathFault` without a decision recorded in the spec // fails here rather than quietly moving an exit code from 1 to 2. const expected = [_][]const u8{ "FileNotFound", "AccessDenied", "PermissionDenied", "NotDir", "IsDir", "SymLinkLoop", "NameTooLong", "BadPathName", }; const members = @typeInfo(PathFault).error_set.?; try testing.expectEqual(expected.len, members.len); inline for (members) |member| { var found = false; for (expected) |name| { if (std.mem.eql(u8, name, member.name)) found = true; } try testing.expect(found); } } test "a missing managed file records the path and the reason" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var diags: validate.Diagnostics = .init(testing.allocator); defer diags.deinit(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); try testing.expectError( error.ManagedConfigUnreadable, load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags), ); try testing.expectEqual(@as(usize, 1), diags.failureCount()); try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path); try testing.expectEqualStrings("no such file", diags.problems.items[0].message); } test "a directory named as the managed file is a configuration fault, not a crash" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(testing.io, "sub"); var diags: validate.Diagnostics = .init(testing.allocator); defer diags.deinit(); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); try testing.expectError( error.ManagedConfigUnreadable, load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags), ); try testing.expectEqual(@as(usize, 1), diags.failureCount()); } test "load parses a valid file and renders a syntax error line by line" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " }); var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var good_diags: validate.Diagnostics = .init(testing.allocator); defer good_diags.deinit(); const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags); try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len); try testing.expectEqual(@as(usize, 1), cfg.upstreams.len); var bad_diags: validate.Diagnostics = .init(testing.allocator); defer bad_diags.deinit(); try testing.expectError( error.ParseZon, load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags), ); try testing.expect(bad_diags.failureCount() >= 1); try testing.expectEqualStrings("config", bad_diags.problems.items[0].path); }