main zmx / src / cfg.zig
Eric Bower  ·  2026-10-23
  1/// Cfg is zmx's configuration container.
  2///
  3/// The purpose of this container is to hold anything that can be modified by the user.
  4pub const Cfg = @This();
  5
  6const std = @import("std");
  7const lib_posix = @import("posix.zig");
  8const cross = @import("cross");
  9
 10socket_dir: []const u8,
 11log_dir: []const u8,
 12max_scrollback_lines: usize = 10_000,
 13dir_mode: u32 = 0o750,
 14log_mode: u32 = 0o640,
 15tracked_envs: []const u8 = "DISPLAY,SSH_AUTH_SOCK,SSH_AGENT_PID,SSH_CONNECTION,WINDOWID,XAUTHORITY,KITTY_LISTEN_ON,KITTY_PID,KITTY_WINDOW_ID",
 16
 17pub fn init(alloc: std.mem.Allocator, io: std.Io) !Cfg {
 18    const socket_dir = try socketDir(alloc);
 19    errdefer alloc.free(socket_dir);
 20    const log_dir = try logDir(alloc);
 21    errdefer alloc.free(log_dir);
 22
 23    const dir_mode = if (lib_posix.getenv("ZMX_DIR_MODE")) |m|
 24        std.fmt.parseInt(u32, m, 8) catch 0o750
 25    else
 26        0o750;
 27
 28    const log_mode = if (lib_posix.getenv("ZMX_LOG_MODE")) |m|
 29        std.fmt.parseInt(u32, m, 8) catch 0o640
 30    else
 31        0o640;
 32
 33    var cfg = Cfg{
 34        .socket_dir = socket_dir,
 35        .log_dir = log_dir,
 36        .dir_mode = dir_mode,
 37        .log_mode = log_mode,
 38    };
 39
 40    try cfg.mkdir(io);
 41
 42    return cfg;
 43}
 44
 45fn socketDir(alloc: std.mem.Allocator) ![]const u8 {
 46    const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
 47    const uid = lib_posix.getuid();
 48
 49    const socket_dir: []const u8 = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
 50        try alloc.dupe(u8, zmxdir)
 51    else if (lib_posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
 52        try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
 53    else
 54        try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
 55
 56    return socket_dir;
 57}
 58
 59fn logDir(alloc: std.mem.Allocator) ![]const u8 {
 60    const log_dir = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
 61        try std.fmt.allocPrint(alloc, "{s}/logs", .{zmxdir})
 62    else if (lib_posix.getenv("XDG_STATE_HOME")) |xdg_state_home|
 63        try std.fmt.allocPrint(alloc, "{s}/zmx/logs", .{xdg_state_home})
 64    else if (lib_posix.getenv("HOME")) |home_dir|
 65        try std.fmt.allocPrint(alloc, "{s}/.local/state/zmx/logs", .{home_dir})
 66    else fallback: {
 67        // This is the last resort: falling back to /tmp/$UID if HOME is unset.
 68        const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
 69        const uid = lib_posix.getuid();
 70        break :fallback try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
 71    };
 72
 73    return log_dir;
 74}
 75
 76pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void {
 77    if (self.socket_dir.len > 0) alloc.free(self.socket_dir);
 78    if (self.log_dir.len > 0) alloc.free(self.log_dir);
 79}
 80
 81pub fn mkdir(self: *Cfg, io: std.Io) !void {
 82    const sock_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
 83    try mkdirAll(io, self.socket_dir, sock_perms);
 84    const log_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
 85    try mkdirAll(io, self.log_dir, log_perms);
 86}
 87
 88fn mkdirAll(io: std.Io, sub_dir_path: []const u8, permissions: std.Io.Dir.Permissions) !void {
 89    var it = std.fs.path.componentIterator(sub_dir_path);
 90    var component = it.last() orelse return error.BadPathName;
 91    while (true) {
 92        std.Io.Dir.createDirAbsolute(io, component.path, permissions) catch |err| switch (err) {
 93            error.PathAlreadyExists => {
 94                // Dangling symlinks return PathAlreadyExists on mkdir but fail on traversal,
 95                // causing an infinite loop between previous() and next().
 96                const stat = std.Io.Dir.cwd().statFile(io, component.path, .{}) catch |e| switch (e) {
 97                    error.FileNotFound => return error.NotDir,
 98                    else => |stat_err| return stat_err,
 99                };
100                if (stat.kind != .directory) return error.NotDir;
101            },
102            error.FileNotFound => |e| {
103                component = it.previous() orelse return e;
104                continue;
105            },
106            else => |e| return e,
107        };
108        component = it.next() orelse return;
109    }
110}
111
112test "Cfg.init uses default modes when env vars are not set" {
113    const alloc = std.testing.allocator;
114
115    // Ensure they are not set
116    _ = cross.unsetenv("ZMX_DIR_MODE");
117    _ = cross.unsetenv("ZMX_LOG_MODE");
118
119    var cfg = try Cfg.init(alloc, std.testing.io);
120    defer cfg.deinit(alloc);
121
122    try std.testing.expectEqual(@as(u32, 0o750), cfg.dir_mode);
123    try std.testing.expectEqual(@as(u32, 0o640), cfg.log_mode);
124}
125
126test "Cfg.init uses custom modes from env vars" {
127    const alloc = std.testing.allocator;
128
129    // Set custom octal values
130    _ = cross.setenv("ZMX_DIR_MODE", "770", 1);
131    _ = cross.setenv("ZMX_LOG_MODE", "660", 1);
132    defer {
133        _ = cross.unsetenv("ZMX_DIR_MODE");
134        _ = cross.unsetenv("ZMX_LOG_MODE");
135    }
136
137    var cfg = try Cfg.init(alloc, std.testing.io);
138    defer cfg.deinit(alloc);
139
140    try std.testing.expectEqual(@as(u32, 0o770), cfg.dir_mode);
141    try std.testing.expectEqual(@as(u32, 0o660), cfg.log_mode);
142}
143
144test "mkdirAll fails on dangling symlink instead of looping" {
145    var tmp = std.testing.tmpDir(.{});
146    defer tmp.cleanup();
147
148    const alloc = std.testing.allocator;
149    // Dir.realPathFile is unsupported on OpenBSD, so build the absolute path
150    // from the cwd instead.
151    const cwd_path = try std.process.currentPathAlloc(std.testing.io, alloc);
152    defer alloc.free(cwd_path);
153    const tmp_path = try std.fs.path.join(alloc, &.{ cwd_path, ".zig-cache", "tmp", &tmp.sub_path });
154    defer alloc.free(tmp_path);
155
156    try tmp.dir.symLink(std.testing.io, "nonexistent", "broken_link", .{});
157
158    const target_path = try std.fs.path.join(alloc, &.{ tmp_path, "broken_link", "sub" });
159    defer alloc.free(target_path);
160
161    const perms = std.Io.Dir.Permissions.fromMode(0o750);
162    try std.testing.expectError(error.NotDir, mkdirAll(std.testing.io, target_path, perms));
163}