b6e51fdd
feat: handoff vocabulary — announce line, per-host cache, dial-host strip
a73x 2026-08-11 12:01
Commit message
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(); |