a73x

5fa0d19a

build: no production line in client or tui spells /bin/sh

a73x   2026-08-29 00:35

Commit message
build: no production line in client or tui spells /bin/sh

Folder rule 4's scanner becomes a table of source bans, and rule 5 is a
row in it: the client and the tui may exec `ssh` and the user's `--via`
words, and nothing else, so a `/bin/sh -c` returning there fails the
build rather than the review.

The shell-metacharacter refusal on host spellings stays — a space is a
second tile in the wall grammar — but its stated reason no longer names a
shell that is not there.

build.zig
Old New
@@ -430,7 +430,7 @@ fn checkFolderRules(b: *std.Build) void {
430 ); 430 );
431 } 431 }
432 } 432 }
433 checkNoTerminalBytes(b); 433 for (source_bans) |ban| checkSourceBan(b, ban);
434 } 434 }
435 435
436 /// Runtime twin of `idxOf`, for the loops above that read the table as data. 436 /// Runtime twin of `idxOf`, for the loops above that read the table as data.
@@ -439,30 +439,56 @@ fn idx0(name: []const u8) usize {
439 fatal("module table: unknown module '{s}'", .{name}); 439 fatal("module table: unknown module '{s}'", .{name});
440 } 440 }
441 441
442 /// Folder rule 4: nothing under `src/engine/` or `src/client/` spells a 442 /// A byte a folder's PRODUCTION lines may not spell. The import graph
443 /// terminal. Emitting escapes and driving termios is the tui's job, and an 443 /// cannot catch a module that writes the bytes itself, so these read the
444 /// import graph cannot catch a module that writes the bytes itself — so this 444 /// sources the way the doc gate does.
445 /// reads the sources, the way the doc gate does.
446 /// 445 ///
447 /// `test` blocks are skipped: driving an engine with VT bytes is how a test 446 /// `test` blocks are skipped: driving an engine with VT bytes is how a test
448 /// speaks to a VT, and a fixture that types an escape is not the module owning 447 /// speaks to a VT, and a test that spawns a shell is testing what the
449 /// a terminal. Line arithmetic rather than a parser, sound for the doc gate's 448 /// product refuses to spawn. Line arithmetic rather than a parser, sound for
450 /// reason — `zig fmt --check` is already a gate, so a container-level `test` 449 /// the doc gate's reason — `zig fmt --check` is already a gate, so a
451 /// opens at column 0 and its `}` closes there and nowhere else. 450 /// container-level `test` opens at column 0 and its `}` closes there and
451 /// nowhere else.
452 /// 452 ///
453 /// The escape hatch for what remains is a line saying `folder rule 4 453 /// The escape hatch is a line saying `folder rule N exemption:` and why. It
454 /// exemption:` and why. It is per FILE and deliberately blunt: a file that has 454 /// is per FILE and deliberately blunt: a file that has to spell one of these
455 /// to spell VT bytes is a design fact worth one visible line, not a per-site 455 /// is a design fact worth one visible line, not a per-site suppression
456 /// suppression nobody reads. 456 /// nobody reads.
457 fn checkNoTerminalBytes(b: *std.Build) void { 457 const SourceBan = struct {
458 const needles = [_][]const u8{ "termios", "\\x1b[", "\\x1b]" }; 458 rule: []const u8,
459 for ([_][]const u8{ "src/engine", "src/client" }) |sub| { 459 folders: []const []const u8,
460 needles: []const []const u8,
461 /// What is wrong with spelling it, in the fatal's own voice.
462 why: []const u8,
463 };
464
465 const source_bans = [_]SourceBan{
466 .{
467 .rule = "4",
468 .folders = &.{ "src/engine", "src/client" },
469 .needles = &.{ "termios", "\\x1b[", "\\x1b]" },
470 .why = "driving a terminal is src/tui/'s job, and this module must " ++
471 "link into an app that paints its own way",
472 },
473 .{
474 .rule = "5",
475 .folders = &.{ "src/client", "src/tui" },
476 .needles = &.{ "\"/bin/sh\"", "\"-c\"" },
477 .why = "the only program the client runs is one the user named — " ++
478 "`ssh` from the handoff recipe, or `--via`'s own words — and it " ++
479 "is exec'd as argv, so no shell of ours ever parses it",
480 },
481 };
482
483 fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
484 const exempt = b.fmt("folder rule {s} exemption:", .{ban.rule});
485 for (ban.folders) |sub| {
460 var paths: std.ArrayList([]const u8) = .empty; 486 var paths: std.ArrayList([]const u8) = .empty;
461 zigFilesIn(b, sub, &paths); 487 zigFilesIn(b, sub, &paths);
462 for (paths.items) |path| { 488 for (paths.items) |path| {
463 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e| 489 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e|
464 fatal("folder rule 4: cannot read {s} ({s})", .{ path, @errorName(e) }); 490 fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) });
465 if (std.mem.indexOf(u8, src, "folder rule 4 exemption:") != null) continue; 491 if (std.mem.indexOf(u8, src, exempt) != null) continue;
466 var in_test = false; 492 var in_test = false;
467 var lineno: usize = 0; 493 var lineno: usize = 0;
468 var it = std.mem.splitScalar(u8, src, '\n'); 494 var it = std.mem.splitScalar(u8, src, '\n');
@@ -478,14 +504,12 @@ fn checkNoTerminalBytes(b: *std.Build) void {
478 in_test = true; 504 in_test = true;
479 continue; 505 continue;
480 } 506 }
481 for (needles) |n| { 507 for (ban.needles) |n| {
482 if (std.mem.indexOf(u8, line, n) != null) fatal( 508 if (std.mem.indexOf(u8, line, n) != null) fatal(
483 "folder rule 4 broken: {s}:{d} spells `{s}` outside a test " ++ 509 "folder rule {s} broken: {s}:{d} spells `{s}` outside a test " ++
484 "block — driving a terminal is src/tui/'s job, and {s} " ++ 510 "block — {s}. Move it, or write one line `// folder rule " ++
485 "must link into an app that paints its own way. Move the " ++ 511 "{s} exemption: <why>` in the file",
486 "bytes, or write one line `// folder rule 4 exemption: " ++ 512 .{ ban.rule, path, lineno, n, ban.why, ban.rule },
487 "<why>` in the file",
488 .{ path, lineno, n, sub },
489 ); 513 );
490 } 514 }
491 } 515 }
src/client/hosts.zig
Old New
@@ -27,7 +27,7 @@ pub fn parse(line: []const u8) ParseError!Spec {
27 } 27 }
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 // line 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.hasShellMeta(line)) return error.ShellMeta;
32 return .{ .host = line }; 32 return .{ .host = line };
33 } 33 }
@@ -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: the wall runs this spelling through /bin/sh", 49 error.ShellMeta => "shell metacharacter 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),
@@ -217,11 +217,11 @@ test "hosts.parse: three spellings classify; a '#' is refused by name" {
217 try std.testing.expect(std.mem.indexOf(u8, reason(error.HasSession), "daemons") != null); 217 try std.testing.expect(std.mem.indexOf(u8, reason(error.HasSession), "daemons") != null);
218 } 218 }
219 219
220 test "hosts.parse: a HOST spelling carries no shell metacharacter — the poll runs it through /bin/sh every second" { 220 test "hosts.parse: a HOST spelling is one word — the file's grammar is one host per line" {
221 // `handoff.sshLine` interpolates this word unquoted and `client` runs 221 // The word becomes one argv element of `handoff.recipeFor`'s ssh line,
222 // the result as `/bin/sh -c`, so a `;` in the file is a command the 222 // and the wall grammar splits on whitespace: a space here is a second
223 // wall executes once a second per host, unattended, under a 223 // tile nobody asked for, and the rest are punctuation that names no
224 // full-screen paint where its output is invisible. 224 // machine any resolver will answer.
225 for ([_][]const u8{ 225 for ([_][]const u8{
226 "box; touch /tmp/pwned", 226 "box; touch /tmp/pwned",
227 "box&sleep 9", 227 "box&sleep 9",
@@ -251,11 +251,11 @@ test "hosts.parse: a HOST spelling carries no shell metacharacter — the poll r
251 "eth0%1", 251 "eth0%1",
252 }) |ok| try std.testing.expectEqualStrings(ok, (try parse(ok)).host); 252 }) |ok| try std.testing.expectEqualStrings(ok, (try parse(ok)).host);
253 253
254 // Only the arm that reaches a shell. A socket path is `connect(2)`'s 254 // Only the arm that reaches ssh. A socket path is `connect(2)`'s
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 a command line, 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), "/bin/sh") != null); 258 try std.testing.expect(std.mem.indexOf(u8, reason(error.ShellMeta), "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
@@ -28,12 +28,12 @@ pub const Spec = union(enum) {
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, ShellMeta };
30 30
31 /// The bytes a HOST spelling may not hold. `handoff.sshLine` interpolates 31 /// The bytes a HOST spelling may not hold. The word is ONE argv element —
32 /// the word unquoted and `client.Transport.spawnPipe` runs the line as 32 /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall
33 /// `/bin/sh -c`, so anything here would be shell syntax a tile executes 33 /// grammar is whitespace-separated, so a space here is a second tile and
34 /// unattended, with the output painted over. `[user@]host` needs none of 34 /// the rest are punctuation no resolver will ever answer. `[user@]host`
35 /// them; `--via` is where an arbitrary command is the contract, and it is 35 /// needs none of them; `--via` is where an arbitrary command is the
36 /// not spellable as a target. 36 /// contract, and it is not spellable as a target.
37 const shell_meta = " \t;&|`$()<>'\"\\*?{}[]!~"; 37 const shell_meta = " \t;&|`$()<>'\"\\*?{}[]!~";
38 38
39 pub fn hasShellMeta(word: []const u8) bool { 39 pub fn hasShellMeta(word: []const u8) bool {
@@ -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: the tile runs this spelling through /bin/sh", 125 error.ShellMeta => "shell metacharacter 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),
@@ -748,11 +748,11 @@ test "spellingFromArgv: a flag is not a target" {
748 try std.testing.expectEqualStrings("--sock /tmp/x", whole.spelling); 748 try std.testing.expectEqualStrings("--sock /tmp/x", whole.spelling);
749 } 749 }
750 750
751 test "parseSpelling: a HOST spelling carries no shell metacharacter — the hub's POST body reaches /bin/sh" { 751 test "parseSpelling: a HOST spelling is one word — the hub's POST body cannot smuggle a second" {
752 // the hub's POST /tiles body is a spelling, and a HOST spelling ends up 752 // The hub's POST /tiles body is a spelling, and a HOST spelling becomes
753 // interpolated unquoted into `handoff.sshLine`, which `client` runs as 753 // one argv word of `handoff.recipeFor`'s ssh line. hosts.zig refuses
754 // `/bin/sh -c`. hosts.zig refuses these on the CLI's file; the browser 754 // these on the CLI's file; the browser mouth reaches the same ssh line
755 // mouth reaches the same ssh line and must be refused in the same words. 755 // and must be refused in the same words.
756 for ([_][]const u8{ 756 for ([_][]const u8{
757 "box; touch /tmp/pwned", 757 "box; touch /tmp/pwned",
758 "box&sleep 9", 758 "box&sleep 9",
@@ -766,7 +766,7 @@ test "parseSpelling: a HOST spelling carries no shell metacharacter — the hub'
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.ShellMeta, parseSpelling(bad));
769 // The refusal is the HOST arm's: a socket path is dialed, never spelled 769 // The refusal is the HOST arm's: a socket path is dialed, never a word
770 // into a command line, 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);
772 } 772 }