f8bcbbcb
feat: xdg module — default key/log paths and key creation
a73x 2026-08-09 11:46
Commit message
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 | } | ||