From da74ae645b76ad04a6ba84b35f2d688376840ef5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 13 Aug 2026 20:41:18 +0200 Subject: [PATCH] fix(test): the multibyte witness had the wrong axis, and CI proved it CI went red on three Test jobs with the helper's own panic. That is the loud failure working as designed --- and the design was wrong. `${#x}` counting characters is a property of the SHELL first and the locale second. `bash` counts characters under a UTF-8 locale; `dash` counts bytes under EVERY locale, having no multibyte handling at all. `/bin/sh` is `bash` on Arch and `dash` on the Ubuntu runners, so probing `/bin/sh` and naming a locale established nothing there: no pair could qualify, and the row could not run. The helper now probes `(shell, locale)` pairs and the row invokes the gate THROUGH the qualifying shell. `/bin/sh` is tried first so the real interpreter is used wherever it qualifies. This is not a contrivance to get green: `#!/bin/sh` resolves to `bash` on Arch and on macOS, which is precisely where a `${#VAR}` guard would miscount, and it is the configuration the guard exists for. Renumbered, because `M-G-8` was taken. Round 3 assigned it to the canonical-traversal mutation and the ledger never recorded it, so the locale exercise reusing the ID was a collision. Canonical `M-G-8` is restored to the ledger; the locale legs are `M-G-9a-c`. Nine total. 9a mutant gate, probed pair -> row fails, boundary row still passes. Re-run with /bin/sh EXCLUDED, covering the dash/CI fallback path -> still fails. 9b SAME mutant gate, pair forced byte-counting -> row passes. The defect reproduced rather than argued. 9c no pair qualifies -> panic naming shells and locales tried Record corrections review asked for: - framing said three rounds and revisions 6a-6c; history is rounds 1-4 plus this follow-up, and each round is now named for what it fixed - framing SS2a claimed `${#var}` counts characters under UTF-8 with no qualifier --- the same error as the helper's. It now states the shell dependence and why the guard measures bytes explicitly. - the helper's prose said every candidate comes from `locale -a` while the code also tried two hardcoded spellings; the doc comment now describes what the code does Gates: all nine green under `env -u TMPDIR`, log 20260813T183646Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 48 ++++++++++-------- docs/gate-script-framing.md | 31 ++++++++---- tests/gate_script_acceptance.rs | 90 ++++++++++++++++++++------------- 3 files changed, 101 insertions(+), 68 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index f4bece7..6def70f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -328,29 +328,33 @@ from #171 and #215. is why that row covers a `Cargo.toml` **directory** as well as both `.git` shapes. `M-G-6` counts characters → the multibyte row. `M-G-7` moves the trap back after the guards → the - rejection-cleanup row. Propagation is observed in a **spawned - child**, because the gate exporting a variable would only prove the - gate can export a variable. -- **`M-G-8` is the one that proves `M-G-6` is not vacuous**, and it + rejection-cleanup row. `M-G-8` restores the old + `for _anc in $(...)` loop → the canonical-walk row, which is what + proves the traversal neither word-splits a space-bearing root nor + misses a marker visible only after `pwd -P`. Propagation is observed + in a **spawned child**, because the gate exporting a variable would + only prove the gate can export a variable. +- **`M-G-9` is the one that proves `M-G-6` is not vacuous**, and it needs three legs because the hazard is in the *environment*, not the - code. The multibyte row originally set `LC_ALL=C.UTF-8` and assumed - it took: locale names beyond `C` and `POSIX` are - implementation-defined, so on a machine lacking that locale the shell - falls back to **byte** semantics — and then the character-counting - mutant counts bytes too, agrees with the fix, and the row passes - while proving nothing. - - **8a** — mutant gate, locale chosen by the probe → the row - **fails**, and the exact-boundary row still passes. - - **8b** — *same mutant gate*, locale forced to `C` → the row - **passes**. This is the defect itself, reproduced rather than - argued: the only difference between a real witness and a vacuous - one is whether the shell counts characters. - - **8c** — no candidate can qualify → the helper **panics** naming - what it tried. A skip here would be indistinguishable from a pass. - The locale is therefore selected by **behaviour**: candidates come - from `locale -a`, and each is probed through the same `/bin/sh` the - gate runs under, asking `${#x}` on a two-byte character and requiring - `1`. + code. **Nine mutations in total.** + - **9a** — mutant gate, probed pair → the row **fails**, and the + exact-boundary row still passes. Re-run with `/bin/sh` excluded, so + the CI fallback path is covered too: still fails. + - **9b** — *same mutant gate*, pair forced to byte-counting → the row + **passes**. The defect itself, reproduced rather than argued. + - **9c** — no pair can qualify → the helper **panics** naming what it + tried. A skip would be indistinguishable from a pass. +- **The multibyte row's axis was wrong, and CI is what proved it.** The + row set `LC_ALL=C.UTF-8` and assumed a character count followed. + `${#x}` counting characters is a property of the **shell** first: + `bash` counts characters under a UTF-8 locale, **`dash` counts bytes + under every locale**. `/bin/sh` is `bash` here and `dash` on the + Ubuntu runners, so the row panicked on CI — the loud failure working + as designed, but on a machine where the distinction is unobservable. + The helper now probes `(shell, locale)` pairs and invokes the gate + **through** the qualifying shell. That is not a contrivance: `#!/bin/sh` + resolves to `bash` on Arch **and on macOS**, which is exactly where a + `${#VAR}` guard would miscount. - **Proved against the live hazard:** `/tmp/.git` is still present on this machine, and the tests it reddened now pass with **no override**. - **Gates:** `./scripts/gate --acceptance gate_script_acceptance`, run diff --git a/docs/gate-script-framing.md b/docs/gate-script-framing.md index 9ee929d..359ee33 100644 --- a/docs/gate-script-framing.md +++ b/docs/gate-script-framing.md @@ -7,15 +7,18 @@ gate isolates", and `TMPDIR` was simply missing from that list — which is how a stray `/tmp/.git` came to redden a gate run on an unrelated lane. -**Approved after three review rounds, all of which turned on evidence -rather than design.** Revisions 6a–6c tightened the socket budget to -the Darwin floor, replaced an existence-only ancestor check with one -that honours marker types, made the traversal canonical, moved the -guard rows onto the exact boundary, and — last — required the -byte-versus-character row to *establish* its locale precondition -instead of naming one. Each correction was a witness asserting -something adjacent to the contract while appearing to assert the -contract itself. +**Approved after four review rounds plus a locale follow-up, all of +which turned on evidence rather than design.** Round 1 corrected a +propagation witness that proved only that the variable was used, and +two wrong guard reserves. Round 2 tightened the socket budget to the +Darwin floor and replaced an existence-only ancestor check with one +that honours marker types. Round 3 made the traversal canonical and +gave the length guard its first real witnesses. Round 4 moved those +rows onto the exact boundary, covered both managed areas on cleanup, +and withdrew an unsupported causal claim. The follow-up required the +byte-versus-character row to *establish* its precondition rather than +name one. Each correction was a witness asserting something adjacent +to the contract while appearing to assert the contract itself. **Previously, revision 5. Approved at revision 4 and IMPLEMENTED; revision 5 records two safety defects review found in the implementation.** @@ -477,8 +480,14 @@ Four decisions inside that, each of which had a cheaper wrong answer: the symptom appear deep in a suite as a limit with no cause: - a **byte-counted** length check reserving the measured maximum suffix (`/.tmpXXXXXX/directory-target.sock`, 33 bytes) plus - headroom — byte-counted because `${#var}` counts *characters* - under UTF-8 while `sun_path` is byte-limited; + headroom — byte-counted because `sun_path` is byte-limited while + `${#var}` counts *characters* in a shell that handles multibyte. + **Which shell runs the script decides this**: `bash` counts + characters under a UTF-8 locale, `dash` counts bytes under every + locale, and `#!/bin/sh` is `bash` on Arch and macOS but `dash` on + Debian and Ubuntu. The count must not depend on that, so the guard + measures bytes explicitly (`printf | LC_ALL=C wc -c`) rather than + relying on the interpreter it happens to get; - an **ancestor-marker check**, because **a managed root is not inherently marker-free**: a `.git` in `$HOME`, a marker above `$HOME/build`, or a contaminated `PMACS_GATE_TARGET_ROOT` rebuilds diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index b073978..15b36b5 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -834,14 +834,21 @@ fn the_socket_path_guard_counts_bytes_not_characters() { bytes (must fail), path {path}" ); - // **Chosen by BEHAVIOUR, not by name.** Locale names beyond `C` and - // `POSIX` are implementation-defined and an unrecognized value has - // unspecified behaviour, so setting `LC_ALL=C.UTF-8` and hoping is - // not a precondition — where that locale is absent the shell would - // fall back to byte semantics and the character-counting mutant - // would pass, silently. - let locale = char_counting_locale(); - let out = std::process::Command::new(gate()) + // **Chosen by BEHAVIOUR, not by name**, and the interpreter is part + // of the choice: `${#x}` counting characters is a property of the + // SHELL first and the locale second. `bash` counts characters under + // a UTF-8 locale; `dash` counts bytes under every locale. Naming a + // locale and running `/bin/sh` therefore proves nothing on its own — + // where `/bin/sh` is `dash`, the character-counting mutant measures + // bytes too, agrees with the fix, and this row passes vacuously. + // + // The gate is invoked THROUGH that shell rather than by its + // shebang, because the configuration being pinned is a real one: + // `#!/bin/sh` resolves to `bash` on Arch and on macOS, which is + // exactly where a `${#VAR}` guard would miscount. + let (shell, locale) = char_counting_shell(); + let out = std::process::Command::new(&shell) + .arg(gate()) .arg("--self-test") .current_dir(repo_root()) .env("PMACS_GATE_TARGET_ROOT", root.path()) @@ -860,27 +867,35 @@ fn the_socket_path_guard_counts_bytes_not_characters() { ); } -/// A locale under which **`/bin/sh` counts CHARACTERS** — verified by -/// asking that very shell, not by trusting a name. +/// A `(shell, locale)` pair under which **`${#x}` counts CHARACTERS** — +/// established by asking that very shell, never by trusting a name. /// -/// `${#x}` on a two-byte character answers `1` under a working UTF-8 -/// locale and `2` under `C`. That difference is the entire subject of -/// the row this serves, so the row must establish it as a precondition -/// rather than assume it: otherwise, on a machine without the named -/// locale, the shell counts bytes, the mutant agrees with the fix, and -/// the test passes while proving nothing. +/// **Both axes matter, and the shell matters more.** `${#x}` on a +/// two-byte character answers `1` under `bash` with a UTF-8 locale and +/// `2` under `bash` with `C` — but `dash` answers `2` under *every* +/// locale, because it has no multibyte handling at all. So naming a +/// locale and invoking `/bin/sh` establishes nothing: where `/bin/sh` +/// is `dash` (Debian and Ubuntu, including CI) the character-counting +/// mutant measures bytes too, agrees with the fix, and the row this +/// serves passes while proving nothing. /// -/// Candidates come from `locale -a`, so this reflects what is actually -/// installed. **Fails loudly when none qualifies** — a skip here would -/// be indistinguishable from a pass. -fn char_counting_locale() -> String { +/// `/bin/sh` is tried first, so the real interpreter is used wherever +/// it qualifies — as it does on Arch and macOS, which is precisely +/// where a `${#VAR}` guard would miscount. +/// +/// Locale candidates are the two conventional `C.UTF-8` spellings, +/// which are commonly usable without appearing in `locale -a`, +/// followed by every UTF-8 entry `locale -a` does report. **Fails +/// loudly when no pair qualifies** — a skip here would be +/// indistinguishable from a pass. +fn char_counting_shell() -> (String, String) { let installed = std::process::Command::new("locale") .arg("-a") .output() .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) .unwrap_or_default(); - let candidates: Vec = ["C.UTF-8".to_owned(), "C.utf8".to_owned()] + let mut locales: Vec = ["C.UTF-8".to_owned(), "C.utf8".to_owned()] .into_iter() .chain( installed @@ -889,24 +904,29 @@ fn char_counting_locale() -> String { .map(str::to_owned), ) .collect(); + locales.dedup(); - for cand in &candidates { - let probe = std::process::Command::new("/bin/sh") - .arg("-c") - .arg("x=é; echo ${#x}") - .env("LC_ALL", cand) - .env("LANG", cand) - .output(); - if let Ok(out) = probe - && String::from_utf8_lossy(&out.stdout).trim() == "1" - { - return cand.clone(); + let shells = ["/bin/sh", "/bin/bash", "bash"]; + for shell in shells { + for locale in &locales { + let probe = std::process::Command::new(shell) + .arg("-c") + .arg("x=é; echo ${#x}") + .env("LC_ALL", locale) + .env("LANG", locale) + .output(); + if let Ok(out) = probe + && String::from_utf8_lossy(&out.stdout).trim() == "1" + { + return (shell.to_owned(), locale.clone()); + } } } panic!( - "no installed locale makes /bin/sh count characters, so the \ - byte-vs-character distinction this row exists to test cannot \ - be established here. Tried: {candidates:?}" + "no available shell counts characters under any installed \ + locale, so the byte-versus-character distinction this row \ + exists to test cannot be established here. Tried shells \ + {shells:?} against locales {locales:?}" ); }