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:?}" ); }