a73x

013c8b6b

fix: handoff — writer refuses port 0, one cache error vocabulary, composed readAnnounce

a73x   2026-08-11 12:29

Commit message
fix: handoff — writer refuses port 0, one cache error vocabulary, composed readAnnounce

- formatAnnounce refuses port 0. endpoint_reply carries 0 as a legal wire
  value meaning "could not", so the daemon side holds a u16 that may be 0
  and owes it a translation into announce_none. Refusing at the writer
  puts the failure on the line that forgot, instead of emitting a line
  whose only complaint arrives on another machine, from the parser, about
  a message this process wrote.

- readCache folds every parse failure into CacheMalformed. The granular
  Announce* names are for the ssh-pipe path, where a caller can report
  what a daemon actually said; a cache has one decision — use it or
  refetch — so it gets one error, as quic.Key.load already does with
  hexToBytes failures.

- readAnnounce composes readLine + parseAnnounce: the cold path's whole
  interaction with the announce in one call, so no caller has to remember
  the buffer size or that the line arrives without its newline. readLine
  stays public because the byte-at-a-time property is worth testing alone.

- The 0700-parent discipline moves to xdg.makePrivateParent, called by
  both writeNewKey and writeCache. It was a byte-identical copy, and the
  duplicated part was not the six lines of code but the reason they are
  subtle (Dir.chmod needs .iterate, or the fd is O_PATH and fchmod
  refuses). xdg is a leaf, so handoff importing it makes no cycle.

- key_len = 32 replaces the bare 32/64 in Endpoint, announce_max_len and
  the key-token length check.

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

