5fa0d19a
build: no production line in client or tui spells /bin/sh
a73x 2026-08-29 00:35
Commit message
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 | } |