c9980623
build: folder rule 7 keeps every OS-specific spelling under src/os
a73x 2026-09-03 15:10
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -78,8 +78,13 @@ we built; its three markers are `flags.zig`, `main.zig` and | |||
| 78 | rather than a shell the product runs. Rule 6 is the one `posix.fork` site, | 78 | rather than a shell the product runs. Rule 6 is the one `posix.fork` site, |
| 79 | and it names its file in `except` rather than in the file. The three keep | 79 | and it names its file in `except` rather than in the file. The three keep |
| 80 | their numbers because the in-file `folder rule N exemption:` lines cite them | 80 | their numbers because the in-file `folder rule N exemption:` lines cite them |
| 81 | by number. Rule 4's three remaining debts are the markers in `engine.zig`, | 81 | by number. Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`, |
| 82 | `protocol.zig` and `keymap.zig`, each of which produces VT bytes by contract. | 82 | `close_range`, `exit_group`, the `SO_PEERCRED` socket option and the two |
| 83 | Linux-only pty ioctls may appear only under `src/os/`, comments included — | ||
| 84 | the roots `server_os.zig` and `client_os.zig` are the contract and their | ||
| 85 | `_linux` children the spellings (spec 2026-09-03). Rule 4's three remaining | ||
| 86 | debts are the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each | ||
| 87 | of which produces VT bytes by contract. | ||
| 83 | `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay | 88 | `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay |
| 84 | lives beside the code that paints it and nowhere `term` would find it. | 89 | lives beside the code that paints it and nowhere `term` would find it. |
| 85 | `spawn` lives under `src/os/` with the rest of the platform layer: asking | 90 | `spawn` lives under `src/os/` with the rest of the platform layer: asking |
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -135,8 +135,14 @@ throughput: | |||
| 135 | $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR) | 135 | $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR) |
| 136 | ./test/throughput.sh $(THRUDIR)/bin/mux $(THRUDIR)/bin/ptyclient | 136 | ./test/throughput.sh $(THRUDIR)/bin/mux $(THRUDIR)/bin/ptyclient |
| 137 | 137 | ||
| 138 | # `zig build check` grades the tree; bans.sh grades the grader. The folder | ||
| 139 | # rules are the one gate whose failure mode is silence — a needle list | ||
| 140 | # edited down to nothing, or a folder dropped from a rule's list, leaves a | ||
| 141 | # green tree asserting rules that no longer bite — so one planted needle per | ||
| 142 | # rule runs here, after the gate it is checking. Two seconds. | ||
| 138 | check: | 143 | check: |
| 139 | $(ZIG) build check | 144 | $(ZIG) build check |
| 145 | sh test/bans.sh $(ZIG) | ||
| 140 | 146 | ||
| 141 | # Everything a change must pass before delivery, cheapest gate first so a | 147 | # Everything a change must pass before delivery, cheapest gate first so a |
| 142 | # fmt slip fails in seconds rather than after the e2e suites. Sequential | 148 | # fmt slip fails in seconds rather than after the e2e suites. Sequential |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -392,6 +392,27 @@ const source_bans = [_]SourceBan{ | |||
| 392 | "client that forked a daemon would be choosing the daemon's " ++ | 392 | "client that forked a daemon would be choosing the daemon's " ++ |
| 393 | "flags, its log and its refusals, none of which it can see", | 393 | "flags, its log and its refusals, none of which it can see", |
| 394 | }, | 394 | }, |
| 395 | .{ | ||
| 396 | .rule = "7", | ||
| 397 | .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" }, | ||
| 398 | // The raw spellings the platform layer exists to hold. `src/os/` is | ||
| 399 | // absent from the list on purpose: its children may spell anything, | ||
| 400 | // and its roots have no reason to. Comments count, as they do for | ||
| 401 | // rule 4 — a comment naming a Linux mechanism is one that goes | ||
| 402 | // stale the day a second arm exists. | ||
| 403 | // Four of the needles are spelled to catch a name in both the form | ||
| 404 | // Zig writes it and the form C and our own prose do. `so.peercred` | ||
| 405 | // and `so_peercred` are `std.posix.SO.PEERCRED` and `SO_PEERCRED`; a | ||
| 406 | // bare `peercred` would ban `client_os.peerCred`, the very operation | ||
| 407 | // callers are supposed to reach for. `iocsptlck` and `iocgptn` drop | ||
| 408 | // the leading T so they catch `std.posix.T.IOCGPTN` as well as | ||
| 409 | // `TIOCGPTN` — `std.posix.T` exists, so that first spelling names no | ||
| 410 | // `std.os.linux` and would otherwise be a Linux-ism that passes. | ||
| 411 | .needles = &.{ "std.os.linux", "/proc", "memfd", "close_range", "exit_group", "so.peercred", "so_peercred", "iocsptlck", "iocgptn" }, | ||
| 412 | .why = "a call whose spelling differs by OS belongs in src/os/, behind a " ++ | ||
| 413 | "server_os or client_os operation whose doc names what it guarantees; " ++ | ||
| 414 | "everything else builds for every OS from the same line", | ||
| 415 | }, | ||
| 395 | }; | 416 | }; |
| 396 | 417 | ||
| 397 | fn checkSourceBan(b: *std.Build, ban: SourceBan) void { | 418 | fn checkSourceBan(b: *std.Build, ban: SourceBan) void { |
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -855,11 +855,12 @@ fn remoteUpgradeCmd(alloc: std.mem.Allocator, host: []const u8, allow_same: bool | |||
| 855 | push.stdout_behavior = .Inherit; | 855 | push.stdout_behavior = .Inherit; |
| 856 | push.stderr_behavior = .Inherit; | 856 | push.stderr_behavior = .Inherit; |
| 857 | try push.spawn(); | 857 | try push.spawn(); |
| 858 | // /proc/self/exe rather than a saved path: the bytes streamed are the | 858 | // The RUNNING image rather than a saved path: the bytes streamed are |
| 859 | // image RUNNING here, the only thing "push this binary" can honestly | 859 | // what is executing here, the only thing "push this binary" can honestly |
| 860 | // mean, even if the file it was started from has since been replaced. | 860 | // mean, even if the file it was started from has since been replaced. |
| 861 | // `openSelfExe` is the portable spelling of that ask. | ||
| 861 | const streamed: bool = blk: { | 862 | const streamed: bool = blk: { |
| 862 | var img = std.fs.openFileAbsolute("/proc/self/exe", .{}) catch break :blk false; | 863 | var img = std.fs.openSelfExe(.{}) catch break :blk false; |
| 863 | defer img.close(); | 864 | defer img.close(); |
| 864 | var buf: [64 * 1024]u8 = undefined; | 865 | var buf: [64 * 1024]u8 = undefined; |
| 865 | while (true) { | 866 | while (true) { |
| @@ -941,7 +942,7 @@ fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight { | |||
| 941 | fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { | 942 | fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { |
| 942 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | 943 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 943 | const exe = std.fs.selfExePath(&exe_buf) catch { | 944 | const exe = std.fs.selfExePath(&exe_buf) catch { |
| 944 | std.debug.print("mux d upgrade: cannot find own binary via /proc/self/exe\n", .{}); | 945 | std.debug.print("mux d upgrade: cannot find own image\n", .{}); |
| 945 | return 1; | 946 | return 1; |
| 946 | }; | 947 | }; |
| 947 | 948 | ||
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -917,7 +917,7 @@ pub const SessionsIter = struct { | |||
| 917 | /// paints as nothing. | 917 | /// paints as nothing. |
| 918 | pub const sessions_meta_prefix = "# mux "; | 918 | pub const sessions_meta_prefix = "# mux "; |
| 919 | /// `stale` is the daemon reporting that the installed binary on its OWN box | 919 | /// `stale` is the daemon reporting that the installed binary on its OWN box |
| 920 | /// was replaced under it (its /proc/self/exe reads deleted): the one drift a | 920 | /// was replaced under it (`server_os.selfImageStale`): the one drift a |
| 921 | /// version string cannot show, because two builds of one dev version spell | 921 | /// version string cannot show, because two builds of one dev version spell |
| 922 | /// the same version. | 922 | /// the same version. |
| 923 | pub const sessions_meta_stale_word = " stale"; | 923 | pub const sessions_meta_stale_word = " stale"; |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -1878,10 +1878,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1878 | .wake = ringKeyboardCtx, | 1878 | .wake = ringKeyboardCtx, |
| 1879 | })) |l| { | 1879 | })) |l| { |
| 1880 | shared.prompts = l; | 1880 | shared.prompts = l; |
| 1881 | // The image that is RUNNING, resolved through /proc, and | 1881 | // The image that is RUNNING, resolved by `spawn.selfExe` |
| 1882 | // never a PATH walk: ssh execs this as its helper, and a | 1882 | // and never a PATH walk: ssh execs this as its helper, and |
| 1883 | // helper found by name is whichever mux the environment | 1883 | // a helper found by name is whichever mux the environment |
| 1884 | // happens to have (`spawn.selfExe`). | 1884 | // happens to have. |
| 1885 | shared.prompt_exe = spawn.selfExe(&ask_exe_buf); | 1885 | shared.prompt_exe = spawn.selfExe(&ask_exe_buf); |
| 1886 | } else |_| {} | 1886 | } else |_| {} |
| 1887 | } | 1887 | } |
test/bans.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,115 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # bans.sh — the folder rules' own pin. `make check` runs it after | ||
| 3 | # `zig build check`. | ||
| 4 | # | ||
| 5 | # build.zig's `checkSourceBan` reads the production lines of every file | ||
| 6 | # under src/ for bytes no import graph can catch. Nothing asserted that it | ||
| 7 | # still bites: a needle list edited down to nothing, a folder list that | ||
| 8 | # stopped naming a folder, or a `test` skip widened to swallow production | ||
| 9 | # lines would all leave a green tree saying the rules hold. This plants ONE | ||
| 10 | # representative needle per rule in a scratch file under one banned folder | ||
| 11 | # and requires the refusal by name, then plants one INSIDE a test block and | ||
| 12 | # requires no refusal at all — the skip is as load-bearing as the ban, since | ||
| 13 | # a test that drives a VT with escape bytes must stay legal. | ||
| 14 | # | ||
| 15 | # The scratch file is removed by an EXIT trap, so a failing assertion never | ||
| 16 | # leaves a planted needle behind to refuse every later build in the tree. | ||
| 17 | set -u | ||
| 18 | |||
| 19 | ZIG="${1:-deps/zig/zig}" | ||
| 20 | ROOT=$(cd "$(dirname "$0")/.." && pwd) | ||
| 21 | cd "$ROOT" || exit 1 | ||
| 22 | [ -x "$ZIG" ] || { echo "bans FAIL: no zig at $ZIG (pass it as \$1)"; exit 1; } | ||
| 23 | |||
| 24 | PROBE="" | ||
| 25 | cleanup() { [ -z "$PROBE" ] || rm -f "$PROBE"; } | ||
| 26 | trap cleanup EXIT INT TERM | ||
| 27 | |||
| 28 | FAILED=0 | ||
| 29 | |||
| 30 | # plant FOLDER BODY — a fresh .zig file under FOLDER holding BODY. The | ||
| 31 | # basename is this run's pid and carries no prefix any other build check | ||
| 32 | # claims: `server_test_` would trip checkSiblingTestsReached first and the | ||
| 33 | # rule under test would never be reached. | ||
| 34 | plant() { | ||
| 35 | PROBE="$1/zz_bans_probe_$$.zig" | ||
| 36 | printf '%s\n' "$2" > "$PROBE" | ||
| 37 | } | ||
| 38 | |||
| 39 | unplant() { | ||
| 40 | [ -z "$PROBE" ] || rm -f "$PROBE" | ||
| 41 | PROBE="" | ||
| 42 | } | ||
| 43 | |||
| 44 | # must_break RULE FOLDER BODY — the planted needle has to stop the build | ||
| 45 | # with that rule's own fatal. | ||
| 46 | # | ||
| 47 | # `zig build check` is the gate's own spelling and is what runs here. | ||
| 48 | # `checkSourceBans` is called from `build(b)`, so the refusal happens while | ||
| 49 | # the build graph is still being constructed: no step runs, no test is | ||
| 50 | # compiled, and the whole call costs about as long as reading src/. | ||
| 51 | must_break() { | ||
| 52 | _rule="$1" | ||
| 53 | plant "$2" "$3" | ||
| 54 | _out=$("$ZIG" build check 2>&1) | ||
| 55 | _rc=$? | ||
| 56 | unplant | ||
| 57 | if [ "$_rc" -eq 0 ]; then | ||
| 58 | echo "bans FAIL: rule $_rule: a needle planted in $2 built cleanly (rc 0)." | ||
| 59 | echo " The rule is not reading that folder's production lines." | ||
| 60 | FAILED=1 | ||
| 61 | return | ||
| 62 | fi | ||
| 63 | case "$_out" in | ||
| 64 | *"folder rule $_rule broken"*) | ||
| 65 | echo "bans ok: rule $_rule refuses its needle in $2" ;; | ||
| 66 | *) | ||
| 67 | echo "bans FAIL: rule $_rule: the build failed, but not with" | ||
| 68 | echo " 'folder rule $_rule broken'. What it said:" | ||
| 69 | echo "$_out" | tail -20 | ||
| 70 | FAILED=1 ;; | ||
| 71 | esac | ||
| 72 | } | ||
| 73 | |||
| 74 | # must_skip FOLDER BODY — the same needles inside a `test` block are legal, | ||
| 75 | # and the build must not so much as mention a rule. | ||
| 76 | # | ||
| 77 | # `zig build --help` rather than `zig build check`: this case has to reach | ||
| 78 | # the END of `build(b)` to prove nothing fatalled, and `check` would then | ||
| 79 | # go on to run the whole unit suite to answer a question the configure pass | ||
| 80 | # has already answered. `--help` constructs the identical graph — same | ||
| 81 | # `build(b)`, same `checkSourceBans` call — and runs no step. | ||
| 82 | must_skip() { | ||
| 83 | plant "$1" "$2" | ||
| 84 | _out=$("$ZIG" build --help 2>&1) | ||
| 85 | _rc=$? | ||
| 86 | unplant | ||
| 87 | if [ "$_rc" -ne 0 ] || [ "${_out#*folder rule}" != "$_out" ]; then | ||
| 88 | echo "bans FAIL: a needle inside a test block was refused (rc $_rc)." | ||
| 89 | echo " The test skip is what lets a test drive a VT or spawn" | ||
| 90 | echo " a shell; without it those tests cannot be written." | ||
| 91 | echo "$_out" | tail -20 | ||
| 92 | FAILED=1 | ||
| 93 | else | ||
| 94 | echo "bans ok: a needle inside a test block is skipped" | ||
| 95 | fi | ||
| 96 | } | ||
| 97 | |||
| 98 | # One needle per rule, each in a folder that rule names, and each chosen so | ||
| 99 | # no EARLIER rule matches the same line — the checker fatals on the first | ||
| 100 | # hit, so a body that tripped rule 4 would say nothing about rule 7. | ||
| 101 | must_break 4 src/client 'const probe = "isatty";' | ||
| 102 | must_break 5 src/engine 'const probe = "/bin/sh";' | ||
| 103 | must_break 6 src/server 'const probe = std.posix.fork();' | ||
| 104 | must_break 7 src/server 'const probe = std.os.linux.O.RDONLY;' | ||
| 105 | |||
| 106 | # A container-level `test` opens at column 0 and its `}` closes there, | ||
| 107 | # which is the line arithmetic checkSourceBan relies on and `zig fmt | ||
| 108 | # --check` already guarantees. | ||
| 109 | must_skip src/server 'test "bans probe" { | ||
| 110 | const probe = std.os.linux.O.RDONLY; | ||
| 111 | _ = probe; | ||
| 112 | }' | ||
| 113 | |||
| 114 | [ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; } | ||
| 115 | echo "bans: every folder rule bit, and the test skip held" | ||