build.zig
Old New
@@ -146,6 +146,9 @@ pub fn build(b: *std.Build) void {
146 .link_libc = true, 146 .link_libc = true,
147 }); 147 });
148 handoff_mod.addImport("testtmp", testtmp_mod); 148 handoff_mod.addImport("testtmp", testtmp_mod);
149 // For makePrivateParent only — the 0700-parent discipline the cache
150 // file shares with the key file. xdg is a leaf, so no cycle.
151 handoff_mod.addImport("xdg", xdg_mod);
149 152
150 const server_mod = b.createModule(.{ 153 const server_mod = b.createModule(.{
151 .root_source_file = b.path("src/server.zig"), 154 .root_source_file = b.path("src/server.zig"),
src/handoff.zig
Old New
@@ -4,6 +4,9 @@
4 //! dialable host. Pure by design — no sockets, no processes — so the 4 //! dialable host. Pure by design — no sockets, no processes — so the
5 //! whole surface tests without a daemon. 5 //! whole surface tests without a daemon.
6 const std = @import("std"); 6 const std = @import("std");
7 // Only for the private-parent discipline the cache file shares with the
8 // key file. No XDG resolution happens here: writeCache is handed a path.
9 const xdg = @import("xdg");
7 10
8 /// The QUIC attach budget for one attempt, warm path and cold path 11 /// The QUIC attach budget for one attempt, warm path and cold path
9 /// alike. Provisional: Component 4 of the M14 design measures what a 12 /// alike. Provisional: Component 4 of the M14 design measures what a
@@ -11,11 +14,17 @@ const std = @import("std");
11 /// this number is re-pinned from that evidence (decisions.md, M14). 14 /// this number is re-pinned from that evidence (decisions.md, M14).
12 pub const deadline_ms: u32 = 2000; 15 pub const deadline_ms: u32 = 2000;
13 16
17 /// The PSK's length in bytes. The same 32 as `quic.Key`, spelled again
18 /// rather than imported: this module stays free of the C stack, which is
19 /// what lets it test without one. A drift between the two would surface
20 /// as a failed handshake, not as a wrong-looking announce.
21 pub const key_len = 32;
22
14 /// Where a `muxd endpoint` announce says its listener lives, and the key 23 /// Where a `muxd endpoint` announce says its listener lives, and the key
15 /// that authenticates to it. 24 /// that authenticates to it.
16 pub const Endpoint = struct { 25 pub const Endpoint = struct {
17 port: u16, 26 port: u16,
18 key: [32]u8, 27 key: [key_len]u8,
19 }; 28 };
20 29
21 /// The announce for "no coordinates" — no usable key, no bind, no reply. 30 /// The announce for "no coordinates" — no usable key, no bind, no reply.
@@ -25,8 +34,8 @@ pub const Endpoint = struct {
25 pub const announce_none = "endpoint none\n"; 34 pub const announce_none = "endpoint none\n";
26 35
27 /// The longest line `formatAnnounce` can produce: `endpoint ` + 5 digits 36 /// The longest line `formatAnnounce` can produce: `endpoint ` + 5 digits
28 /// + ` ` + 64 hex + `\n`. Callers size their buffers from this. 37 /// + ` ` + the hexed key + `\n`. Callers size their buffers from this.
29 pub const announce_max_len = 9 + 5 + 1 + 64 + 1; 38 pub const announce_max_len = 9 + 5 + 1 + key_len * 2 + 1;
30 39
31 pub const ParseError = error{ 40 pub const ParseError = error{
32 AnnounceMissingPrefix, 41 AnnounceMissingPrefix,
@@ -59,6 +68,14 @@ pub const CacheError = error{
59 /// exact line. One grammar, not two, so a cache written by one version and 68 /// exact line. One grammar, not two, so a cache written by one version and
60 /// read by another can only agree or fail loudly. 69 /// read by another can only agree or fail loudly.
61 pub fn formatAnnounce(buf: []u8, ep: Endpoint) ![]const u8 { 70 pub fn formatAnnounce(buf: []u8, ep: Endpoint) ![]const u8 {
71 // The writer refuses what the reader refuses. `endpoint_reply` carries
72 // 0 as a legal wire value meaning "could not", so the daemon side is
73 // holding a u16 that may be 0 and owes it a translation into
74 // `announce_none`. Refusing here puts the failure on the line that
75 // forgot, rather than emitting a syntactically fine announce whose
76 // only complaint arrives on another machine, from the parser, about a
77 // message this process wrote.
78 if (ep.port == 0) return error.AnnouncePortZero;
62 // `{x}` on a byte slice is per-byte lowercase hex — 64 characters for 79 // `{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 80 // 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 81 // assumed: a formatter that took the key as one big number would drop
@@ -70,6 +87,15 @@ pub fn formatAnnounce(buf: []u8, ep: Endpoint) ![]const u8 {
70 /// 87 ///
71 /// Accepts the line with or without its trailing newline: `readLine` hands 88 /// Accepts the line with or without its trailing newline: `readLine` hands
72 /// back the line stripped, a cache file still has it on. 89 /// back the line stripped, a cache file still has it on.
90 ///
91 /// Deliberately no stricter than its parts. The port token is whatever
92 /// `std.fmt.parseInt` accepts, so `+443`, `00443` and `4_433` all parse;
93 /// the key is hex in either case, as `quic.Key.load` also accepts. Nothing
94 /// but this module's own writer produces these lines, a looser reader
95 /// cannot admit anything a dial would not immediately reject, and the
96 /// input is length-bounded by `announce_max_len` at both call sites
97 /// (`readAnnounce`'s buffer, `readCache`'s size check). Tightening it
98 /// would only add rules to get wrong.
73 pub fn parseAnnounce(line: []const u8) ParseError!?Endpoint { 99 pub fn parseAnnounce(line: []const u8) ParseError!?Endpoint {
74 const prefix = "endpoint "; 100 const prefix = "endpoint ";
75 101
@@ -99,7 +125,7 @@ pub fn parseAnnounce(line: []const u8) ParseError!?Endpoint {
99 // Checked before the length, so an otherwise-good line with something 125 // Checked before the length, so an otherwise-good line with something
100 // appended reports what is actually wrong with it. 126 // appended reports what is actually wrong with it.
101 if (std.mem.indexOfScalar(u8, key_tok, ' ') != null) return error.AnnounceTrailingJunk; 127 if (std.mem.indexOfScalar(u8, key_tok, ' ') != null) return error.AnnounceTrailingJunk;
102 if (key_tok.len != 64) return error.AnnounceKeyLength; 128 if (key_tok.len != key_len * 2) return error.AnnounceKeyLength;
103 129
104 var ep: Endpoint = .{ .port = port, .key = undefined }; 130 var ep: Endpoint = .{ .port = port, .key = undefined };
105 _ = std.fmt.hexToBytes(&ep.key, key_tok) catch return error.AnnounceKeyNotHex; 131 _ = std.fmt.hexToBytes(&ep.key, key_tok) catch return error.AnnounceKeyNotHex;
@@ -134,6 +160,19 @@ pub fn readLine(fd: std.posix.fd_t, buf: []u8) ![]const u8 {
134 } 160 }
135 } 161 }
136 162
163 /// The whole pipe-side read: one announce line off `fd`, parsed. Null is
164 /// `endpoint none` — coordinates were not produced and the session stays
165 /// on ssh.
166 ///
167 /// This is the cold path's entire interaction with the announce, in one
168 /// call, so no caller has to remember the buffer size or that the line
169 /// arrives without its newline. `readLine` stays public because the
170 /// byte-at-a-time property is worth testing on its own.
171 pub fn readAnnounce(fd: std.posix.fd_t) !?Endpoint {
172 var buf: [announce_max_len]u8 = undefined;
173 return parseAnnounce(try readLine(fd, &buf));
174 }
175
137 /// The announce line for `ep` at `path`: mode 0600, parent directories 176 /// The announce line for `ep` at `path`: mode 0600, parent directories
138 /// created, immediate parent tightened to 0700 — the key travels in this 177 /// created, immediate parent tightened to 0700 — the key travels in this
139 /// file. 178 /// file.
@@ -142,19 +181,12 @@ pub fn readLine(fd: std.posix.fd_t, buf: []u8) ![]const u8 {
142 /// `xdg.writeNewKey`, which refuses because overwriting would destroy the 181 /// `xdg.writeNewKey`, which refuses because overwriting would destroy the
143 /// only copy of something, everything here is re-derivable from one ssh. 182 /// only copy of something, everything here is re-derivable from one ssh.
144 pub fn writeCache(path: []const u8, ep: Endpoint) !void { 183 pub fn writeCache(path: []const u8, ep: Endpoint) !void {
145 if (std.fs.path.dirname(path)) |dir| { 184 // The same discipline the key file gets, from the same place: the
146 try std.fs.cwd().makePath(dir); 185 // file's own 0600 hides the key, but a 0755 directory still publishes
147 // Same reasoning as xdg.writeNewKey's 0700: the file's own 0600 186 // which hosts this user attaches to, by name. Shared rather than
148 // hides the key, but a 0755 directory still publishes which hosts 187 // copied because the reason it is subtle — Dir.chmod needs `.iterate`
149 // this user attaches to, by name. Only the LAST component is 188 // — is worth having written down once.
150 // tightened — `~` and `~/.cache` are the user's own business. 189 try xdg.makePrivateParent(path);
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; 190 var buf: [announce_max_len]u8 = undefined;
159 const line = try formatAnnounce(&buf, ep); 191 const line = try formatAnnounce(&buf, ep);
160 192
@@ -173,9 +205,12 @@ pub fn writeCache(path: []const u8, ep: Endpoint) !void {
173 /// A group- or other-readable file is refused before its contents are 205 /// 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 206 /// 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 207 /// key is in here, and a cache that anyone can read has cached a
176 /// credential in public. `CacheMissing` is kept distinct from the 208 /// credential in public. Everything a caller can do about this file is
177 /// malformed cases — callers do the same thing with both today, but "no 209 /// the same — attach cold — so the vocabulary is three errors and no
178 /// cache yet" and "a cache I will not use" are not the same news. 210 /// more: `CacheMissing` for "no cache yet", `CachePermissive` for one
211 /// held wrong, `CacheMalformed` for every way the contents can be
212 /// unusable. Missing stays separate from the other two because it is the
213 /// ordinary first run rather than something to look into.
179 pub fn readCache(path: []const u8) !Endpoint { 214 pub fn readCache(path: []const u8) !Endpoint {
180 const f = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { 215 const f = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
181 error.FileNotFound => return error.CacheMissing, 216 error.FileNotFound => return error.CacheMissing,
@@ -190,7 +225,12 @@ pub fn readCache(path: []const u8) !Endpoint {
190 const n = try f.readAll(&buf); 225 const n = try f.readAll(&buf);
191 if (n > announce_max_len) return error.CacheMalformed; 226 if (n > announce_max_len) return error.CacheMalformed;
192 227
193 const ep = try parseAnnounce(buf[0..n]); 228 // Every way the contents can be wrong arrives as one error. The
229 // granular `Announce*` names are for the ssh-pipe path, where a caller
230 // can report what a daemon actually said; a cache has one decision to
231 // make — use it or refetch — and `quic.Key.load` already set this
232 // taste by folding hexToBytes failures into KeyFileMalformed.
233 const ep = parseAnnounce(buf[0..n]) catch return error.CacheMalformed;
194 // Nobody writes `endpoint none` here: there are no coordinates to 234 // Nobody writes `endpoint none` here: there are no coordinates to
195 // remember, so the cold path simply leaves the cache alone. A file 235 // remember, so the cold path simply leaves the cache alone. A file
196 // holding it was written by something else. 236 // holding it was written by something else.
@@ -252,6 +292,20 @@ test "announce: a key with leading zero bytes still hexes to 64 chars" {
252 try std.testing.expectEqualSlices(u8, &key, &back.key); 292 try std.testing.expectEqualSlices(u8, &key, &back.key);
253 } 293 }
254 294
295 test "announce: the WRITER refuses port 0, where the mistake is still local" {
296 // `endpoint_reply` carries 0 as a legal wire value meaning "could
297 // not", so Task 5 holds a u16 that may be 0 and must turn it into
298 // `endpoint none` rather than a line. Refusing at the writer means a
299 // caller that forgets fails on its own line, instead of shipping a
300 // line that only fails much later, on the client, as a parse error
301 // about a message this side produced.
302 var buf: [announce_max_len]u8 = undefined;
303 try std.testing.expectError(
304 ParseError.AnnouncePortZero,
305 formatAnnounce(&buf, .{ .port = 0, .key = [_]u8{0xAB} ** 32 }),
306 );
307 }
308
255 test "announce: `endpoint none` parses as null, not as a failure" { 309 test "announce: `endpoint none` parses as null, not as a failure" {
256 try std.testing.expectEqual(@as(?Endpoint, null), try parseAnnounce(announce_none)); 310 try std.testing.expectEqual(@as(?Endpoint, null), try parseAnnounce(announce_none));
257 try std.testing.expectEqual(@as(?Endpoint, null), try parseAnnounce("endpoint none")); 311 try std.testing.expectEqual(@as(?Endpoint, null), try parseAnnounce("endpoint none"));
@@ -314,6 +368,37 @@ test "readLine: consumes the newline and NOT the byte after it" {
314 try std.testing.expectEqual(@as(u8, 'X'), one[0]); 368 try std.testing.expectEqual(@as(u8, 'X'), one[0]);
315 } 369 }
316 370
371 test "readAnnounce: one call for the cold path, and it leaves the frames alone" {
372 const fds = try std.posix.pipe();
373 defer std.posix.close(fds[0]);
374
375 var buf: [announce_max_len]u8 = undefined;
376 const ep: Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 };
377 const line = try formatAnnounce(&buf, ep);
378 _ = try std.posix.write(fds[1], line);
379 _ = try std.posix.write(fds[1], "X");
380 // Closed before the read for the same reason as the readLine test: a
381 // reader that swallowed the 'X' would otherwise hang here instead of
382 // failing with a printed assertion.
383 std.posix.close(fds[1]);
384
385 const got = (try readAnnounce(fds[0])).?;
386 try std.testing.expectEqual(ep.port, got.port);
387 try std.testing.expectEqualSlices(u8, &ep.key, &got.key);
388
389 var one: [1]u8 = undefined;
390 try std.testing.expectEqual(@as(usize, 1), try std.posix.read(fds[0], &one));
391 try std.testing.expectEqual(@as(u8, 'X'), one[0]);
392 }
393
394 test "readAnnounce: `endpoint none` off a pipe is null, not an error" {
395 const fds = try std.posix.pipe();
396 defer std.posix.close(fds[0]);
397 _ = try std.posix.write(fds[1], announce_none);
398 std.posix.close(fds[1]);
399 try std.testing.expectEqual(@as(?Endpoint, null), try readAnnounce(fds[0]));
400 }
401
317 test "readLine: EOF before a newline, and a line longer than the buffer" { 402 test "readLine: EOF before a newline, and a line longer than the buffer" {
318 { 403 {
319 const fds = try std.posix.pipe(); 404 const fds = try std.posix.pipe();
@@ -447,4 +532,18 @@ test "cache: refuses a permissive file, a missing one, and `endpoint none`" {
447 try f.chmod(0o600); 532 try f.chmod(0o600);
448 } 533 }
449 try std.testing.expectError(CacheError.CacheMalformed, readCache(path)); 534 try std.testing.expectError(CacheError.CacheMalformed, readCache(path));
535
536 // Content that is not the grammar at all reports the SAME error. The
537 // granular parse names exist for the ssh-pipe path, where the caller
538 // can say something useful about a daemon that answered oddly; for a
539 // cache there is one decision — use it or refetch — so there is one
540 // error. Two vocabularies for one condition would just mean every
541 // caller had to know both.
542 try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "garbage\n" });
543 {
544 const f = try std.fs.cwd().openFile(path, .{});
545 defer f.close();
546 try f.chmod(0o600);
547 }
548 try std.testing.expectError(CacheError.CacheMalformed, readCache(path));
450 } 549 }
src/xdg.zig
Old New
@@ -67,26 +67,34 @@ pub fn hostCachePathFrom(
67 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host }); 67 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host });
68 } 68 }
69 69
70 /// Create `path`'s parent directories and tighten the immediate parent to
71 /// 0700. Both files this project writes under a home directory — the key
72 /// and the handoff cache — hold a credential and want exactly this, so the
73 /// policy and the reason it is subtle live here rather than in two copies.
74 /// A `path` with no directory component is a no-op.
75 pub fn makePrivateParent(path: []const u8) !void {
76 const dir = std.fs.path.dirname(path) orelse return;
77 try std.fs.cwd().makePath(dir);
78 // makePath leaves 0755, which does not expose the file's contents —
79 // that is 0600 — but does expose that it exists and what it is called.
80 // ssh's answer for the analogous directory is 0700 and there is no
81 // reason to be looser. Only the LAST component is tightened: the
82 // parents on the way (`~`, `~/.config`) are the user's own business
83 // and are not ours to re-permission.
84 // `.iterate = true` is not optional here: Dir.chmod fchmods the
85 // directory's own fd, and without it the fd is opened O_PATH, which
86 // fchmod refuses.
87 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
88 defer d.close();
89 try d.chmod(0o700);
90 }
91
70 /// 32 random bytes at `path`, mode 0600, parent directories created and 92 /// 32 random bytes at `path`, mode 0600, parent directories created and
71 /// the immediate parent tightened to 0700. 93 /// the immediate parent tightened to 0700.
72 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both 94 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both
73 /// counts, so overwriting silently would delete a credential. 95 /// counts, so overwriting silently would delete a credential.
74 pub fn writeNewKey(path: []const u8) !void { 96 pub fn writeNewKey(path: []const u8) !void {
75 if (std.fs.path.dirname(path)) |dir| { 97 try makePrivateParent(path);
76 try std.fs.cwd().makePath(dir);
77 // makePath leaves 0755, which does not expose the key — that is
78 // 0600 — but does expose that a key exists and what it is called.
79 // ssh's answer for the analogous directory is 0700 and there is no
80 // reason to be looser. Only the LAST component is tightened: the
81 // parents on the way (`~`, `~/.config`) are the user's own business
82 // and are not ours to re-permission.
83 // `.iterate = true` is not optional here: Dir.chmod fchmods the
84 // directory's own fd, and without it the fd is opened O_PATH, which
85 // fchmod refuses.
86 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
87 defer d.close();
88 try d.chmod(0o700);
89 }
90 const f = std.fs.cwd().createFile(path, .{ 98 const f = std.fs.cwd().createFile(path, .{
91 .exclusive = true, 99 .exclusive = true,
92 .mode = 0o600, 100 .mode = 0o600,