Files
nxdns/src/config/loader.zig
T

324 lines
13 KiB
Zig

//! The one way a configuration file becomes a `model.Config`.
//!
//! `nxdns run --config <file>` and `nxdns check --config <file>` 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);
}