From 0562376eb60f6419d6fd85106af8f386be94a44a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 00:25:56 +0500 Subject: [PATCH 1/2] fix(step7): decode a tool's bytes by the code page that wrote them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_tool` ran every probe through `subprocess.run(..., text=True)`, which decodes with the locale's preferred encoding. On a non-English Windows that is the ANSI code page, while a console tool writes in the CONSOLE output code page. The two disagree, so `powercfg` arrived as mojibake — and mojibake whose bytes move with the ambient code page, which on one machine here resolves to 65001 under PowerShell and 866 under Git Bash. `power_policy` is identity-bearing and fields compare whole, so the same unchanged machine produced two different identity values: drift the capture invents rather than observes. `python`, `rustc`, `dotnet` and the CIM-backed `cpu_model` and `virtualization` all read through the same helper. The invariant is not that the bytes are UTF-8 — they are whatever the console is set to. It is that the decoded VALUE is the same string whichever code page produced it. `_tool_encoding()` asks Windows for the producing code page (GetConsoleOutputCP, then GetOEMCP) and decodes with it, UTF-8 elsewhere, with the same `errors="replace"` `_text` already uses. `envcapture-tool-encoding` drives the same text through cp866, cp1251 and utf-8 and requires one string out of all three. Before this change that control reports three different values; the accidental pass is cp1251, which is why a Russian Windows could look fine in one shell and drift in the next. No digest moves: the capture tool is outside all three frozen source sets, and `envcapture-frozen-untouched` still passes. 12 controls pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- scripts/step7/envcapture.py | 37 +++++++++++++++++++++-- tests/test_step7_envcapture.py | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/scripts/step7/envcapture.py b/scripts/step7/envcapture.py index e4c7b733..a0ddf494 100644 --- a/scripts/step7/envcapture.py +++ b/scripts/step7/envcapture.py @@ -117,6 +117,32 @@ def _text(path: str) -> str | None: return None +def _tool_encoding() -> str: + """The encoding a console tool's bytes actually arrive in. + + `text=True` decodes with the locale's preferred encoding, which on a + non-English Windows is the ANSI code page — while a console tool writes in + the CONSOLE output code page. The two disagree, so `powercfg` output arrived + as mojibake whose bytes changed with the ambient code page, and + `power_policy` is identity-bearing: that is drift on a machine that never + moved. Decoding by the producing code page makes the value the same string + whichever console the capture is taken from. + """ + if os.name == "nt": + import ctypes # only needed on the Windows path + + try: + # ctypes.windll is defined only on Windows; same treatment as above. + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only + for query in ("GetConsoleOutputCP", "GetOEMCP"): + code_page = getattr(kernel32, query)() + if code_page: + return f"cp{code_page}" + except (AttributeError, OSError, ValueError): + pass + return "utf-8" + + def _tool(argv: list[str]) -> tuple[int, str] | None: """Run a version query. NOT timed, and no shell. @@ -124,12 +150,19 @@ def _tool(argv: list[str]) -> tuple[int, str] | None: return code is handed back because `systemd-detect-virt` reports "none" with a non-zero exit, and reading that as a failure would turn a real answer into an unavailable. + + Bytes are decoded by `_tool_encoding()` rather than by the locale, with the + same `errors="replace"` `_text` uses: an undecodable byte becomes a visible + replacement character instead of raising inside a probe. """ try: - proc = subprocess.run(argv, capture_output=True, text=True, check=False) + proc = subprocess.run(argv, capture_output=True, check=False) except (OSError, ValueError): return None - return proc.returncode, (proc.stdout + proc.stderr).strip() + encoding = _tool_encoding() + out = proc.stdout.decode(encoding, errors="replace") + err = proc.stderr.decode(encoding, errors="replace") + return proc.returncode, (out + err).strip() def _host_fingerprint() -> dict[str, object]: diff --git a/tests/test_step7_envcapture.py b/tests/test_step7_envcapture.py index 4230ba16..c5c0e160 100644 --- a/tests/test_step7_envcapture.py +++ b/tests/test_step7_envcapture.py @@ -15,6 +15,7 @@ envcapture-windows-fixture the schema holds off Linux; the capture path does not envcapture-frozen-untouched this addition moved none of the three frozen digests envcapture-ci-provenance a CI-taken manifest says so and cannot hide it + envcapture-tool-encoding a tool's bytes decode by the code page that wrote them `envcapture-no-measurement` walks the AST rather than the text, because this module's own docstring names `perf_counter` and `wait4` to say it does not use @@ -544,6 +545,58 @@ def always_raises() -> None: "and this probe prints no FAIL line of its own on a green run") +def control_tool_encoding() -> None: + """A console tool's bytes are decoded by the code page that produced them. + + `powercfg` writes in the console OUTPUT code page. `text=True` decoded with + the locale's preferred encoding — the ANSI code page — so on a Russian + Windows `power_policy` arrived as mojibake, and mojibake whose bytes moved + with the ambient code page: the same unchanged machine produced two + different identity values. Identity fields compare whole, so that is drift + the capture invents rather than observes. + + The invariant is not "the bytes are UTF-8" — they are whatever the console + is set to. It is that the decoded VALUE is the same string whichever code + page produced it. + """ + expected = "Высокая производительность" + original = ec._tool_encoding + seen: dict[str, str] = {} + try: + for code_page in ("cp866", "cp1251", "utf-8"): + ec._tool_encoding = lambda page=code_page: page # type: ignore[assignment] + emitted = ec._tool([sys.executable, "-c", + "import sys; sys.stdout.buffer.write(" + f"{expected.encode(code_page)!r})"]) + if emitted is None: + fail("envcapture-tool-encoding", f"the {code_page} probe did not run at all") + return + seen[code_page] = emitted[1] + finally: + ec._tool_encoding = original # type: ignore[assignment] + + wrong = {page: text for page, text in seen.items() if text != expected} + if wrong: + fail("envcapture-tool-encoding", + f"decoded {wrong!r}, expected {expected!r} from every code page: the producing " + "code page was ignored, so an identity-bearing value moves with the console") + return + + resolved = original() + if os.name == "nt": + if not (resolved.startswith("cp") and resolved[2:].isdigit()): + fail("envcapture-tool-encoding", + f"on Windows the resolver named {resolved!r}, which is not a console code page") + return + elif resolved != "utf-8": + fail("envcapture-tool-encoding", + f"off Windows the resolver named {resolved!r} rather than utf-8") + return + ok("envcapture-tool-encoding", + f"the same text decodes identically from cp866, cp1251 and utf-8; this host resolves " + f"{resolved}") + + def run() -> int: guarded("envcapture-guard-reports", control_guard_reports) guarded("envcapture-schema", control_schema) @@ -556,6 +609,7 @@ def run() -> int: guarded("envcapture-windows-fixture", control_windows_fixture) guarded("envcapture-frozen-untouched", control_frozen_untouched) guarded("envcapture-ci-provenance", control_ci_provenance) + guarded("envcapture-tool-encoding", control_tool_encoding) print() print(f"step 7 environment capture controls: {len(_PASSES)} passed, {len(_FAILURES)} failed") return 1 if _FAILURES else 0 From b8fdddfccce5c50ddc9fc639a123feef77786b12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 00:38:51 +0500 Subject: [PATCH 2/2] docs(step7): the control inventory names the control that now exists The Controls block is a live inventory, not a record of what the campaign once ran, so omitting `envcapture-tool-encoding` would make it false. The mutation paragraph beneath it is history and is left alone: the new control postdates those fifteen mutations and says so rather than borrowing their score. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh --- docs/notes/p022-263a-step7-environment-capture.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/notes/p022-263a-step7-environment-capture.md b/docs/notes/p022-263a-step7-environment-capture.md index d06301b5..85669c0c 100644 --- a/docs/notes/p022-263a-step7-environment-capture.md +++ b/docs/notes/p022-263a-step7-environment-capture.md @@ -151,8 +151,16 @@ envcapture-reason-fits-platform an 'unavailable' reason names THIS platform envcapture-windows-fixture the schema holds off Linux envcapture-frozen-untouched this addition moved none of the three frozen digests envcapture-ci-provenance a CI-taken manifest says so and cannot hide it +envcapture-tool-encoding a tool's bytes decode by the code page that wrote them ``` +`envcapture-tool-encoding` postdates the mutation campaign below and was not +scored by it. It was added with the locale-decoding fix: `text=True` decoded a +console tool's output with the ANSI code page while the tool wrote in the +console's, so `power_policy` — identity-bearing — arrived as mojibake that moved +with the ambient code page, which is drift the capture invented rather than +observed. + Fifteen mutations, each declaring in advance which control must catch it, and each scored on a `FAIL` line from **that** control rather than on a non-zero exit. Two of the fifteen mutate the **control file**, not the tool: the guard is