a73x

ab0c737f

refactor: wall.zig is gone; the hosts file is the only wall

a73x   2026-08-29 12:14

Commit message
refactor: wall.zig is gone; the hosts file is the only wall

Nothing imports it: the grammar and the atomic writer moved to hosts.zig,
the layout sidecar to wall_layout, and the hub stopped reading the
`#SESSION` spellings. The `wall` state file is dead with it — an old
~/.local/state/mux/wall is ignored and may be deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

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;