//! One definition of "the operator's configuration is wrong". //! //! `run`, `check` and `import` all sort a failure into two buckets: the //! configuration is wrong and the operator can fix it (exit 2, `nxdns check` //! is the next step), or something else broke (exit 1). Each subcommand used to //! carry its own list of which errors meant which, and the lists disagreed — //! the same seed file exited 1 from `run` and 2 from `check`. There is one list //! now, and it is this file. Nothing else may keep a second one. const std = @import("std"); const validate = @import("validate.zig"); /// `ValidateError` enters as a whole set rather than variant by variant, so a /// variant added to the validator cannot silently fall through to exit 1. The /// five extras are the configuration faults raised outside the validator: the /// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed /// file the operator named and this process cannot open /// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's /// upstream build (`NoUsableUpstreams`) and its certificate load /// (`BadCertificate`). /// /// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a /// configuration fault, and it is deliberately not `FileNotFound` itself: the /// operator named that path on the command line, so it is theirs to fix, while a /// missing file anywhere else stays a runtime failure. `config/loader.zig` owns /// the conversion and the closed set of open errors that qualify. /// /// Not here on purpose: `error.DestructiveImport`, which reports what an import /// would do to the database rather than the content of a file, and is the one /// config-shaped exit 2 `cli` decides for itself. const ConfigFault = validate.ValidateError || error{ ParseZon, ConfigTooLarge, ManagedConfigUnreadable, NoUsableUpstreams, BadCertificate, }; const faults: []const anyerror = blk: { const set = @typeInfo(ConfigFault).error_set.?; var list: [set.len]anyerror = undefined; for (set, 0..) |member, i| list[i] = @field(anyerror, member.name); const frozen = list; break :blk &frozen; }; /// True when `err` means the configuration the operator supplied is wrong. /// Linear over a set of about forty errors, on failure paths only. pub fn isConfigFault(err: anyerror) bool { for (faults) |fault| { if (err == fault) return true; } return false; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; test "every ValidateError variant is a configuration fault, with no exceptions" { // The guard on the derivation: a variant added to `ValidateError` and left // out of the classification fails here rather than exiting 1 in the field. // // No member is excused. This file used to subtract `error.OutOfMemory` // here, which made the rule "every ValidateError is a fault, except one" — // a private exclusion list of exactly the kind this file exists to abolish. // `validate.ValidateError` no longer carries an allocation failure, so the // rule is literal again. inline for (@typeInfo(validate.ValidateError).error_set.?) |member| { const err = @field(anyerror, member.name); if (!isConfigFault(err)) { std.debug.print("isConfigFault(error.{s}) is false, expected true\n", .{member.name}); return error.TestUnexpectedResult; } } } test "an allocation failure is not a member of the validator's verdict" { // The root of it: `faults.zig` can only be exception-free while // `ValidateError` holds nothing that is not a verdict on the file. inline for (@typeInfo(validate.ValidateError).error_set.?) |member| { if (std.mem.eql(u8, member.name, "OutOfMemory")) { std.debug.print("ValidateError carries error.OutOfMemory\n", .{}); return error.TestUnexpectedResult; } } // It is still reachable from `validate`, just not as a finding: the // allocator can fail and the caller has to handle it. comptime var reachable = false; inline for (@typeInfo(validate.Error).error_set.?) |member| { if (comptime std.mem.eql(u8, member.name, "OutOfMemory")) reachable = true; } try testing.expect(reachable); } test "the faults raised outside the validator are configuration faults" { try testing.expect(isConfigFault(error.ParseZon)); try testing.expect(isConfigFault(error.ConfigTooLarge)); try testing.expect(isConfigFault(error.NoUsableUpstreams)); try testing.expect(isConfigFault(error.BadCertificate)); } test "a managed file the operator named and this process cannot open is exit 2" { // Ruling 2. The general rule below still holds — a bare `FileNotFound` is a // runtime failure — and this is the one converted form, produced only by // `config/loader.zig` for a path `--config` named. try testing.expect(isConfigFault(error.ManagedConfigUnreadable)); try testing.expect(!isConfigFault(error.FileNotFound)); } test "the seed-file errors that used to exit 1 from run are configuration faults" { // D1 verbatim: these three reached `run` from a rejected seed file and were // classified as runtime failures. try testing.expect(isConfigFault(error.ParseZon)); try testing.expect(isConfigFault(error.MissingDefaultGroup)); try testing.expect(isConfigFault(error.NoUpstreams)); } test "a runtime failure is not a configuration fault" { try testing.expect(!isConfigFault(error.OutOfMemory)); try testing.expect(!isConfigFault(error.AccessDenied)); try testing.expect(!isConfigFault(error.FileNotFound)); try testing.expect(!isConfigFault(error.AddressInUse)); // A verdict on the diff, not on the file: `import` refuses a run that would // delete rows and decides that exit code itself. try testing.expect(!isConfigFault(error.DestructiveImport)); // Only ever a warning, so it never reaches an exit code by this route. try testing.expect(!isConfigFault(error.SourceInNoGroup)); }