orama/vault/src/config.zig

217 lines
8.0 KiB
Zig

/// Configuration loading for vault-guardian.
/// Reads a simple key=value config file. Lines starting with '#' are comments.
const std = @import("std");
pub const Config = struct {
/// Address to bind client-facing server
listen_address: []const u8 = "0.0.0.0",
/// Client-facing port (TLS in production, plain TCP for MVP)
client_port: u16 = 7500,
/// Guardian-to-guardian port (WireGuard-only interface)
peer_port: u16 = 7501,
/// Data storage directory
data_dir: []const u8 = "/opt/orama/.orama/data/vault",
/// RQLite endpoint for node discovery
rqlite_url: []const u8 = "http://127.0.0.1:4001",
/// Arena allocator that owns duped strings (null when using defaults)
_arena: ?std.heap.ArenaAllocator = null,
/// Free all allocated memory.
pub fn deinit(self: *Config) void {
if (self._arena) |*arena| {
arena.deinit();
}
self.* = undefined;
}
};
pub const ConfigError = error{
InvalidPort,
LineTooLong,
};
/// Loads config from file, or returns defaults if file doesn't exist.
pub fn loadOrDefault(allocator: std.mem.Allocator, path: []const u8) !Config {
// Read whole file into memory (config files are small)
const contents = std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024) catch |err| {
if (err == error.FileNotFound) {
return Config{};
}
return err;
};
defer allocator.free(contents);
var cfg = Config{};
var arena = std.heap.ArenaAllocator.init(allocator);
errdefer arena.deinit();
const arena_alloc = arena.allocator();
var line_iter = std.mem.splitScalar(u8, contents, '\n');
while (line_iter.next()) |raw_line| {
// Strip trailing \r for Windows line endings
const line_trimmed = if (raw_line.len > 0 and raw_line[raw_line.len - 1] == '\r')
raw_line[0 .. raw_line.len - 1]
else
raw_line;
const line = std.mem.trim(u8, line_trimmed, " \t");
// Skip empty lines and comments
if (line.len == 0) continue;
if (line[0] == '#') continue;
// Find '='
const eq_idx = std.mem.indexOfScalar(u8, line, '=') orelse continue;
const key = std.mem.trim(u8, line[0..eq_idx], " \t");
const value = std.mem.trim(u8, line[eq_idx + 1 ..], " \t");
if (std.mem.eql(u8, key, "listen_address")) {
cfg.listen_address = try arena_alloc.dupe(u8, value);
} else if (std.mem.eql(u8, key, "client_port")) {
cfg.client_port = std.fmt.parseInt(u16, value, 10) catch return ConfigError.InvalidPort;
} else if (std.mem.eql(u8, key, "peer_port")) {
cfg.peer_port = std.fmt.parseInt(u16, value, 10) catch return ConfigError.InvalidPort;
} else if (std.mem.eql(u8, key, "data_dir")) {
cfg.data_dir = try arena_alloc.dupe(u8, value);
} else if (std.mem.eql(u8, key, "rqlite_url")) {
cfg.rqlite_url = try arena_alloc.dupe(u8, value);
}
// Unknown keys are silently ignored
}
cfg._arena = arena;
return cfg;
}
// ── Tests ────────────────────────────────────────────────────────────────────
test "config: defaults when file not found" {
var cfg = try loadOrDefault(std.testing.allocator, "/tmp/nonexistent-vault-config-file-xyz");
defer cfg.deinit();
try std.testing.expectEqualSlices(u8, "0.0.0.0", cfg.listen_address);
try std.testing.expectEqual(@as(u16, 7500), cfg.client_port);
try std.testing.expectEqual(@as(u16, 7501), cfg.peer_port);
try std.testing.expectEqualSlices(u8, "/opt/orama/.orama/data/vault", cfg.data_dir);
try std.testing.expectEqualSlices(u8, "http://127.0.0.1:4001", cfg.rqlite_url);
}
test "config: parse key=value file" {
// Write a temp config file
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
const file = try tmp_dir.dir.createFile("test.conf", .{});
try file.writeAll(
\\# This is a comment
\\listen_address = 127.0.0.1
\\client_port = 8080
\\peer_port = 8081
\\data_dir = /tmp/vault-test
\\rqlite_url = http://10.0.0.1:4001
\\
\\# Another comment
\\unknown_key = ignored
\\
);
file.close();
// Get the full path
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
var full_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = try std.fmt.bufPrint(&full_path_buf, "{s}/test.conf", .{dir_path});
var cfg = try loadOrDefault(std.testing.allocator, full_path);
defer cfg.deinit();
try std.testing.expectEqualSlices(u8, "127.0.0.1", cfg.listen_address);
try std.testing.expectEqual(@as(u16, 8080), cfg.client_port);
try std.testing.expectEqual(@as(u16, 8081), cfg.peer_port);
try std.testing.expectEqualSlices(u8, "/tmp/vault-test", cfg.data_dir);
try std.testing.expectEqualSlices(u8, "http://10.0.0.1:4001", cfg.rqlite_url);
}
test "config: partial config uses defaults for missing keys" {
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
const file = try tmp_dir.dir.createFile("partial.conf", .{});
try file.writeAll("client_port = 9000\n");
file.close();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
var full_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = try std.fmt.bufPrint(&full_path_buf, "{s}/partial.conf", .{dir_path});
var cfg = try loadOrDefault(std.testing.allocator, full_path);
defer cfg.deinit();
try std.testing.expectEqual(@as(u16, 9000), cfg.client_port);
// Defaults for everything else
try std.testing.expectEqualSlices(u8, "0.0.0.0", cfg.listen_address);
try std.testing.expectEqual(@as(u16, 7501), cfg.peer_port);
}
test "config: invalid port returns error" {
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
const file = try tmp_dir.dir.createFile("bad.conf", .{});
try file.writeAll("client_port = not_a_number\n");
file.close();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
var full_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = try std.fmt.bufPrint(&full_path_buf, "{s}/bad.conf", .{dir_path});
const result = loadOrDefault(std.testing.allocator, full_path);
try std.testing.expectError(ConfigError.InvalidPort, result);
}
test "config: empty file returns defaults" {
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
const file = try tmp_dir.dir.createFile("empty.conf", .{});
file.close();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
var full_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = try std.fmt.bufPrint(&full_path_buf, "{s}/empty.conf", .{dir_path});
var cfg = try loadOrDefault(std.testing.allocator, full_path);
defer cfg.deinit();
try std.testing.expectEqual(@as(u16, 7500), cfg.client_port);
}
test "config: comments and blank lines are skipped" {
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
const file = try tmp_dir.dir.createFile("comments.conf", .{});
try file.writeAll(
\\# Full line comment
\\
\\ # Indented comment
\\client_port = 1234
\\
);
file.close();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
var full_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = try std.fmt.bufPrint(&full_path_buf, "{s}/comments.conf", .{dir_path});
var cfg = try loadOrDefault(std.testing.allocator, full_path);
defer cfg.deinit();
try std.testing.expectEqual(@as(u16, 1234), cfg.client_port);
}