a73x

b6e51fdd

feat: handoff vocabulary — announce line, per-host cache, dial-host strip

a73x   2026-08-11 12:01

Commit message
feat: handoff vocabulary — announce line, per-host cache, dial-host strip

src/handoff.zig: the ssh→QUIC handoff's shared vocabulary, pure by
design. formatAnnounce/parseAnnounce spell one grammar used by two
readers — the ssh pipe and the cache file, which stores the exact
announce line. readLine reads a byte at a time because the frame stream
starts at the very next byte. writeCache/readCache hold the cache to the
key file's discipline (0600 in a 0700 directory, permissive files
refused before they are read), but overwrite, because a cache is the
latest truth rather than a credential.

xdg.hostCachePath{,From}: $XDG_CACHE_HOME/mux/hosts/<host>, with a host
containing a path separator refused rather than silently naming a
different file.

build.zig wires the module into muxd, mux, the client, and the test
loop, so Tasks 5 and 6 touch it zero times.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -135,6 +135,18 @@ pub fn build(b: *std.Build) void {
135 spawn_mod.addImport("xdg", xdg_mod); 135 spawn_mod.addImport("xdg", xdg_mod);
136 spawn_mod.addImport("testtmp", testtmp_mod); 136 spawn_mod.addImport("testtmp", testtmp_mod);
137 137
138 // The ssh→QUIC handoff's shared vocabulary: the announce line, the
139 // per-host cache, the dial-host strip. Both binaries import it —
140 // `muxd endpoint` writes the line, `mux HOST` reads it — and it is
141 // pure enough to need nothing but testtmp for its cache tests.
142 const handoff_mod = b.createModule(.{
143 .root_source_file = b.path("src/handoff.zig"),
144 .target = target,
145 .optimize = optimize,
146 .link_libc = true,
147 });
148 handoff_mod.addImport("testtmp", testtmp_mod);
149
138 const server_mod = b.createModule(.{ 150 const server_mod = b.createModule(.{
139 .root_source_file = b.path("src/server.zig"), 151 .root_source_file = b.path("src/server.zig"),
140 .target = target, 152 .target = target,
@@ -175,6 +187,7 @@ pub fn build(b: *std.Build) void {
175 // The client is the only thing that predicts: the overlay is a local 187 // The client is the only thing that predicts: the overlay is a local
176 // display decision and never becomes state anybody else can see. 188 // display decision and never becomes state anybody else can see.
177 client_mod.addImport("predict", predict_mod); 189 client_mod.addImport("predict", predict_mod);
190 client_mod.addImport("handoff", handoff_mod);
178 191
179 const mux_mod = b.createModule(.{ 192 const mux_mod = b.createModule(.{
180 .root_source_file = b.path("src/mux_main.zig"), 193 .root_source_file = b.path("src/mux_main.zig"),
@@ -186,6 +199,7 @@ pub fn build(b: *std.Build) void {
186 mux_mod.addImport("build_options", version_opts.createModule()); 199 mux_mod.addImport("build_options", version_opts.createModule());
187 mux_mod.addImport("xdg", xdg_mod); 200 mux_mod.addImport("xdg", xdg_mod);
188 mux_mod.addImport("spawn", spawn_mod); 201 mux_mod.addImport("spawn", spawn_mod);
202 mux_mod.addImport("handoff", handoff_mod);
189 203
190 // No imports that teach it anything, deliberately: the proxy is a byte 204 // No imports that teach it anything, deliberately: the proxy is a byte
191 // pump that knows nothing about the protocol it carries. `testtmp` is 205 // pump that knows nothing about the protocol it carries. `testtmp` is
@@ -255,6 +269,8 @@ pub fn build(b: *std.Build) void {
255 // daemon itself never touches this. 269 // daemon itself never touches this.
256 exe_mod.addImport("testtmp", testtmp_mod); 270 exe_mod.addImport("testtmp", testtmp_mod);
257 exe_mod.addImport("spawn", spawn_mod); 271 exe_mod.addImport("spawn", spawn_mod);
272 // `muxd endpoint` prints the announce line this module spells.
273 exe_mod.addImport("handoff", handoff_mod);
258 274
259 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); 275 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
260 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe 276 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe
@@ -299,7 +315,7 @@ pub fn build(b: *std.Build) void {
299 // absence here was a live hazard recorded in decisions.md — muxd's 315 // absence here was a live hazard recorded in decisions.md — muxd's
300 // entrypoint could grow tests that silently never ran, exactly as 316 // entrypoint could grow tests that silently never ran, exactly as
301 // mux_main.zig's five did before it was added. 317 // mux_main.zig's five did before it was added.
302 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, spawn_mod, render_mod, ptyclient_mod }) |mod| { 318 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, spawn_mod, handoff_mod, render_mod, ptyclient_mod }) |mod| {
303 const t = b.addTest(.{ .root_module = mod }); 319 const t = b.addTest(.{ .root_module = mod });
304 t.use_llvm = true; 320 t.use_llvm = true;
305 t.use_lld = true; 321 t.use_lld = true;
src/handoff.zig
Old New
@@ -0,0 +1,394 @@
1 //! The ssh→QUIC handoff's shared vocabulary: the announce line `muxd
2 //! endpoint` prints and `mux` parses, the per-host cache file that
3 //! remembers it, and the strip that turns an ssh destination into a
4 //! dialable host. Pure by design — no sockets, no processes — so the
5 //! whole surface tests without a daemon.
6 const std = @import("std");
7
8 /// The QUIC attach budget for one attempt, warm path and cold path
9 /// alike. Provisional: Component 4 of the M14 design measures what a
10 /// wrong PSK and a blackholed UDP port actually cost the client, and
11 /// this number is re-pinned from that evidence (decisions.md, M14).
12 pub const deadline_ms: u32 = 2000;
13
14 /// Where a `muxd endpoint` announce says its listener lives, and the key
15 /// that authenticates to it.
16 pub const Endpoint = struct {
17 port: u16,
18 key: [32]u8,
19 };
20
21 /// The announce for "no coordinates" — no usable key, no bind, no reply.
22 /// An explicit negative rather than silence: the daemon side sends
23 /// nothing unprompted, so a client waiting for a line that is never
24 /// coming cannot tell that from a slow ssh.
25 pub const announce_none = "endpoint none\n";
26
27 /// The longest line `formatAnnounce` can produce: `endpoint ` + 5 digits
28 /// + ` ` + 64 hex + `\n`. Callers size their buffers from this.
29 pub const announce_max_len = 9 + 5 + 1 + 64 + 1;
30
31 pub const ParseError = error{
32 AnnounceMissingPrefix,
33 AnnounceMissingPort,
34 AnnouncePortInvalid,
35 AnnouncePortZero,
36 AnnounceMissingKey,
37 AnnounceKeyLength,
38 AnnounceKeyNotHex,
39 AnnounceTrailingJunk,
40 };
41
42 pub const ReadLineError = error{
43 /// The stream ended before a newline arrived.
44 UnterminatedLine,
45 /// `buf` filled with no newline in it.
46 LineTooLong,
47 };
48
49 pub const CacheError = error{
50 CacheMissing,
51 CachePermissive,
52 CacheMalformed,
53 };
54
55 /// `endpoint <port> <64 lowercase hex chars>\n` into `buf`.
56 ///
57 /// One writer — `muxd endpoint` — and two readers: the client reading the
58 /// ssh pipe, and the client reading its own cache file, which stores this
59 /// exact line. One grammar, not two, so a cache written by one version and
60 /// read by another can only agree or fail loudly.
61 pub fn formatAnnounce(buf: []u8, ep: Endpoint) ![]const u8 {
62 // `{x}` on a byte slice is per-byte lowercase hex — 64 characters for
63 // 32 bytes, leading zeros and all. Verified against 0.15.2 rather than
64 // assumed: a formatter that took the key as one big number would drop
65 // leading zeros and emit a short line for one key in 256.
66 return std.fmt.bufPrint(buf, "endpoint {d} {x}\n", .{ ep.port, &ep.key });
67 }
68
69 /// The announce line back into an `Endpoint`, or null for `endpoint none`.
70 ///
71 /// Accepts the line with or without its trailing newline: `readLine` hands
72 /// back the line stripped, a cache file still has it on.
73 pub fn parseAnnounce(line: []const u8) ParseError!?Endpoint {
74 const prefix = "endpoint ";
75
76 // Exactly one trailing newline, and the \r that may sit in front of it.
77 // Not a general trim: junk after the key must stay visible as junk.
78 var body = line;
79 if (body.len > 0 and body[body.len - 1] == '\n') {
80 body = body[0 .. body.len - 1];
81 if (body.len > 0 and body[body.len - 1] == '\r') body = body[0 .. body.len - 1];
82 }
83
84 if (!std.mem.startsWith(u8, body, prefix)) return error.AnnounceMissingPrefix;
85 const rest = body[prefix.len..];
86 if (std.mem.eql(u8, rest, "none")) return null;
87
88 const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse {
89 return if (rest.len == 0) error.AnnounceMissingPort else error.AnnounceMissingKey;
90 };
91 const port = std.fmt.parseInt(u16, rest[0..sp], 10) catch return error.AnnouncePortInvalid;
92 // The daemon's "could not" is `endpoint none`. A zero here would be a
93 // failure wearing a dialable shape: the client would spend its whole
94 // deadline on a port that cannot exist, and the fallback line it then
95 // printed would name port 0 to the user as if that were an address.
96 if (port == 0) return error.AnnouncePortZero;
97
98 const key_tok = rest[sp + 1 ..];
99 // Checked before the length, so an otherwise-good line with something
100 // appended reports what is actually wrong with it.
101 if (std.mem.indexOfScalar(u8, key_tok, ' ') != null) return error.AnnounceTrailingJunk;
102 if (key_tok.len != 64) return error.AnnounceKeyLength;
103
104 var ep: Endpoint = .{ .port = port, .key = undefined };
105 _ = std.fmt.hexToBytes(&ep.key, key_tok) catch return error.AnnounceKeyNotHex;
106 return ep;
107 }
108
109 /// An ssh destination reduced to something dialable: everything after the
110 /// LAST `@`. ssh takes everything before the last `@` as the user name, so
111 /// this splits where ssh splits. A bare host passes through untouched.
112 pub fn dialHost(host: []const u8) []const u8 {
113 const at = std.mem.lastIndexOfScalar(u8, host, '@') orelse return host;
114 return host[at + 1 ..];
115 }
116
117 /// One newline-terminated line from `fd`, returned WITHOUT the newline.
118 ///
119 /// Read a byte at a time, deliberately. The frame stream begins at the
120 /// very next byte after the newline, so a buffered read would swallow its
121 /// first bytes into a buffer nobody looks at again — and the whole
122 /// announce-then-frames protocol rests on that not happening.
123 pub fn readLine(fd: std.posix.fd_t, buf: []u8) ![]const u8 {
124 var n: usize = 0;
125 while (true) {
126 if (n == buf.len) return error.LineTooLong;
127 var one: [1]u8 = undefined;
128 // std.posix.read retries EINTR itself, so a short read here is a
129 // real short read.
130 if (try std.posix.read(fd, &one) == 0) return error.UnterminatedLine;
131 if (one[0] == '\n') return buf[0..n];
132 buf[n] = one[0];
133 n += 1;
134 }
135 }
136
137 /// The announce line for `ep` at `path`: mode 0600, parent directories
138 /// created, immediate parent tightened to 0700 — the key travels in this
139 /// file.
140 ///
141 /// OVERWRITES. A cache is the latest truth, not a credential: unlike
142 /// `xdg.writeNewKey`, which refuses because overwriting would destroy the
143 /// only copy of something, everything here is re-derivable from one ssh.
144 pub fn writeCache(path: []const u8, ep: Endpoint) !void {
145 if (std.fs.path.dirname(path)) |dir| {
146 try std.fs.cwd().makePath(dir);
147 // Same reasoning as xdg.writeNewKey's 0700: the file's own 0600
148 // hides the key, but a 0755 directory still publishes which hosts
149 // this user attaches to, by name. Only the LAST component is
150 // tightened — `~` and `~/.cache` are the user's own business.
151 // `.iterate = true` is not optional: Dir.chmod fchmods the
152 // directory's own fd, and without it the fd is opened O_PATH,
153 // which fchmod refuses.
154 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
155 defer d.close();
156 try d.chmod(0o700);
157 }
158 var buf: [announce_max_len]u8 = undefined;
159 const line = try formatAnnounce(&buf, ep);
160
161 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600, .truncate = true });
162 defer f.close();
163 // createFile's mode applies at creation only. A cache file that
164 // somehow already exists with looser bits would keep them, and
165 // readCache would then refuse it forever — a permanently cold host
166 // with no visible cause. Make 0600 a post-condition instead.
167 try f.chmod(0o600);
168 try f.writeAll(line);
169 }
170
171 /// The endpoint remembered at `path`.
172 ///
173 /// A group- or other-readable file is refused before its contents are
174 /// read, exactly as `quic.Key.load` refuses a permissive key file: the
175 /// key is in here, and a cache that anyone can read has cached a
176 /// credential in public. `CacheMissing` is kept distinct from the
177 /// malformed cases — callers do the same thing with both today, but "no
178 /// cache yet" and "a cache I will not use" are not the same news.
179 pub fn readCache(path: []const u8) !Endpoint {
180 const f = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
181 error.FileNotFound => return error.CacheMissing,
182 else => |e| return e,
183 };
184 defer f.close();
185
186 const st = try f.stat();
187 if (st.mode & 0o077 != 0) return error.CachePermissive;
188
189 var buf: [announce_max_len + 1]u8 = undefined;
190 const n = try f.readAll(&buf);
191 if (n > announce_max_len) return error.CacheMalformed;
192
193 const ep = try parseAnnounce(buf[0..n]);
194 // Nobody writes `endpoint none` here: there are no coordinates to
195 // remember, so the cold path simply leaves the cache alone. A file
196 // holding it was written by something else.
197 return ep orelse error.CacheMalformed;
198 }
199
200 test "announce: format → parse round-trip, with and without the newline" {
201 var buf: [announce_max_len]u8 = undefined;
202
203 const ep: Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 };
204 const line = try formatAnnounce(&buf, ep);
205 try std.testing.expectEqualStrings(
206 "endpoint 4433 " ++ ("ab" ** 32) ++ "\n",
207 line,
208 );
209
210 const back = (try parseAnnounce(line)).?;
211 try std.testing.expectEqual(ep.port, back.port);
212 try std.testing.expectEqualSlices(u8, &ep.key, &back.key);
213
214 // The same line with the newline already stripped, which is what
215 // readLine hands back.
216 const stripped = (try parseAnnounce(line[0 .. line.len - 1])).?;
217 try std.testing.expectEqual(ep.port, stripped.port);
218 try std.testing.expectEqualSlices(u8, &ep.key, &stripped.key);
219
220 // A \r\n line: ssh is not the only thing that could carry this.
221 var crlf_buf: [announce_max_len + 1]u8 = undefined;
222 @memcpy(crlf_buf[0 .. line.len - 1], line[0 .. line.len - 1]);
223 crlf_buf[line.len - 1] = '\r';
224 crlf_buf[line.len] = '\n';
225 const crlf = (try parseAnnounce(crlf_buf[0 .. line.len + 1])).?;
226 try std.testing.expectEqual(ep.port, crlf.port);
227 }
228
229 test "announce: a key with leading zero bytes still hexes to 64 chars" {
230 // A formatter that treats the key as a number rather than as bytes
231 // drops the leading zeros and produces a short line that parses as
232 // malformed on the other side — for one key in 256, at random.
233 var key = [_]u8{0} ** 32;
234 key[31] = 0x0f;
235 var buf: [announce_max_len]u8 = undefined;
236 const line = try formatAnnounce(&buf, .{ .port = 1, .key = key });
237 try std.testing.expectEqualStrings(
238 "endpoint 1 " ++ ("00" ** 31) ++ "0f\n",
239 line,
240 );
241 const back = (try parseAnnounce(line)).?;
242 try std.testing.expectEqualSlices(u8, &key, &back.key);
243 }
244
245 test "announce: `endpoint none` parses as null, not as a failure" {
246 try std.testing.expectEqual(@as(?Endpoint, null), try parseAnnounce(announce_none));
247 try std.testing.expectEqual(@as(?Endpoint, null), try parseAnnounce("endpoint none"));
248 }
249
250 test "announce: every shape of junk is a named error" {
251 const hex64 = "ab" ** 32;
252 const cases = .{
253 .{ "", ParseError.AnnounceMissingPrefix },
254 .{ "4433 " ++ hex64 ++ "\n", ParseError.AnnounceMissingPrefix },
255 .{ "endpoints 4433 " ++ hex64 ++ "\n", ParseError.AnnounceMissingPrefix },
256 .{ "endpoint \n", ParseError.AnnounceMissingPort },
257 .{ "endpoint 4433\n", ParseError.AnnounceMissingKey },
258 // Port 0 is the daemon's "could not" leaking out in a dialable
259 // shape. The wire spelling for that is `endpoint none`; a zero here
260 // means somebody built the line from an endpoint_reply without
261 // checking it, and dialing port 0 would fail far from the cause.
262 .{ "endpoint 0 " ++ hex64 ++ "\n", ParseError.AnnouncePortZero },
263 .{ "endpoint 65536 " ++ hex64 ++ "\n", ParseError.AnnouncePortInvalid },
264 .{ "endpoint http " ++ hex64 ++ "\n", ParseError.AnnouncePortInvalid },
265 .{ "endpoint 4433 " ++ ("ab" ** 31) ++ "a\n", ParseError.AnnounceKeyLength },
266 .{ "endpoint 4433 " ++ hex64 ++ "a\n", ParseError.AnnounceKeyLength },
267 .{ "endpoint 4433 " ++ ("ab" ** 31) ++ "zz\n", ParseError.AnnounceKeyNotHex },
268 .{ "endpoint 4433 " ++ hex64 ++ " extra\n", ParseError.AnnounceTrailingJunk },
269 };
270 inline for (cases) |c| {
271 try std.testing.expectError(c[1], parseAnnounce(c[0]));
272 }
273 }
274
275 test "dialHost: the LAST @ wins, which is where ssh splits" {
276 try std.testing.expectEqualStrings("box", dialHost("ubuntu@box"));
277 try std.testing.expectEqualStrings("box", dialHost("box"));
278 try std.testing.expectEqualStrings("c", dialHost("a@b@c"));
279 }
280
281 test "readLine: consumes the newline and NOT the byte after it" {
282 // The property the announce-then-frames protocol stands on. A buffered
283 // read here would take 'X' — the frame stream's first byte — into a
284 // buffer that is then thrown away, and the session would hang or
285 // desynchronise with nothing to point at.
286 const fds = try std.posix.pipe();
287 defer std.posix.close(fds[0]);
288
289 const written = "endpoint none\nX";
290 try std.testing.expectEqual(written.len, try std.posix.write(fds[1], written));
291 // Closed before the read, deliberately: with the write end open, a
292 // readLine that had already swallowed the 'X' would leave the assertion
293 // below blocking forever, and this test's whole job is to catch exactly
294 // that implementation. A hung suite names nothing; EOF here turns the
295 // catch into a printed "expected 1, found 0". The bytes are already in
296 // the pipe, so the close costs the correct implementation nothing.
297 std.posix.close(fds[1]);
298
299 var buf: [64]u8 = undefined;
300 try std.testing.expectEqualStrings("endpoint none", try readLine(fds[0], &buf));
301
302 var one: [1]u8 = undefined;
303 try std.testing.expectEqual(@as(usize, 1), try std.posix.read(fds[0], &one));
304 try std.testing.expectEqual(@as(u8, 'X'), one[0]);
305 }
306
307 test "readLine: EOF before a newline, and a line longer than the buffer" {
308 {
309 const fds = try std.posix.pipe();
310 defer std.posix.close(fds[0]);
311 _ = try std.posix.write(fds[1], "endpoi");
312 std.posix.close(fds[1]);
313 var buf: [64]u8 = undefined;
314 try std.testing.expectError(ReadLineError.UnterminatedLine, readLine(fds[0], &buf));
315 }
316 {
317 const fds = try std.posix.pipe();
318 defer std.posix.close(fds[0]);
319 defer std.posix.close(fds[1]);
320 _ = try std.posix.write(fds[1], "0123456789\n");
321 var buf: [4]u8 = undefined;
322 try std.testing.expectError(ReadLineError.LineTooLong, readLine(fds[0], &buf));
323 }
324 }
325
326 test "cache: round-trips, 0600 in a 0700 directory, and overwrites" {
327 const testtmp = @import("testtmp");
328 var tmp = try testtmp.TmpDir.make();
329 defer tmp.cleanup();
330
331 var buf: [128]u8 = undefined;
332 const path = try std.fmt.bufPrint(&buf, "{s}/hosts/box", .{tmp.path()});
333
334 const first: Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 };
335 try writeCache(path, first);
336
337 const read_back = try readCache(path);
338 try std.testing.expectEqual(first.port, read_back.port);
339 try std.testing.expectEqualSlices(u8, &first.key, &read_back.key);
340
341 // The key is in this file, so it is held to the key file's standard.
342 const f = try std.fs.cwd().openFile(path, .{});
343 defer f.close();
344 const fst = try f.stat();
345 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
346
347 // 0755 would not expose the key but would expose which hosts this user
348 // attaches to, by name.
349 var dbuf: [128]u8 = undefined;
350 const dir = try std.fmt.bufPrint(&dbuf, "{s}/hosts", .{tmp.path()});
351 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
352 defer d.close();
353 const dst = try d.stat();
354 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(dst.mode & 0o777)));
355
356 // A cache is the latest truth: the second write wins, and leaves no
357 // tail of the first behind.
358 const second: Endpoint = .{ .port = 9, .key = [_]u8{0x01} ** 32 };
359 try writeCache(path, second);
360 const again = try readCache(path);
361 try std.testing.expectEqual(second.port, again.port);
362 try std.testing.expectEqualSlices(u8, &second.key, &again.key);
363 }
364
365 test "cache: refuses a permissive file, a missing one, and `endpoint none`" {
366 const testtmp = @import("testtmp");
367 var tmp = try testtmp.TmpDir.make();
368 defer tmp.cleanup();
369
370 var buf: [128]u8 = undefined;
371 const path = try std.fmt.bufPrint(&buf, "{s}/hosts/box", .{tmp.path()});
372
373 var missing_buf: [128]u8 = undefined;
374 const missing = try std.fmt.bufPrint(&missing_buf, "{s}/hosts/absent", .{tmp.path()});
375 try std.testing.expectError(CacheError.CacheMissing, readCache(missing));
376
377 try writeCache(path, .{ .port = 4433, .key = [_]u8{0xAB} ** 32 });
378 {
379 const f = try std.fs.cwd().openFile(path, .{});
380 defer f.close();
381 try f.chmod(0o644);
382 }
383 try std.testing.expectError(CacheError.CachePermissive, readCache(path));
384
385 // Nobody writes `endpoint none` to a cache — there is nothing to
386 // remember — so a file holding it is a file somebody else wrote.
387 try std.fs.cwd().writeFile(.{ .sub_path = path, .data = announce_none });
388 {
389 const f = try std.fs.cwd().openFile(path, .{});
390 defer f.close();
391 try f.chmod(0o600);
392 }
393 try std.testing.expectError(CacheError.CacheMalformed, readCache(path));
394 }
src/xdg.zig
Old New
@@ -43,6 +43,30 @@ pub fn logPathFrom(
43 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h}); 43 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h});
44 } 44 }
45 45
46 /// `$XDG_CACHE_HOME/mux/hosts/<host>`, defaulting to
47 /// `~/.cache/mux/hosts/<host>`. Where `mux HOST` remembers the last
48 /// announce. A host containing a path separator is refused — it would
49 /// name a different file than it means — and the caller attaches
50 /// uncached rather than failing.
51 pub fn hostCachePath(alloc: std.mem.Allocator, host: []const u8) ![]const u8 {
52 return hostCachePathFrom(alloc, host, std.posix.getenv("XDG_CACHE_HOME"), std.posix.getenv("HOME"));
53 }
54
55 pub fn hostCachePathFrom(
56 alloc: std.mem.Allocator,
57 host: []const u8,
58 xdg_cache_home: ?[]const u8,
59 home: ?[]const u8,
60 ) ![]const u8 {
61 // Checked before the environment, so the refusal does not depend on
62 // which of the two spellings the caller's box happens to take.
63 if (std.mem.indexOfScalar(u8, host, '/') != null) return error.UncacheableHost;
64 if (xdg_cache_home) |d| if (d.len > 0)
65 return std.fmt.allocPrint(alloc, "{s}/mux/hosts/{s}", .{ d, host });
66 const h = home orelse return error.NoHome;
67 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host });
68 }
69
46 /// 32 random bytes at `path`, mode 0600, parent directories created and 70 /// 32 random bytes at `path`, mode 0600, parent directories created and
47 /// the immediate parent tightened to 0700. 71 /// the immediate parent tightened to 0700.
48 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both 72 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both
@@ -105,6 +129,29 @@ test "logPathFrom: same shape against XDG_STATE_HOME" {
105 try std.testing.expectEqualStrings("/home/u/.local/state/mux/muxd.log", fallback); 129 try std.testing.expectEqualStrings("/home/u/.local/state/mux/muxd.log", fallback);
106 } 130 }
107 131
132 test "hostCachePathFrom: same shape against XDG_CACHE_HOME, and refuses a host with a separator" {
133 const a = std.testing.allocator;
134 const explicit = try hostCachePathFrom(a, "box", "/tmp/cache", "/home/u");
135 defer a.free(explicit);
136 try std.testing.expectEqualStrings("/tmp/cache/mux/hosts/box", explicit);
137
138 const fallback = try hostCachePathFrom(a, "box", null, "/home/u");
139 defer a.free(fallback);
140 try std.testing.expectEqualStrings("/home/u/.cache/mux/hosts/box", fallback);
141
142 const empty = try hostCachePathFrom(a, "box", "", "/home/u");
143 defer a.free(empty);
144 try std.testing.expectEqualStrings("/home/u/.cache/mux/hosts/box", empty);
145
146 try std.testing.expectError(error.NoHome, hostCachePathFrom(a, "box", null, null));
147
148 // `a/b` would name `.../hosts/a/b`, which is a different host than the
149 // one asked for — and `..` in that position names a file outside the
150 // cache entirely. Refused; the caller attaches cold instead.
151 try std.testing.expectError(error.UncacheableHost, hostCachePathFrom(a, "a/b", "/tmp/cache", "/home/u"));
152 try std.testing.expectError(error.UncacheableHost, hostCachePathFrom(a, "../k", null, "/home/u"));
153 }
154
108 test "writeNewKey: creates 0600 with 32 bytes, refuses to overwrite" { 155 test "writeNewKey: creates 0600 with 32 bytes, refuses to overwrite" {
109 const testtmp = @import("testtmp"); 156 const testtmp = @import("testtmp");
110 var tmp = try testtmp.TmpDir.make(); 157 var tmp = try testtmp.TmpDir.make();