milestone 20: declarative configuration for iac

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 2f29121e27
commit d76afc147a
74 changed files with 6722 additions and 1949 deletions
+56 -2
View File
@@ -101,6 +101,7 @@ pub const ValidateError = error{
MissingKeyPath,
MissingLogPath,
PasswordAndHashBothSet,
EmptyWebPassword,
};
/// What `validate` returns: a verdict on the configuration, or the allocation
@@ -114,7 +115,19 @@ pub const Error = ValidateError || Allocator.Error;
/// same channel so a syntax error's line/column reaches the operator's output,
/// plus the warnings — which are never returned by `validate` and so are not
/// `ValidateError` members.
pub const ProblemError = ValidateError || error{ ParseZon, SourceInNoGroup };
/// Wider than `ValidateError`: the diagnostic channel also carries the problems
/// found before the validator ever sees a `Config` — the ZON parse, the managed
/// file that would not open (`config/loader.zig`), the file above the size limit
/// — and the one found after it, an import whose diff would delete rows. The
/// validator itself records only `ValidateError` members, which is what makes
/// `validate`'s `@errorCast` of its own findings checked-safe.
pub const ProblemError = ValidateError || error{
ParseZon,
SourceInNoGroup,
ManagedConfigUnreadable,
ConfigTooLarge,
DestructiveImport,
};
/// `.fail` rejects the configuration and is what an exit code is computed from.
/// `.warn` reports something legal that is almost certainly not what the
@@ -392,7 +405,10 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
try checkBind(diags, cfg.web.bind, "web.bind", .any);
try checkPort(diags, cfg.web.port, "web.port");
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
// Both fields are optional, and absence is the third state: a file that
// states neither keeps the stored hash. So the test is on presence, not on
// length.
if (cfg.web.password != null and cfg.web.password_hash != null) {
try diags.add(
error.PasswordAndHashBothSet,
"web.password",
@@ -401,6 +417,22 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
.{},
);
}
// A present-but-empty password would hash the empty string into a non-empty
// PHC — authentication on — while every login with an empty password is
// refused: authentication on and unreachable. The remedy is named, because
// the operator who wrote this meant one of two other things.
if (cfg.web.password) |password| {
if (password.len == 0) {
try diags.add(
error.EmptyWebPassword,
"web.password",
.{},
"password is set to the empty string; omit the field to keep the stored password, " ++
"or set password_hash = \"\" to disable authentication",
.{},
);
}
}
// A session TTL is a TTL; `BadTtl` is its bucket.
if (cfg.web.session_ttl_hours < 1) {
try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{});
@@ -1968,6 +2000,28 @@ test "error.PasswordAndHashBothSet" {
try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password");
}
test "error.EmptyWebPassword names password_hash as the way to disable auth" {
var cfg = baseConfig();
cfg.web.password = "";
try expectProblem(cfg, error.EmptyWebPassword, "web.password");
// The remedy has to be in the text: the operator who wrote `password = ""`
// meant either "keep the current one" or "turn authentication off", and the
// diagnostic is the only place that distinction is spelled out.
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.EmptyWebPassword, validate(cfg, &diags));
try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "password_hash = \"\"") != null);
// Absence is not emptiness: a file that states no password is legal and
// means "keep the stored hash".
var absent = baseConfig();
absent.web.password = null;
var quiet: Diagnostics = .init(testing.allocator);
defer quiet.deinit();
try validate(absent, &quiet);
}
test "a config with five distinct problems yields five diagnostics and the first error" {
var cfg = baseConfig();
cfg.dns.port = 0; // BadPort, first in check order