ab0c737f
refactor: wall.zig is gone; the hosts file is the only wall
a73x 2026-08-29 12:14
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -234,10 +234,6 @@ const mod_table = [_]ModSpec{ | |||
| 234 | // under it. | 234 | // under it. |
| 235 | .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, | 235 | .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, |
| 236 | // ---- layer 2 ---- | 236 | // ---- layer 2 ---- |
| 237 | // The browser hub's private wall file, being retired: the `#SESSION` | ||
| 238 | // spelling half the hub has not yet stopped reading. It borrows the | ||
| 239 | // host grammar rather than forking a second one, so it sits above it. | ||
| 240 | .{ .name = "wall", .path = "src/client/wall.zig", .layer = 2, .imports = &.{ "protocol", "hosts", "xdg" }, .test_imports = &.{"testtmp"} }, | ||
| 241 | // Everything that happens between a user at a terminal and one already | 237 | // Everything that happens between a user at a terminal and one already |
| 242 | // open session: the chord table, the wheel splitter, prediction, the | 238 | // open session: the chord table, the wheel splitter, prediction, the |
| 243 | // side channels, terminal ownership. It sits BELOW client because it | 239 | // side channels, terminal ownership. It sits BELOW client because it |
| @@ -264,10 +260,10 @@ const mod_table = [_]ModSpec{ | |||
| 264 | // state anybody else can see — but the predicting itself is interact's | 260 | // state anybody else can see — but the predicting itself is interact's |
| 265 | // now, along with the rest of the terminal-facing machinery; what stays | 261 | // now, along with the rest of the terminal-facing machinery; what stays |
| 266 | // here is Target/Transport, the attach loop and the session's meanings. | 262 | // here is Target/Transport, the attach loop and the session's meanings. |
| 267 | // `wall` is the spelling grammar AND the state file: a grid-claiming | 263 | // `hosts` is the host grammar: `Target.fromSpec` takes a `hosts.Spec`, |
| 268 | // attach records its own tile (the wall is attach history), and the | 264 | // and `resolveHost` sits here rather than in either front so the CLI |
| 269 | // chord switches that re-dial from inside client.attach have to record | 265 | // wall and the browser hub resolve a host line the same way. Nothing |
| 270 | // theirs too, so the writer cannot live up in mux_main. | 266 | // here WRITES that file — `wall_host.recordHost` and `hub_main` do. |
| 271 | .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "hosts", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 267 | .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "hosts", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 272 | // The daemon entrypoint loads the key and constructs the listener, so | 268 | // The daemon entrypoint loads the key and constructs the listener, so |
| 273 | // it needs quic/quic_server directly rather than through the server. | 269 | // it needs quic/quic_server directly rather than through the server. |
| @@ -284,7 +280,7 @@ const mod_table = [_]ModSpec{ | |||
| 284 | // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of | 280 | // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of |
| 285 | // which can be ZOOMED — promoted to the terminal's size and typed | 281 | // which can be ZOOMED — promoted to the terminal's size and typed |
| 286 | // through. Same layer as webhub for the same reason — both sit on | 282 | // through. Same layer as webhub for the same reason — both sit on |
| 287 | // client's Transport and wall's grammar; neither may import the other; | 283 | // client's Transport and hosts' grammar; neither may import the other; |
| 288 | // both resolve a spelling through `client.Target.fromSpec`. | 284 | // both resolve a spelling through `client.Target.fromSpec`. |
| 289 | // `predict` is here because a zoomed tile speculates like any other | 285 | // `predict` is here because a zoomed tile speculates like any other |
| 290 | // typed-at session; the overlay machinery itself is interact's, shared — | 286 | // typed-at session; the overlay machinery itself is interact's, shared — |
| @@ -301,9 +297,8 @@ const mod_table = [_]ModSpec{ | |||
| 301 | // sockpath is the sun_path bound only; the client binds no socket itself. | 297 | // sockpath is the sun_path bound only; the client binds no socket itself. |
| 302 | // protocol is the session-name validator alone (validSessionName): a bad | 298 | // protocol is the session-name validator alone (validSessionName): a bad |
| 303 | // --session has to be a usage error here, at parse, not bytes some | 299 | // --session has to be a usage error here, at parse, not bytes some |
| 304 | // daemon downstream has to notice and refuse. Layer 5 since `mux wall` | 300 | // daemon downstream has to notice and refuse. Layer 5 since the no-arg |
| 305 | // pulled in wallview (layer 4); wall rides along for the no-arg wall | 301 | // wall pulled in wallview (layer 4). |
| 306 | // (the state file the browser hub builds). | ||
| 307 | .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 302 | .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 308 | // ---- layer 6: the one binary ---- | 303 | // ---- layer 6: the one binary ---- |
| 309 | // Four words, one image. Nothing but the mode letter lives here, which | 304 | // Four words, one image. Nothing but the mode letter lives here, which |
| @@ -798,12 +793,12 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 798 | /// they carry the argument parsers — a test that is never built is not a | 793 | /// they carry the argument parsers — a test that is never built is not a |
| 799 | /// test (decisions.md). | 794 | /// test (decisions.md). |
| 800 | const test_order = [_][]const u8{ | 795 | const test_order = [_][]const u8{ |
| 801 | "script", "select", "protocol", "client_core", "interact", "engine", "pty", | 796 | "script", "select", "protocol", "client_core", "interact", "engine", "pty", |
| 802 | "delta", "cmd", "wall", "hosts", "upgrade", "shellint", "replica", | 797 | "delta", "cmd", "hosts", "upgrade", "shellint", "replica", "keymap", |
| 803 | "keymap", "webhub", "wallview", "sockpath", "agent_main", "server", "client", | 798 | "webhub", "wallview", "sockpath", "agent_main", "server", "client", "proxy", |
| 804 | "proxy", "client_main", "quic", "quic_server", "daemon_main", "testtmp", "predict", | 799 | "client_main", "quic", "quic_server", "daemon_main", "testtmp", "predict", "rawmode", |
| 805 | "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", | 800 | "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", "render", |
| 806 | "render", "ptyclient", "hub_main", "wsclient", "cliflags", "mux", | 801 | "ptyclient", "hub_main", "wsclient", "cliflags", "mux", |
| 807 | }; | 802 | }; |
| 808 | 803 | ||
| 809 | comptime { | 804 | comptime { |
docscheck.budget
| Old | New | ||
|---|---|---|---|
| @@ -54,7 +54,6 @@ wall_test_picker.zig 0 | |||
| 54 | wall_test_pump.zig 0 | 54 | wall_test_pump.zig 0 |
| 55 | wall_test_wall.zig 0 | 55 | wall_test_wall.zig 0 |
| 56 | wallview.zig 0 | 56 | wallview.zig 0 |
| 57 | wall.zig 0 | ||
| 58 | wasm_core.zig 0 | 57 | wasm_core.zig 0 |
| 59 | webhub_main.zig 0 | 58 | webhub_main.zig 0 |
| 60 | webhub.zig 0 | 59 | webhub.zig 0 |
src/client/wall.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,414 +0,0 @@ | |||
| 1 | //! The browser hub's private wall file, being retired: an ordered list of | ||
| 2 | //! TARGET spellings, one per line of `$XDG_STATE_HOME/mux/wall`. The | ||
| 3 | //! grammar, the atomic writer and the argv collector all live in | ||
| 4 | //! `hosts.zig` now; what is left here is the `#SESSION` half the hub has | ||
| 5 | //! not yet stopped reading. | ||
| 6 | const std = @import("std"); | ||
| 7 | const proto = @import("protocol"); | ||
| 8 | const hosts = @import("hosts"); | ||
| 9 | const xdg = @import("xdg"); | ||
| 10 | |||
| 11 | pub const Spec = hosts.Spec; | ||
| 12 | |||
| 13 | pub const Parsed = struct { spec: Spec, session: []const u8 }; | ||
| 14 | pub const ParseError = error{ BadSession, EmptySpec, BadByte, BadSpelling }; | ||
| 15 | |||
| 16 | /// Splits and classifies one spelling. Refuses here, at usage altitude, | ||
| 17 | /// what would otherwise surface as a rejected attach far from the typo: | ||
| 18 | /// a malformed session name, or a spelling whose target part is empty | ||
| 19 | /// (`#b`, `quic://`, `--sock #b`). | ||
| 20 | /// | ||
| 21 | /// The returned slices BORROW from `line` — nothing is copied, so a | ||
| 22 | /// caller that keeps a Parsed must keep the string it parsed. | ||
| 23 | pub fn parseSpelling(line: []const u8) ParseError!Parsed { | ||
| 24 | // No control byte anywhere, checked before the split so it covers the | ||
| 25 | // target part too (validSessionName already refuses them after '#'). | ||
| 26 | // A '\n' is the sharp one: the file is one spelling per line, so such | ||
| 27 | // a spelling would be WRITTEN as one tile and LOADED as two — the | ||
| 28 | // writer/loader identity, and with it the one-grammar contract, gone. | ||
| 29 | // Reachable from argv and from the browser's POST body, so it is | ||
| 30 | // refused in the grammar rather than at either mouth. | ||
| 31 | for (line) |c| if (c < 0x20) return error.BadByte; | ||
| 32 | var spec_str = line; | ||
| 33 | var session: []const u8 = ""; | ||
| 34 | if (std.mem.lastIndexOfScalar(u8, line, '#')) |hash| { | ||
| 35 | const name = line[hash + 1 ..]; | ||
| 36 | if (!proto.validSessionName(name)) return error.BadSession; | ||
| 37 | spec_str = line[0..hash]; | ||
| 38 | session = name; | ||
| 39 | } | ||
| 40 | const sock_prefix = "--sock "; | ||
| 41 | const quic_prefix = "quic://"; | ||
| 42 | if (std.mem.startsWith(u8, spec_str, sock_prefix)) { | ||
| 43 | const path = spec_str[sock_prefix.len..]; | ||
| 44 | if (path.len == 0) return error.EmptySpec; | ||
| 45 | return .{ .spec = .{ .sock = path }, .session = session }; | ||
| 46 | } | ||
| 47 | if (std.mem.startsWith(u8, spec_str, quic_prefix)) { | ||
| 48 | const hp = spec_str[quic_prefix.len..]; | ||
| 49 | if (hp.len == 0) return error.EmptySpec; | ||
| 50 | return .{ .spec = .{ .quic = hp }, .session = session }; | ||
| 51 | } | ||
| 52 | if (spec_str.len == 0) return error.EmptySpec; | ||
| 53 | if (hosts.hasBadSpelling(spec_str)) return error.BadSpelling; | ||
| 54 | return .{ .spec = .{ .host = spec_str }, .session = session }; | ||
| 55 | } | ||
| 56 | |||
| 57 | /// The one refusal this grammar has that the host grammar does not: a | ||
| 58 | /// `#SESSION` it will not take. Everything else is `hosts.reason`'s, so | ||
| 59 | /// the two mouths refuse in the same words. | ||
| 60 | pub fn reason(err: anyerror) []const u8 { | ||
| 61 | return switch (err) { | ||
| 62 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 63 | else => hosts.reason(err), | ||
| 64 | }; | ||
| 65 | } | ||
| 66 | |||
| 67 | /// The argv side of the grammar, one collector for both binaries' flag | ||
| 68 | /// tables: bare words and `--sock PATH` become owned spellings, each | ||
| 69 | /// validated HERE at usage altitude rather than downstream as one tile | ||
| 70 | /// that will not attach. `positional` and `extra` are the names cliflags | ||
| 71 | /// finds by reflection. | ||
| 72 | pub const Argv = struct { | ||
| 73 | alloc: std.mem.Allocator, | ||
| 74 | tiles: std.ArrayList([]const u8) = .empty, | ||
| 75 | /// A hook answers yes or no, so one that refused for a REASON has | ||
| 76 | /// nowhere to say so: it leaves the word and the why for the caller, | ||
| 77 | /// whose message can then name WHICH tile of several was refused. | ||
| 78 | err: ?struct { word: []const u8, err: (hosts.ArgvError || ParseError) } = null, | ||
| 79 | |||
| 80 | pub fn deinit(self: *Argv) void { | ||
| 81 | for (self.tiles.items) |t| self.alloc.free(t); | ||
| 82 | self.tiles.deinit(self.alloc); | ||
| 83 | } | ||
| 84 | |||
| 85 | /// Bare HOST and quic:// are already wall spellings verbatim. | ||
| 86 | pub fn positional(self: *Argv, word: []const u8) bool { | ||
| 87 | return self.take(word); | ||
| 88 | } | ||
| 89 | |||
| 90 | /// `--sock PATH` is one tile in two words, a grammar no flag table can | ||
| 91 | /// hold. A flag-shaped word is refused with NO record: it is not a | ||
| 92 | /// target this program rejects but one it never saw, left for cliflags | ||
| 93 | /// to name as the unknown flag it is. | ||
| 94 | pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { | ||
| 95 | const n = hosts.spellingFromArgv(self.alloc, rest, 0) catch |e| { | ||
| 96 | if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); | ||
| 97 | return 0; | ||
| 98 | }; | ||
| 99 | defer self.alloc.free(n.spelling); | ||
| 100 | return if (self.take(n.spelling)) n.consumed else 0; | ||
| 101 | } | ||
| 102 | |||
| 103 | fn take(self: *Argv, spelling: []const u8) bool { | ||
| 104 | // Appended before it is judged, so a refusal's `word` points into a | ||
| 105 | // copy this list owns: `--sock PATH` is joined into the caller's | ||
| 106 | // temporary, and a message naming it must not outlive that. | ||
| 107 | const copy = self.alloc.dupe(u8, spelling) catch return self.refuse("", error.OutOfMemory); | ||
| 108 | self.tiles.append(self.alloc, copy) catch { | ||
| 109 | self.alloc.free(copy); | ||
| 110 | return self.refuse("", error.OutOfMemory); | ||
| 111 | }; | ||
| 112 | _ = parseSpelling(copy) catch |e| return self.refuse(copy, e); | ||
| 113 | return true; | ||
| 114 | } | ||
| 115 | |||
| 116 | fn refuse(self: *Argv, word: []const u8, e: (hosts.ArgvError || ParseError)) bool { | ||
| 117 | self.err = .{ .word = word, .err = e }; | ||
| 118 | return false; | ||
| 119 | } | ||
| 120 | }; | ||
| 121 | |||
| 122 | pub const Wall = struct { | ||
| 123 | /// Owned copies, wall order. The spelling IS the label downstream. | ||
| 124 | targets: std.ArrayList([]u8) = .empty, | ||
| 125 | |||
| 126 | pub fn deinit(self: *Wall, alloc: std.mem.Allocator) void { | ||
| 127 | for (self.targets.items) |t| alloc.free(t); | ||
| 128 | self.targets.deinit(alloc); | ||
| 129 | } | ||
| 130 | |||
| 131 | /// Validates, then appends. Returns the new entry's index. | ||
| 132 | pub fn add(self: *Wall, alloc: std.mem.Allocator, spelling: []const u8) !usize { | ||
| 133 | _ = try parseSpelling(spelling); | ||
| 134 | const copy = try alloc.dupe(u8, spelling); | ||
| 135 | errdefer alloc.free(copy); | ||
| 136 | try self.targets.append(alloc, copy); | ||
| 137 | return self.targets.items.len - 1; | ||
| 138 | } | ||
| 139 | |||
| 140 | pub fn remove(self: *Wall, alloc: std.mem.Allocator, idx: usize) void { | ||
| 141 | alloc.free(self.targets.orderedRemove(idx)); | ||
| 142 | } | ||
| 143 | |||
| 144 | /// `order` must be an exact permutation of 0..len — anything else is | ||
| 145 | /// the caller working from a stale view, refused so it can refetch. | ||
| 146 | pub fn reorder(self: *Wall, alloc: std.mem.Allocator, order: []const usize) error{ BadOrder, OutOfMemory }!void { | ||
| 147 | const n = self.targets.items.len; | ||
| 148 | if (order.len != n) return error.BadOrder; | ||
| 149 | var seen = try alloc.alloc(bool, n); | ||
| 150 | defer alloc.free(seen); | ||
| 151 | @memset(seen, false); | ||
| 152 | for (order) |i| { | ||
| 153 | if (i >= n or seen[i]) return error.BadOrder; | ||
| 154 | seen[i] = true; | ||
| 155 | } | ||
| 156 | const old = try alloc.dupe([]u8, self.targets.items); | ||
| 157 | defer alloc.free(old); | ||
| 158 | for (order, 0..) |src, dst| self.targets.items[dst] = old[src]; | ||
| 159 | } | ||
| 160 | }; | ||
| 161 | |||
| 162 | /// Missing file is an empty wall, not an error: first run has no state. | ||
| 163 | /// A line that no longer parses (edited by hand) is refused loudly — | ||
| 164 | /// error, not skip — because silently dropping a tile the user wrote | ||
| 165 | /// down is worse than making them fix the line. | ||
| 166 | pub fn load(alloc: std.mem.Allocator, path: []const u8) !Wall { | ||
| 167 | var w = Wall{}; | ||
| 168 | errdefer w.deinit(alloc); | ||
| 169 | const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) { | ||
| 170 | error.FileNotFound => return w, | ||
| 171 | else => return err, | ||
| 172 | }; | ||
| 173 | defer alloc.free(data); | ||
| 174 | // tokenize, not split: `save` ends every line with '\n', so a split | ||
| 175 | // would hand the trailing empty string to `add` and make every | ||
| 176 | // round-trip fail EmptySpec. Blank lines are skipped for free. | ||
| 177 | var it = std.mem.tokenizeScalar(u8, data, '\n'); | ||
| 178 | while (it.next()) |line| _ = try w.add(alloc, line); | ||
| 179 | return w; | ||
| 180 | } | ||
| 181 | |||
| 182 | pub fn save(w: *const Wall, path: []const u8) !void { | ||
| 183 | return hosts.saveLines(w.targets.items, path); | ||
| 184 | } | ||
| 185 | |||
| 186 | /// Dedup is on the SPELLING, never on session identity: the same session | ||
| 187 | /// as `HOST#S` and as `quic://HOST#S` is two tiles, deliberately. Identity | ||
| 188 | /// dedup would need an endpoint handshake the wall does not want. | ||
| 189 | pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { | ||
| 190 | var w = try load(alloc, path); | ||
| 191 | defer w.deinit(alloc); | ||
| 192 | for (w.targets.items) |t| if (std.mem.eql(u8, t, spelling)) return false; | ||
| 193 | _ = try w.add(alloc, spelling); | ||
| 194 | try save(&w, path); | ||
| 195 | return true; | ||
| 196 | } | ||
| 197 | |||
| 198 | pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { | ||
| 199 | return xdg.statePath(alloc, "wall"); | ||
| 200 | } | ||
| 201 | |||
| 202 | test "Argv: bare words and both --sock dialects all become owned spellings" { | ||
| 203 | var a = Argv{ .alloc = std.testing.allocator }; | ||
| 204 | defer a.deinit(); | ||
| 205 | try std.testing.expect(a.positional("box1")); | ||
| 206 | try std.testing.expectEqual(@as(usize, 2), a.extra(&[_][:0]const u8{ "--sock", "/tmp/a.sock" })); | ||
| 207 | try std.testing.expectEqual(@as(usize, 1), a.extra(&[_][:0]const u8{"--sock /tmp/b.sock#w"})); | ||
| 208 | try std.testing.expect(a.err == null); | ||
| 209 | try std.testing.expectEqual(@as(usize, 3), a.tiles.items.len); | ||
| 210 | try std.testing.expectEqualStrings("box1", a.tiles.items[0]); | ||
| 211 | // The two-word dialect is JOINED: one spelling from here on, prefix | ||
| 212 | // included, because that string is the label and the wall line too. | ||
| 213 | try std.testing.expectEqualStrings("--sock /tmp/a.sock", a.tiles.items[1]); | ||
| 214 | try std.testing.expectEqualStrings("--sock /tmp/b.sock#w", a.tiles.items[2]); | ||
| 215 | } | ||
| 216 | |||
| 217 | test "Argv: a refusal records WHICH word and why; a flag-shaped word records nothing" { | ||
| 218 | var a = Argv{ .alloc = std.testing.allocator }; | ||
| 219 | defer a.deinit(); | ||
| 220 | try std.testing.expect(!a.positional("h#bad name")); | ||
| 221 | try std.testing.expectEqualStrings("h#bad name", a.err.?.word); | ||
| 222 | try std.testing.expect(a.err.?.err == error.BadSession); | ||
| 223 | |||
| 224 | // The joined spelling is what the message names, and it must still be | ||
| 225 | // readable after `extra` freed the temporary it was joined into. | ||
| 226 | var b = Argv{ .alloc = std.testing.allocator }; | ||
| 227 | defer b.deinit(); | ||
| 228 | try std.testing.expectEqual(@as(usize, 0), b.extra(&[_][:0]const u8{ "--sock", "/tmp/x#bad name" })); | ||
| 229 | try std.testing.expectEqualStrings("--sock /tmp/x#bad name", b.err.?.word); | ||
| 230 | try std.testing.expect(b.err.?.err == error.BadSession); | ||
| 231 | |||
| 232 | // A trailing `--sock` names no path — a usage mistake this collector | ||
| 233 | // owns, so it IS recorded, against the flag word the user typed. | ||
| 234 | var c = Argv{ .alloc = std.testing.allocator }; | ||
| 235 | defer c.deinit(); | ||
| 236 | try std.testing.expectEqual(@as(usize, 0), c.extra(&[_][:0]const u8{"--sock"})); | ||
| 237 | try std.testing.expectEqualStrings("--sock", c.err.?.word); | ||
| 238 | try std.testing.expect(c.err.?.err == error.MissingSockPath); | ||
| 239 | |||
| 240 | // `-A` is not a target this refuses but one it never saw: no record, | ||
| 241 | // so cliflags names it the unknown flag it is. | ||
| 242 | var d = Argv{ .alloc = std.testing.allocator }; | ||
| 243 | defer d.deinit(); | ||
| 244 | try std.testing.expectEqual(@as(usize, 0), d.extra(&[_][:0]const u8{ "-A", "host" })); | ||
| 245 | try std.testing.expect(d.err == null); | ||
| 246 | try std.testing.expectEqual(@as(usize, 0), d.tiles.items.len); | ||
| 247 | } | ||
| 248 | |||
| 249 | test "parseSpelling: three spellings classify; session splits at the LAST '#'" { | ||
| 250 | try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host); | ||
| 251 | try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session); | ||
| 252 | try std.testing.expectEqualStrings("h:4433", (try parseSpelling("quic://h:4433#b")).spec.quic); | ||
| 253 | try std.testing.expectEqualStrings("b", (try parseSpelling("quic://h:4433#b")).session); | ||
| 254 | try std.testing.expectEqualStrings("/tmp/x", (try parseSpelling("--sock /tmp/x#b")).spec.sock); | ||
| 255 | // The LAST '#': earlier ones belong to the target's own spelling. | ||
| 256 | try std.testing.expectEqualStrings("a#b", (try parseSpelling("a#b#c")).spec.host); | ||
| 257 | try std.testing.expectEqualStrings("c", (try parseSpelling("a#b#c")).session); | ||
| 258 | } | ||
| 259 | |||
| 260 | test "parseSpelling: refusals — bad session, empty spec in every spelling" { | ||
| 261 | try std.testing.expectError(error.BadSession, parseSpelling("host#has space")); | ||
| 262 | try std.testing.expectError(error.BadSession, parseSpelling("host#")); // empty name is not typeable | ||
| 263 | try std.testing.expectError(error.EmptySpec, parseSpelling("#b")); | ||
| 264 | try std.testing.expectError(error.EmptySpec, parseSpelling("quic://")); | ||
| 265 | try std.testing.expectError(error.EmptySpec, parseSpelling("--sock #b")); | ||
| 266 | try std.testing.expectError(error.EmptySpec, parseSpelling("")); | ||
| 267 | // Control bytes: '\n' would break the one-spelling-per-line file in | ||
| 268 | // two, and there is no target spelling the rest of them belong in. | ||
| 269 | try std.testing.expectError(error.BadByte, parseSpelling("a\nb")); | ||
| 270 | try std.testing.expectError(error.BadByte, parseSpelling("a\tb")); | ||
| 271 | try std.testing.expectError(error.BadByte, parseSpelling("a\rb")); | ||
| 272 | try std.testing.expectError(error.BadByte, parseSpelling("host#s\nevil")); | ||
| 273 | } | ||
| 274 | |||
| 275 | test "wall: add validates, remove frees, reorder is permutation-or-refused" { | ||
| 276 | const alloc = std.testing.allocator; | ||
| 277 | var w = Wall{}; | ||
| 278 | defer w.deinit(alloc); | ||
| 279 | _ = try w.add(alloc, "a"); | ||
| 280 | _ = try w.add(alloc, "b#s"); | ||
| 281 | _ = try w.add(alloc, "quic://c:1"); | ||
| 282 | try std.testing.expectError(error.BadSession, w.add(alloc, "d#bad name")); | ||
| 283 | // The add gate is why `load` can never meet a multi-line entry: a | ||
| 284 | // spelling holding '\n' never reaches the file to be split by it. | ||
| 285 | try std.testing.expectError(error.BadByte, w.add(alloc, "d\ne")); | ||
| 286 | try std.testing.expectEqual(@as(usize, 3), w.targets.items.len); | ||
| 287 | |||
| 288 | try w.reorder(alloc, &.{ 2, 0, 1 }); | ||
| 289 | try std.testing.expectEqualStrings("quic://c:1", w.targets.items[0]); | ||
| 290 | try std.testing.expectEqualStrings("a", w.targets.items[1]); | ||
| 291 | // Stale views are refused, not guessed at: wrong length, dup, range. | ||
| 292 | try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 1 })); | ||
| 293 | try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 0, 1 })); | ||
| 294 | try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 1, 3 })); | ||
| 295 | |||
| 296 | w.remove(alloc, 1); | ||
| 297 | try std.testing.expectEqual(@as(usize, 2), w.targets.items.len); | ||
| 298 | try std.testing.expectEqualStrings("b#s", w.targets.items[1]); | ||
| 299 | } | ||
| 300 | |||
| 301 | test "wall: save/load round-trip; missing file loads empty; bad line refuses" { | ||
| 302 | const testtmp = @import("testtmp"); | ||
| 303 | const alloc = std.testing.allocator; | ||
| 304 | var tmp = try testtmp.TmpDir.make(); | ||
| 305 | defer tmp.cleanup(); | ||
| 306 | |||
| 307 | const path = try std.fmt.allocPrint(alloc, "{s}/deep/wall", .{tmp.path()}); | ||
| 308 | defer alloc.free(path); | ||
| 309 | |||
| 310 | { | ||
| 311 | var missing = try load(alloc, path); | ||
| 312 | defer missing.deinit(alloc); | ||
| 313 | try std.testing.expectEqual(@as(usize, 0), missing.targets.items.len); | ||
| 314 | } | ||
| 315 | { | ||
| 316 | var w = Wall{}; | ||
| 317 | defer w.deinit(alloc); | ||
| 318 | _ = try w.add(alloc, "a#s"); | ||
| 319 | _ = try w.add(alloc, "--sock /tmp/x"); | ||
| 320 | try save(&w, path); // .make_path: the deep/ parent did not exist | ||
| 321 | } | ||
| 322 | { | ||
| 323 | var r = try load(alloc, path); | ||
| 324 | defer r.deinit(alloc); | ||
| 325 | try std.testing.expectEqual(@as(usize, 2), r.targets.items.len); | ||
| 326 | try std.testing.expectEqualStrings("a#s", r.targets.items[0]); | ||
| 327 | try std.testing.expectEqualStrings("--sock /tmp/x", r.targets.items[1]); | ||
| 328 | } | ||
| 329 | // A hand-edited line that no longer parses refuses the whole load. | ||
| 330 | try tmp.dir.writeFile(.{ .sub_path = "deep/wall", .data = "ok\nbad name#x y\n" }); | ||
| 331 | try std.testing.expectError(error.BadSession, load(alloc, path)); | ||
| 332 | |||
| 333 | // The BadByte refusal is per-LINE, so it cannot change what the | ||
| 334 | // tokenizer means: blank lines are still skipped, not refused. | ||
| 335 | try tmp.dir.writeFile(.{ .sub_path = "deep/wall", .data = "a\n\n\nb#s\n" }); | ||
| 336 | { | ||
| 337 | var r = try load(alloc, path); | ||
| 338 | defer r.deinit(alloc); | ||
| 339 | try std.testing.expectEqual(@as(usize, 2), r.targets.items.len); | ||
| 340 | try std.testing.expectEqualStrings("b#s", r.targets.items[1]); | ||
| 341 | } | ||
| 342 | } | ||
| 343 | |||
| 344 | test "record: appends once per spelling, dedups byte-exactly, keeps order" { | ||
| 345 | const testtmp = @import("testtmp"); | ||
| 346 | const alloc = std.testing.allocator; | ||
| 347 | var tmp = try testtmp.TmpDir.make(); | ||
| 348 | defer tmp.cleanup(); | ||
| 349 | |||
| 350 | // A path whose parent does not exist yet: the first attach of a fresh | ||
| 351 | // install has no `mux/` directory, and must not be the one that fails. | ||
| 352 | const path = try std.fmt.allocPrint(alloc, "{s}/deep/wall", .{tmp.path()}); | ||
| 353 | defer alloc.free(path); | ||
| 354 | |||
| 355 | try std.testing.expect(try record(alloc, path, "--sock /run/muxd.sock#0")); | ||
| 356 | try std.testing.expect(try record(alloc, path, "box#build")); | ||
| 357 | // The same spelling again is a no-op write, and says so. | ||
| 358 | try std.testing.expect(!try record(alloc, path, "--sock /run/muxd.sock#0")); | ||
| 359 | |||
| 360 | // The same SESSION under two spellings is two tiles: dedup is on the | ||
| 361 | // string, not on the identity behind it. | ||
| 362 | try std.testing.expect(try record(alloc, path, "quic://box:4433#build")); | ||
| 363 | |||
| 364 | var w = try load(alloc, path); | ||
| 365 | defer w.deinit(alloc); | ||
| 366 | try std.testing.expectEqual(@as(usize, 3), w.targets.items.len); | ||
| 367 | try std.testing.expectEqualStrings("--sock /run/muxd.sock#0", w.targets.items[0]); | ||
| 368 | try std.testing.expectEqualStrings("box#build", w.targets.items[1]); | ||
| 369 | try std.testing.expectEqualStrings("quic://box:4433#build", w.targets.items[2]); | ||
| 370 | } | ||
| 371 | |||
| 372 | test "record: an unwritable wall file is an error the caller may swallow" { | ||
| 373 | const testtmp = @import("testtmp"); | ||
| 374 | const alloc = std.testing.allocator; | ||
| 375 | var tmp = try testtmp.TmpDir.make(); | ||
| 376 | defer tmp.cleanup(); | ||
| 377 | // A directory where the file should be: the write cannot land, and the | ||
| 378 | // attach that called this must still happen (client.zig warns and goes | ||
| 379 | // on). Asserted here so "best effort" is a caught error, not a hope. | ||
| 380 | try tmp.dir.makePath("wall"); | ||
| 381 | const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{tmp.path()}); | ||
| 382 | defer alloc.free(path); | ||
| 383 | try std.testing.expectError(error.IsDir, record(alloc, path, "a#0")); | ||
| 384 | } | ||
| 385 | |||
| 386 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 387 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 388 | // decisions.md). | ||
| 389 | test { | ||
| 390 | std.testing.refAllDeclsRecursive(@This()); | ||
| 391 | } | ||
| 392 | |||
| 393 | test "parseSpelling: a HOST spelling is one word — the hub's POST body cannot smuggle a second" { | ||
| 394 | // The hub's POST /tiles body is a spelling, and a HOST spelling becomes | ||
| 395 | // one argv word of `handoff.recipeFor`'s ssh line. hosts.zig refuses | ||
| 396 | // these on the CLI's file; the browser mouth reaches the same ssh line | ||
| 397 | // and must be refused in the same words. | ||
| 398 | for ([_][]const u8{ | ||
| 399 | "box; touch /tmp/pwned", | ||
| 400 | "box&sleep 9", | ||
| 401 | "box|tee /tmp/x", | ||
| 402 | "box`id`", | ||
| 403 | "box$(id)", | ||
| 404 | "box$HOME", | ||
| 405 | "box>out", | ||
| 406 | "box<in", | ||
| 407 | "box 'two words'", | ||
| 408 | "box*glob", | ||
| 409 | "box\\esc", | ||
| 410 | }) |bad| try std.testing.expectError(error.BadSpelling, parseSpelling(bad)); | ||
| 411 | // The refusal is the HOST arm's: a socket path is dialed, never a word | ||
| 412 | // in an argv, and `--sock ` itself holds a space. | ||
| 413 | try std.testing.expectEqualStrings("/tmp/a b.sock", (try parseSpelling("--sock /tmp/a b.sock")).spec.sock); | ||
| 414 | } | ||
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -896,8 +896,8 @@ pub fn wireName(name: []const u8) []const u8 { | |||
| 896 | pub const sock_env = "MUX_SOCK"; | 896 | pub const sock_env = "MUX_SOCK"; |
| 897 | pub const session_env = "MUX_SESSION"; | 897 | pub const session_env = "MUX_SESSION"; |
| 898 | 898 | ||
| 899 | /// A name a user may spell: printable ASCII, no space; '#' is the hub's | 899 | /// A name a user may spell: printable ASCII, no space; '#' separates a |
| 900 | /// TARGET separator and '/' is reserved. The empty string is valid ON THE | 900 | /// label's host from its session, '/' is reserved. Empty is valid ON THE |
| 901 | /// WIRE (it means default) but not as a user-supplied name. | 901 | /// WIRE (it means default) but not as a user-supplied name. |
| 902 | pub fn validSessionName(name: []const u8) bool { | 902 | pub fn validSessionName(name: []const u8) bool { |
| 903 | if (name.len == 0 or name.len > session_name_max) return false; | 903 | if (name.len == 0 or name.len > session_name_max) return false; |
tools/docscheck.zig
| Old | New | ||
|---|---|---|---|
| @@ -397,7 +397,7 @@ pub fn main() !u8 { | |||
| 397 | const tok = body[i + 1 .. close]; | 397 | const tok = body[i + 1 .. close]; |
| 398 | i = close + 1; | 398 | i = close + 1; |
| 399 | if (classify(tok) != .check) continue; | 399 | if (classify(tok) != .check) continue; |
| 400 | // A backticked `wall.zig` is a file reference, and tier 1a | 400 | // A backticked `hosts.zig` is a file reference, and tier 1a |
| 401 | // above already owns those. Left here it reads as | 401 | // above already owns those. Left here it reads as |
| 402 | // `module.symbol` and asks whether `zig` is a symbol. | 402 | // `module.symbol` and asks whether `zig` is a symbol. |
| 403 | if (std.mem.endsWith(u8, tok, ".zig")) continue; | 403 | if (std.mem.endsWith(u8, tok, ".zig")) continue; |