a73x

e3ad4ec2

refactor: the refusal is named for the grammar it enforces, not the defence it outlived

a73x   2026-08-29 01:13

Commit message
refactor: the refusal is named for the grammar it enforces, not the defence it outlived

A host spelling holding punctuation is refused because the wall grammar is
whitespace-separated and no resolver answers the rest — nothing to do with
a shell, which nothing here has run since the recipe became argv. Same
bytes refused, same assertions.

src/client/hosts.zig
Old New
@@ -12,7 +12,7 @@ const std = @import("std");
12 const wall = @import("wall"); 12 const wall = @import("wall");
13 13
14 pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 }; 14 pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 };
15 pub const ParseError = error{ HasSession, EmptySpec, BadByte, ShellMeta }; 15 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling };
16 16
17 pub fn parse(line: []const u8) ParseError!Spec { 17 pub fn parse(line: []const u8) ParseError!Spec {
18 for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte; 18 for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte;
@@ -28,7 +28,7 @@ pub fn parse(line: []const u8) ParseError!Spec {
28 if (line.len == 0) return error.EmptySpec; 28 if (line.len == 0) return error.EmptySpec;
29 // wall.zig owns the byte set: the hub's POST body reaches the same ssh 29 // wall.zig owns the byte set: the hub's POST body reaches the same ssh
30 // argv this file's poll does, so one refusal serves both mouths. 30 // argv this file's poll does, so one refusal serves both mouths.
31 if (wall.hasShellMeta(line)) return error.ShellMeta; 31 if (wall.hasBadSpelling(line)) return error.BadSpelling;
32 return .{ .host = line }; 32 return .{ .host = line };
33 } 33 }
34 34
@@ -46,7 +46,7 @@ pub fn reason(err: anyerror) []const u8 {
46 error.HasSession => "names a session after '#': the wall lists daemons and shows every session they have", 46 error.HasSession => "names a session after '#': the wall lists daemons and shows every session they have",
47 error.EmptySpec => "empty host", 47 error.EmptySpec => "empty host",
48 error.BadByte => "control byte in host", 48 error.BadByte => "control byte in host",
49 error.ShellMeta => "shell metacharacter in host: a host line names a machine, not a command", 49 error.BadSpelling => "punctuation in host: a host line names a machine, not a command",
50 error.MissingSockPath => "names no path", 50 error.MissingSockPath => "names no path",
51 error.SockPathTooLong => "socket path too long to bind", 51 error.SockPathTooLong => "socket path too long to bind",
52 else => @errorName(err), 52 else => @errorName(err),
@@ -234,7 +234,7 @@ test "hosts.parse: a HOST spelling is one word — the file's grammar is one hos
234 "box 'two words'", 234 "box 'two words'",
235 "box*glob", 235 "box*glob",
236 "box\\esc", 236 "box\\esc",
237 }) |bad| try std.testing.expectError(error.ShellMeta, parse(bad)); 237 }) |bad| try std.testing.expectError(error.BadSpelling, parse(bad));
238 // A tab is a word separator too, and the control-byte rule reaches it 238 // A tab is a word separator too, and the control-byte rule reaches it
239 // first — the refusal is what matters, not which rule spoke. 239 // first — the refusal is what matters, not which rule spoke.
240 try std.testing.expectError(error.BadByte, parse("box\ttab")); 240 try std.testing.expectError(error.BadByte, parse("box\ttab"));
@@ -255,7 +255,7 @@ test "hosts.parse: a HOST spelling is one word — the file's grammar is one hos
255 // business and a quic spelling is `quic.resolveHost`'s; neither is ever 255 // business and a quic spelling is `quic.resolveHost`'s; neither is ever
256 // a word in an argv, and a path with a space is legal. 256 // a word in an argv, and a path with a space is legal.
257 try std.testing.expectEqualStrings("/tmp/my sock", (try parse("--sock /tmp/my sock")).sock); 257 try std.testing.expectEqualStrings("/tmp/my sock", (try parse("--sock /tmp/my sock")).sock);
258 try std.testing.expect(std.mem.indexOf(u8, reason(error.ShellMeta), "not a command") != null); 258 try std.testing.expect(std.mem.indexOf(u8, reason(error.BadSpelling), "not a command") != null);
259 } 259 }
260 260
261 test "hosts: add dedups, load/save round-trip two hosts in order, and a doubled line loads once" { 261 test "hosts: add dedups, load/save round-trip two hosts in order, and a doubled line loads once" {
src/client/wall.zig
Old New
@@ -26,7 +26,7 @@ pub const Spec = union(enum) {
26 }; 26 };
27 27
28 pub const Parsed = struct { spec: Spec, session: []const u8 }; 28 pub const Parsed = struct { spec: Spec, session: []const u8 };
29 pub const ParseError = error{ BadSession, EmptySpec, BadByte, ShellMeta }; 29 pub const ParseError = error{ BadSession, EmptySpec, BadByte, BadSpelling };
30 30
31 /// The bytes a HOST spelling may not hold. The word is ONE argv element — 31 /// The bytes a HOST spelling may not hold. The word is ONE argv element —
32 /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall 32 /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall
@@ -34,10 +34,10 @@ pub const ParseError = error{ BadSession, EmptySpec, BadByte, ShellMeta };
34 /// the rest are punctuation no resolver will ever answer. `[user@]host` 34 /// the rest are punctuation no resolver will ever answer. `[user@]host`
35 /// needs none of them; `--via` is where an arbitrary command is the 35 /// needs none of them; `--via` is where an arbitrary command is the
36 /// contract, and it is not spellable as a target. 36 /// contract, and it is not spellable as a target.
37 const shell_meta = " \t;&|`$()<>'\"\\*?{}[]!~"; 37 const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~";
38 38
39 pub fn hasShellMeta(word: []const u8) bool { 39 pub fn hasBadSpelling(word: []const u8) bool {
40 for (word) |b| if (std.mem.indexOfScalar(u8, shell_meta, b) != null) return true; 40 for (word) |b| if (std.mem.indexOfScalar(u8, unspellable, b) != null) return true;
41 return false; 41 return false;
42 } 42 }
43 43
@@ -78,7 +78,7 @@ pub fn parseSpelling(line: []const u8) ParseError!Parsed {
78 return .{ .spec = .{ .quic = hp }, .session = session }; 78 return .{ .spec = .{ .quic = hp }, .session = session };
79 } 79 }
80 if (spec_str.len == 0) return error.EmptySpec; 80 if (spec_str.len == 0) return error.EmptySpec;
81 if (hasShellMeta(spec_str)) return error.ShellMeta; 81 if (hasBadSpelling(spec_str)) return error.BadSpelling;
82 return .{ .spec = .{ .host = spec_str }, .session = session }; 82 return .{ .spec = .{ .host = spec_str }, .session = session };
83 } 83 }
84 84
@@ -122,7 +122,7 @@ pub fn reason(err: anyerror) []const u8 {
122 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", 122 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')",
123 error.EmptySpec => "empty target", 123 error.EmptySpec => "empty target",
124 error.BadByte => "control byte in target", 124 error.BadByte => "control byte in target",
125 error.ShellMeta => "shell metacharacter in host: a tile spelling names a machine, not a command", 125 error.BadSpelling => "punctuation in host: a tile spelling names a machine, not a command",
126 error.MissingSockPath => "names no path", 126 error.MissingSockPath => "names no path",
127 error.SockPathTooLong => "socket path too long to bind", 127 error.SockPathTooLong => "socket path too long to bind",
128 else => @errorName(err), 128 else => @errorName(err),
@@ -765,7 +765,7 @@ test "parseSpelling: a HOST spelling is one word — the hub's POST body cannot
765 "box 'two words'", 765 "box 'two words'",
766 "box*glob", 766 "box*glob",
767 "box\\esc", 767 "box\\esc",
768 }) |bad| try std.testing.expectError(error.ShellMeta, parseSpelling(bad)); 768 }) |bad| try std.testing.expectError(error.BadSpelling, parseSpelling(bad));
769 // The refusal is the HOST arm's: a socket path is dialed, never a word 769 // The refusal is the HOST arm's: a socket path is dialed, never a word
770 // in an argv, and `--sock ` itself holds a space. 770 // in an argv, and `--sock ` itself holds a space.
771 try std.testing.expectEqualStrings("/tmp/a b.sock", (try parseSpelling("--sock /tmp/a b.sock")).spec.sock); 771 try std.testing.expectEqualStrings("/tmp/a b.sock", (try parseSpelling("--sock /tmp/a b.sock")).spec.sock);
src/client/webhub.zig
Old New
@@ -1133,7 +1133,7 @@ pub fn serveConn(
1133 error.BadSession => .{ .bad_request, "bad session name after '#'\n" }, 1133 error.BadSession => .{ .bad_request, "bad session name after '#'\n" },
1134 error.EmptySpec => .{ .bad_request, "empty target\n" }, 1134 error.EmptySpec => .{ .bad_request, "empty target\n" },
1135 error.BadByte => .{ .bad_request, "control byte in target\n" }, 1135 error.BadByte => .{ .bad_request, "control byte in target\n" },
1136 error.ShellMeta => .{ .bad_request, "shell metacharacter in host: a tile spelling names a machine, not a command\n" }, 1136 error.BadSpelling => .{ .bad_request, "punctuation in host: a tile spelling names a machine, not a command\n" },
1137 error.MissingKey => .{ .bad_request, "no key for quic:// target (mux d keygen, or MUX_KEY_FILE)\n" }, 1137 error.MissingKey => .{ .bad_request, "no key for quic:// target (mux d keygen, or MUX_KEY_FILE)\n" },
1138 error.SockPathTooLong => .{ .bad_request, "socket path too long\n" }, 1138 error.SockPathTooLong => .{ .bad_request, "socket path too long\n" },
1139 // The tile is NOT live: addTile rolls back on a 1139 // The tile is NOT live: addTile rolls back on a