Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/notes/p022-263a-step7-environment-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 35 additions & 2 deletions scripts/step7/envcapture.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,52 @@ 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.

Returns (returncode, stdout+stderr) or None when the tool is absent. The
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]:
Expand Down
54 changes: 54 additions & 0 deletions tests/test_step7_envcapture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading