a73x

f8bcbbcb

feat: xdg module — default key/log paths and key creation

a73x   2026-08-09 11:46

Commit message
feat: xdg module — default key/log paths and key creation

build.zig
Old New
@@ -115,6 +115,15 @@ pub fn build(b: *std.Build) void {
115 .optimize = optimize, 115 .optimize = optimize,
116 }); 116 });
117 117
118 // XDG-derived paths (key file, daemon log), shared by both binaries.
119 const xdg_mod = b.createModule(.{
120 .root_source_file = b.path("src/xdg.zig"),
121 .target = target,
122 .optimize = optimize,
123 .link_libc = true,
124 });
125 xdg_mod.addImport("testtmp", testtmp_mod);
126
118 const server_mod = b.createModule(.{ 127 const server_mod = b.createModule(.{
119 .root_source_file = b.path("src/server.zig"), 128 .root_source_file = b.path("src/server.zig"),
120 .target = target, 129 .target = target,
@@ -161,6 +170,7 @@ pub fn build(b: *std.Build) void {
161 }); 170 });
162 mux_mod.addImport("client", client_mod); 171 mux_mod.addImport("client", client_mod);
163 mux_mod.addImport("build_options", version_opts.createModule()); 172 mux_mod.addImport("build_options", version_opts.createModule());
173 mux_mod.addImport("xdg", xdg_mod);
164 174
165 // No imports that teach it anything, deliberately: the proxy is a byte 175 // No imports that teach it anything, deliberately: the proxy is a byte
166 // pump that knows nothing about the protocol it carries. `testtmp` is 176 // pump that knows nothing about the protocol it carries. `testtmp` is
@@ -203,6 +213,7 @@ pub fn build(b: *std.Build) void {
203 // needs the module directly rather than through the server. 213 // needs the module directly rather than through the server.
204 exe_mod.addImport("quic", quic_mod); 214 exe_mod.addImport("quic", quic_mod);
205 exe_mod.addImport("build_options", version_opts.createModule()); 215 exe_mod.addImport("build_options", version_opts.createModule());
216 exe_mod.addImport("xdg", xdg_mod);
206 217
207 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); 218 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
208 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe 219 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe
@@ -237,7 +248,7 @@ pub fn build(b: *std.Build) void {
237 // absence here was a live hazard recorded in decisions.md — muxd's 248 // absence here was a live hazard recorded in decisions.md — muxd's
238 // entrypoint could grow tests that silently never ran, exactly as 249 // entrypoint could grow tests that silently never ran, exactly as
239 // mux_main.zig's five did before it was added. 250 // mux_main.zig's five did before it was added.
240 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod }) |mod| { 251 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod }) |mod| {
241 const t = b.addTest(.{ .root_module = mod }); 252 const t = b.addTest(.{ .root_module = mod });
242 t.use_llvm = true; 253 t.use_llvm = true;
243 t.use_lld = true; 254 t.use_lld = true;
docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -202,7 +202,7 @@ git commit -m "feat: --version on both binaries, one source in build.zig"
202 - Create: `src/xdg.zig` 202 - Create: `src/xdg.zig`
203 - Modify: `build.zig` (module creation ~line 107 region; **test loop ~line 233**; imports for `exe_mod` and `mux_mod`) 203 - Modify: `build.zig` (module creation ~line 107 region; **test loop ~line 233**; imports for `exe_mod` and `mux_mod`)
204 204
205 - [ ] **Step 1: Create `src/xdg.zig` with tests included** 205 - [x] **Step 1: Create `src/xdg.zig` with tests included**
206 206
207 ```zig 207 ```zig
208 //! XDG-derived paths shared by both binaries, plus key file creation. 208 //! XDG-derived paths shared by both binaries, plus key file creation.
@@ -327,7 +327,7 @@ test "writeNewKey: creates 0600 with 32 bytes, refuses to overwrite" {
327 } 327 }
328 ``` 328 ```
329 329
330 - [ ] **Step 2: Wire the module in build.zig** 330 - [x] **Step 2: Wire the module in build.zig**
331 331
332 After the `testtmp_mod` block (~line 111): 332 After the `testtmp_mod` block (~line 111):
333 333
@@ -355,12 +355,12 @@ Add imports:
355 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod }) |mod| { 355 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod }) |mod| {
356 ``` 356 ```
357 357
358 - [ ] **Step 3: Run tests, expect the three new tests to run and pass** 358 - [x] **Step 3: Run tests, expect the three new tests to run and pass**
359 359
360 Run: `make test 2>&1 | tail -20` 360 Run: `make test 2>&1 | tail -20`
361 Expected: pass. Confirm the tests actually ran: temporarily change `"{s}/mux/key"` to `"{s}/mux/kee"`; `make test` MUST fail in `keyPathFrom`. Restore. (This is the test-loop-omission mutation — it proves the loop registration, not just the test.) 361 Expected: pass. Confirm the tests actually ran: temporarily change `"{s}/mux/key"` to `"{s}/mux/kee"`; `make test` MUST fail in `keyPathFrom`. Restore. (This is the test-loop-omission mutation — it proves the loop registration, not just the test.)
362 362
363 - [ ] **Step 4: Commit** 363 - [x] **Step 4: Commit**
364 364
365 ```bash 365 ```bash
366 git add src/xdg.zig build.zig 366 git add src/xdg.zig build.zig
src/xdg.zig
Old New
@@ -0,0 +1,120 @@
1 //! XDG-derived paths shared by both binaries, plus key file creation.
2 //!
3 //! The `*From` variants are pure — environment handed in, nothing read —
4 //! because that is what makes them testable without setenv, which Zig
5 //! tests cannot safely do in-process. The un-suffixed wrappers read the
6 //! real environment and are one line each, thin enough to trust by
7 //! inspection.
8 const std = @import("std");
9
10 /// `$XDG_CONFIG_HOME/mux/key`, defaulting to `~/.config/mux/key`.
11 /// The one place the default key location is spelled; muxd keygen writes
12 /// it and both binaries' key resolution reads it.
13 pub fn keyPath(alloc: std.mem.Allocator) ![]const u8 {
14 return keyPathFrom(alloc, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"));
15 }
16
17 pub fn keyPathFrom(
18 alloc: std.mem.Allocator,
19 xdg_config_home: ?[]const u8,
20 home: ?[]const u8,
21 ) ![]const u8 {
22 if (xdg_config_home) |d| if (d.len > 0)
23 return std.fmt.allocPrint(alloc, "{s}/mux/key", .{d});
24 const h = home orelse return error.NoHome;
25 return std.fmt.allocPrint(alloc, "{s}/.config/mux/key", .{h});
26 }
27
28 /// `$XDG_STATE_HOME/mux/muxd.log`, defaulting to `~/.local/state/mux/muxd.log`.
29 /// Truncated at each spawn by the spawner: it holds the current daemon's
30 /// stdout+stderr, not history.
31 pub fn logPath(alloc: std.mem.Allocator) ![]const u8 {
32 return logPathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME"));
33 }
34
35 pub fn logPathFrom(
36 alloc: std.mem.Allocator,
37 xdg_state_home: ?[]const u8,
38 home: ?[]const u8,
39 ) ![]const u8 {
40 if (xdg_state_home) |d| if (d.len > 0)
41 return std.fmt.allocPrint(alloc, "{s}/mux/muxd.log", .{d});
42 const h = home orelse return error.NoHome;
43 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h});
44 }
45
46 /// 32 random bytes at `path`, mode 0600, parent directories created.
47 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both
48 /// counts, so overwriting silently would delete a credential.
49 pub fn writeNewKey(path: []const u8) !void {
50 if (std.fs.path.dirname(path)) |dir| try std.fs.cwd().makePath(dir);
51 const f = std.fs.cwd().createFile(path, .{
52 .exclusive = true,
53 .mode = 0o600,
54 }) catch |err| switch (err) {
55 error.PathAlreadyExists => return error.KeyExists,
56 else => |e| return e,
57 };
58 defer f.close();
59 var key: [32]u8 = undefined;
60 std.crypto.random.bytes(&key);
61 try f.writeAll(&key);
62 }
63
64 test "keyPathFrom: XDG_CONFIG_HOME wins, HOME is the fallback, empty is unset" {
65 const a = std.testing.allocator;
66 const explicit = try keyPathFrom(a, "/tmp/cfg", "/home/u");
67 defer a.free(explicit);
68 try std.testing.expectEqualStrings("/tmp/cfg/mux/key", explicit);
69
70 const fallback = try keyPathFrom(a, null, "/home/u");
71 defer a.free(fallback);
72 try std.testing.expectEqualStrings("/home/u/.config/mux/key", fallback);
73
74 // Empty XDG var means unset, per the basedir spec.
75 const empty = try keyPathFrom(a, "", "/home/u");
76 defer a.free(empty);
77 try std.testing.expectEqualStrings("/home/u/.config/mux/key", empty);
78
79 try std.testing.expectError(error.NoHome, keyPathFrom(a, null, null));
80 }
81
82 test "logPathFrom: same shape against XDG_STATE_HOME" {
83 const a = std.testing.allocator;
84 const explicit = try logPathFrom(a, "/tmp/state", "/home/u");
85 defer a.free(explicit);
86 try std.testing.expectEqualStrings("/tmp/state/mux/muxd.log", explicit);
87
88 const fallback = try logPathFrom(a, null, "/home/u");
89 defer a.free(fallback);
90 try std.testing.expectEqualStrings("/home/u/.local/state/mux/muxd.log", fallback);
91 }
92
93 test "writeNewKey: creates 0600 with 32 bytes, refuses to overwrite" {
94 const testtmp = @import("testtmp");
95 var tmp = try testtmp.TmpDir.make();
96 defer tmp.cleanup();
97
98 var buf: [128]u8 = undefined;
99 const path = try std.fmt.bufPrint(&buf, "{s}/sub/key", .{tmp.path()});
100
101 try writeNewKey(path);
102
103 const st = try std.fs.cwd().statFile(path);
104 try std.testing.expectEqual(@as(u64, 32), st.size);
105 // mode() carries type bits; mask to permissions.
106 const f = try std.fs.cwd().openFile(path, .{});
107 defer f.close();
108 const fst = try f.stat();
109 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
110
111 var first: [32]u8 = undefined;
112 try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&first, 0));
113
114 // Refusal leaves the file byte-identical: a credential is never
115 // silently replaced.
116 try std.testing.expectError(error.KeyExists, writeNewKey(path));
117 var second: [32]u8 = undefined;
118 try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&second, 0));
119 try std.testing.expectEqualSlices(u8, &first, &second);
120 }