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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
3a7e3790a1
commit
da74ae645b
|
|
@ -328,29 +328,33 @@ from #171 and #215.
|
||||||
is why that row covers a `Cargo.toml` **directory** as well as both
|
is why that row covers a `Cargo.toml` **directory** as well as both
|
||||||
`.git` shapes. `M-G-6` counts characters → the multibyte row.
|
`.git` shapes. `M-G-6` counts characters → the multibyte row.
|
||||||
`M-G-7` moves the trap back after the guards → the
|
`M-G-7` moves the trap back after the guards → the
|
||||||
rejection-cleanup row. Propagation is observed in a **spawned
|
rejection-cleanup row. `M-G-8` restores the old
|
||||||
child**, because the gate exporting a variable would only prove the
|
`for _anc in $(...)` loop → the canonical-walk row, which is what
|
||||||
gate can export a variable.
|
proves the traversal neither word-splits a space-bearing root nor
|
||||||
- **`M-G-8` is the one that proves `M-G-6` is not vacuous**, and it
|
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
|
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
|
code. **Nine mutations in total.**
|
||||||
it took: locale names beyond `C` and `POSIX` are
|
- **9a** — mutant gate, probed pair → the row **fails**, and the
|
||||||
implementation-defined, so on a machine lacking that locale the shell
|
exact-boundary row still passes. Re-run with `/bin/sh` excluded, so
|
||||||
falls back to **byte** semantics — and then the character-counting
|
the CI fallback path is covered too: still fails.
|
||||||
mutant counts bytes too, agrees with the fix, and the row passes
|
- **9b** — *same mutant gate*, pair forced to byte-counting → the row
|
||||||
while proving nothing.
|
**passes**. The defect itself, reproduced rather than argued.
|
||||||
- **8a** — mutant gate, locale chosen by the probe → the row
|
- **9c** — no pair can qualify → the helper **panics** naming what it
|
||||||
**fails**, and the exact-boundary row still passes.
|
tried. A skip would be indistinguishable from a pass.
|
||||||
- **8b** — *same mutant gate*, locale forced to `C` → the row
|
- **The multibyte row's axis was wrong, and CI is what proved it.** The
|
||||||
**passes**. This is the defect itself, reproduced rather than
|
row set `LC_ALL=C.UTF-8` and assumed a character count followed.
|
||||||
argued: the only difference between a real witness and a vacuous
|
`${#x}` counting characters is a property of the **shell** first:
|
||||||
one is whether the shell counts characters.
|
`bash` counts characters under a UTF-8 locale, **`dash` counts bytes
|
||||||
- **8c** — no candidate can qualify → the helper **panics** naming
|
under every locale**. `/bin/sh` is `bash` here and `dash` on the
|
||||||
what it tried. A skip here would be indistinguishable from a pass.
|
Ubuntu runners, so the row panicked on CI — the loud failure working
|
||||||
The locale is therefore selected by **behaviour**: candidates come
|
as designed, but on a machine where the distinction is unobservable.
|
||||||
from `locale -a`, and each is probed through the same `/bin/sh` the
|
The helper now probes `(shell, locale)` pairs and invokes the gate
|
||||||
gate runs under, asking `${#x}` on a two-byte character and requiring
|
**through** the qualifying shell. That is not a contrivance: `#!/bin/sh`
|
||||||
`1`.
|
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
|
- **Proved against the live hazard:** `/tmp/.git` is still present on
|
||||||
this machine, and the tests it reddened now pass with **no override**.
|
this machine, and the tests it reddened now pass with **no override**.
|
||||||
- **Gates:** `./scripts/gate --acceptance gate_script_acceptance`, run
|
- **Gates:** `./scripts/gate --acceptance gate_script_acceptance`, run
|
||||||
|
|
|
||||||
|
|
@ -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
|
is how a stray `/tmp/.git` came to redden a gate run on an unrelated
|
||||||
lane.
|
lane.
|
||||||
|
|
||||||
**Approved after three review rounds, all of which turned on evidence
|
**Approved after four review rounds plus a locale follow-up, all of
|
||||||
rather than design.** Revisions 6a–6c tightened the socket budget to
|
which turned on evidence rather than design.** Round 1 corrected a
|
||||||
the Darwin floor, replaced an existence-only ancestor check with one
|
propagation witness that proved only that the variable was used, and
|
||||||
that honours marker types, made the traversal canonical, moved the
|
two wrong guard reserves. Round 2 tightened the socket budget to the
|
||||||
guard rows onto the exact boundary, and — last — required the
|
Darwin floor and replaced an existence-only ancestor check with one
|
||||||
byte-versus-character row to *establish* its locale precondition
|
that honours marker types. Round 3 made the traversal canonical and
|
||||||
instead of naming one. Each correction was a witness asserting
|
gave the length guard its first real witnesses. Round 4 moved those
|
||||||
something adjacent to the contract while appearing to assert the
|
rows onto the exact boundary, covered both managed areas on cleanup,
|
||||||
contract itself.
|
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
|
**Previously, revision 5. Approved at revision 4 and IMPLEMENTED; revision
|
||||||
5 records two safety defects review found in the implementation.**
|
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:
|
the symptom appear deep in a suite as a limit with no cause:
|
||||||
- a **byte-counted** length check reserving the measured maximum
|
- a **byte-counted** length check reserving the measured maximum
|
||||||
suffix (`/.tmpXXXXXX/directory-target.sock`, 33 bytes) plus
|
suffix (`/.tmpXXXXXX/directory-target.sock`, 33 bytes) plus
|
||||||
headroom — byte-counted because `${#var}` counts *characters*
|
headroom — byte-counted because `sun_path` is byte-limited while
|
||||||
under UTF-8 while `sun_path` is byte-limited;
|
`${#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
|
- an **ancestor-marker check**, because **a managed root is not
|
||||||
inherently marker-free**: a `.git` in `$HOME`, a marker above
|
inherently marker-free**: a `.git` in `$HOME`, a marker above
|
||||||
`$HOME/build`, or a contaminated `PMACS_GATE_TARGET_ROOT` rebuilds
|
`$HOME/build`, or a contaminated `PMACS_GATE_TARGET_ROOT` rebuilds
|
||||||
|
|
|
||||||
|
|
@ -834,14 +834,21 @@ fn the_socket_path_guard_counts_bytes_not_characters() {
|
||||||
bytes (must fail), path {path}"
|
bytes (must fail), path {path}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// **Chosen by BEHAVIOUR, not by name.** Locale names beyond `C` and
|
// **Chosen by BEHAVIOUR, not by name**, and the interpreter is part
|
||||||
// `POSIX` are implementation-defined and an unrecognized value has
|
// of the choice: `${#x}` counting characters is a property of the
|
||||||
// unspecified behaviour, so setting `LC_ALL=C.UTF-8` and hoping is
|
// SHELL first and the locale second. `bash` counts characters under
|
||||||
// not a precondition — where that locale is absent the shell would
|
// a UTF-8 locale; `dash` counts bytes under every locale. Naming a
|
||||||
// fall back to byte semantics and the character-counting mutant
|
// locale and running `/bin/sh` therefore proves nothing on its own —
|
||||||
// would pass, silently.
|
// where `/bin/sh` is `dash`, the character-counting mutant measures
|
||||||
let locale = char_counting_locale();
|
// bytes too, agrees with the fix, and this row passes vacuously.
|
||||||
let out = std::process::Command::new(gate())
|
//
|
||||||
|
// 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")
|
.arg("--self-test")
|
||||||
.current_dir(repo_root())
|
.current_dir(repo_root())
|
||||||
.env("PMACS_GATE_TARGET_ROOT", root.path())
|
.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
|
/// A `(shell, locale)` pair under which **`${#x}` counts CHARACTERS** —
|
||||||
/// asking that very shell, not by trusting a name.
|
/// established by asking that very shell, never by trusting a name.
|
||||||
///
|
///
|
||||||
/// `${#x}` on a two-byte character answers `1` under a working UTF-8
|
/// **Both axes matter, and the shell matters more.** `${#x}` on a
|
||||||
/// locale and `2` under `C`. That difference is the entire subject of
|
/// two-byte character answers `1` under `bash` with a UTF-8 locale and
|
||||||
/// the row this serves, so the row must establish it as a precondition
|
/// `2` under `bash` with `C` — but `dash` answers `2` under *every*
|
||||||
/// rather than assume it: otherwise, on a machine without the named
|
/// locale, because it has no multibyte handling at all. So naming a
|
||||||
/// locale, the shell counts bytes, the mutant agrees with the fix, and
|
/// locale and invoking `/bin/sh` establishes nothing: where `/bin/sh`
|
||||||
/// the test passes while proving nothing.
|
/// 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
|
/// `/bin/sh` is tried first, so the real interpreter is used wherever
|
||||||
/// installed. **Fails loudly when none qualifies** — a skip here would
|
/// it qualifies — as it does on Arch and macOS, which is precisely
|
||||||
/// be indistinguishable from a pass.
|
/// where a `${#VAR}` guard would miscount.
|
||||||
fn char_counting_locale() -> String {
|
///
|
||||||
|
/// 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")
|
let installed = std::process::Command::new("locale")
|
||||||
.arg("-a")
|
.arg("-a")
|
||||||
.output()
|
.output()
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let candidates: Vec<String> = ["C.UTF-8".to_owned(), "C.utf8".to_owned()]
|
let mut locales: Vec<String> = ["C.UTF-8".to_owned(), "C.utf8".to_owned()]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(
|
.chain(
|
||||||
installed
|
installed
|
||||||
|
|
@ -889,24 +904,29 @@ fn char_counting_locale() -> String {
|
||||||
.map(str::to_owned),
|
.map(str::to_owned),
|
||||||
)
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
locales.dedup();
|
||||||
|
|
||||||
for cand in &candidates {
|
let shells = ["/bin/sh", "/bin/bash", "bash"];
|
||||||
let probe = std::process::Command::new("/bin/sh")
|
for shell in shells {
|
||||||
|
for locale in &locales {
|
||||||
|
let probe = std::process::Command::new(shell)
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg("x=é; echo ${#x}")
|
.arg("x=é; echo ${#x}")
|
||||||
.env("LC_ALL", cand)
|
.env("LC_ALL", locale)
|
||||||
.env("LANG", cand)
|
.env("LANG", locale)
|
||||||
.output();
|
.output();
|
||||||
if let Ok(out) = probe
|
if let Ok(out) = probe
|
||||||
&& String::from_utf8_lossy(&out.stdout).trim() == "1"
|
&& String::from_utf8_lossy(&out.stdout).trim() == "1"
|
||||||
{
|
{
|
||||||
return cand.clone();
|
return (shell.to_owned(), locale.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
panic!(
|
panic!(
|
||||||
"no installed locale makes /bin/sh count characters, so the \
|
"no available shell counts characters under any installed \
|
||||||
byte-vs-character distinction this row exists to test cannot \
|
locale, so the byte-versus-character distinction this row \
|
||||||
be established here. Tried: {candidates:?}"
|
exists to test cannot be established here. Tried shells \
|
||||||
|
{shells:?} against locales {locales:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue