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 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