013c8b6b
fix: handoff — writer refuses port 0, one cache error vocabulary, composed readAnnounce
a73x 2026-08-11 12:29
Commit message
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, |