milestone 20: declarative configuration for iac
This commit is contained in:
+76
-11
@@ -87,10 +87,16 @@ pub const Web = struct {
|
||||
enabled: bool = true,
|
||||
bind: []const u8 = "0.0.0.0",
|
||||
port: u16 = 8080,
|
||||
/// Operator input only. Never a settings row, always exported as "".
|
||||
password: []const u8 = "",
|
||||
/// argon2id PHC string; "" disables authentication.
|
||||
password_hash: []const u8 = "",
|
||||
/// Operator input only. Never a settings row, never exported.
|
||||
///
|
||||
/// Optional because absence and emptiness are different declarations: null
|
||||
/// means "the file says nothing about the password, keep the stored hash",
|
||||
/// while a present value is an instruction to set one.
|
||||
password: ?[]const u8 = null,
|
||||
/// argon2id PHC string. Null means "the file says nothing, keep what is
|
||||
/// stored"; an explicit `""` is the documented way to disable
|
||||
/// authentication.
|
||||
password_hash: ?[]const u8 = null,
|
||||
session_ttl_hours: u16 = 24,
|
||||
api_rate_limit_per_min: u32 = 300,
|
||||
/// Requests from the box itself skip the API rate limit. On by default: a
|
||||
@@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
/// `web.password` is operator input, never a settings row: it is hashed into
|
||||
/// `web.password_hash` at import time and discarded (S2.5).
|
||||
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
/// The skip policy splits by direction, because encode and decode need
|
||||
/// different sets.
|
||||
///
|
||||
/// `web.password` is operator input and is skipped both ways: it is hashed into
|
||||
/// `web.password_hash` and discarded (S2.5).
|
||||
///
|
||||
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
|
||||
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
|
||||
/// "keep the stored hash", which a general encode pass cannot express. Skipping
|
||||
/// it on decode as well would leave `cfg.web.password_hash` null on every read
|
||||
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
|
||||
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
if (!std.mem.eql(u8, section, "web")) return false;
|
||||
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
|
||||
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
@@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
|
||||
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
|
||||
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
|
||||
.pointer => text,
|
||||
// A stored key is a present value, so an optional field decodes to a
|
||||
// non-null one; the null stays reserved for the absent key, which never
|
||||
// reaches this function at all.
|
||||
.optional => |info| try decodeValue(info.child, text),
|
||||
else => @compileError("unsupported setting field type " ++ @typeName(T)),
|
||||
};
|
||||
}
|
||||
@@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair))
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
const section = @field(cfg, section_field.name);
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
|
||||
const value = try encodeValue(field.type, @field(section, field.name), gpa);
|
||||
errdefer gpa.free(value);
|
||||
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
|
||||
@@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi
|
||||
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isDecodeSkipped(section_field.name, field.name)) {
|
||||
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
|
||||
@field(@field(cfg, section_field.name), field.name) =
|
||||
try decodeValue(field.type, pair.value);
|
||||
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.bind",
|
||||
"web.enabled",
|
||||
"web.password_hash",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.sse_max_connections_per_ip",
|
||||
@@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
|
||||
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
|
||||
if (comptime isScalarSection(section_field.type)) {
|
||||
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (comptime !isSkipped(section_field.name, field.name)) {
|
||||
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
|
||||
const a = @field(@field(original, section_field.name), field.name);
|
||||
const b = @field(@field(restored, section_field.name), field.name);
|
||||
if (comptime @typeInfo(field.type) == .pointer) {
|
||||
@@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" {
|
||||
try testing.expectEqual(@as(usize, 2), unknown);
|
||||
}
|
||||
|
||||
test "web.password_hash decodes from the settings table but is never encoded" {
|
||||
const gpa = testing.allocator;
|
||||
var pairs: std.ArrayList(SettingPair) = .empty;
|
||||
defer {
|
||||
freeSettings(gpa, pairs.items);
|
||||
pairs.deinit(gpa);
|
||||
}
|
||||
|
||||
// Encode: the reconciler owns that row, so no pass over the model emits it.
|
||||
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
|
||||
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
|
||||
for (pairs.items) |pair| {
|
||||
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
|
||||
}
|
||||
|
||||
// Decode: every read path still sees the stored hash, or `authEnabled`
|
||||
// would read false on a box that has a password set.
|
||||
var cfg: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
|
||||
try fromSettings(&stored, &cfg, &unknown);
|
||||
try testing.expectEqual(@as(usize, 0), unknown);
|
||||
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
|
||||
}
|
||||
|
||||
test "an optional settings field is null when absent and non-null when present" {
|
||||
var absent: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
|
||||
try fromSettings(&other, &absent, &unknown);
|
||||
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
|
||||
|
||||
// An explicit empty string is a present value, not an absent key: it is how
|
||||
// a config file disables authentication.
|
||||
var empty: Config = .{};
|
||||
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
|
||||
try fromSettings(&disabled, &empty, &unknown);
|
||||
try testing.expect(empty.web.password_hash != null);
|
||||
try testing.expectEqualStrings("", empty.web.password_hash.?);
|
||||
}
|
||||
|
||||
test "a malformed settings value is BadSettingValue" {
|
||||
var cfg: Config = .{};
|
||||
var unknown: usize = 0;
|
||||
|
||||
Reference in New Issue
Block a user