Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m54s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
Python lint here was a property of the operator's laptop: the global instructions
say to run ruff, no workflow ran it, and with no committed config ruff fell back
to whichever ~/.config/ruff/ruff.toml the machine happened to have.
- ruff.toml at the root, pinned ruff==0.12.11 in the script-tests job.
- Both lint steps pass an EXPLICIT population from `git ls-files` with
`--no-force-exclude`, never `ruff check .` — an `exclude` empties a
discovery-based run into a GREEN one (top level empties both commands, [lint]
empties check, [format] empties format --check), and `ruff check .` over zero
files exits 0 with only a stderr warning. Guarded by an empty-population arm.
- Tree clean: 74 findings at 706674272, 57 fixed in code, 17 per-site noqa with
reasons inline. S105 deliberately per-site, not a directory blanket. RUF100
selected so a suppression that suppresses nothing is itself a finding.
- pyright stays ungated; reasoning in the record.
Both steps witnessed red on the runner against the shipped bodies: run 2173 job
9176 (ruff check) and run 2170 job 9163 (ruff format --check).
Docs: new record ci.python-lint-ruff-config-committed, ci.script-tests-job
cross-ref, docs/ci-cd.md (also correcting a stale ~190-tests/~10s figure to the
measured 773 tests / ~4.5 min), docs/defect-shapes-773.md §5.2 resolved.
fixes #780
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
198 lines
8.8 KiB
Python
198 lines
8.8 KiB
Python
"""`pretooluse-bom-guard.sh` actually detects a BOM — including where `xxd` does not exist.
|
|
|
|
The guard compared `head -c3 <file> | xxd -p` against `efbbbf`. **`xxd` ships with vim and is absent
|
|
on plain Linux hosts, including this repo's CI runner** (verified there directly). On such a host the
|
|
command substitution yields the empty string, never equals `efbbbf`, and the guard allows every BOM
|
|
in silence. It had been fail-open on any machine without vim since it was written, and nothing
|
|
noticed because `docs/guard-inventory.md` graded it `NONE` — no proof it could go red.
|
|
|
|
It surfaced only when an unrelated change (ersatztv#776) added an assertion that this hook must
|
|
reach a real decision, and that assertion ran on Linux. Every prior review of this guard ran on
|
|
macOS, where `xxd` exists — the *environment* was a sampled population, and the sample was
|
|
unanimous.
|
|
|
|
So this file exists to make the guard's detection load-bearing rather than assumed:
|
|
|
|
* it drives the real hook end to end, through the real payload shape;
|
|
* it drives it with `xxd` REMOVED FROM PATH, which is the regression;
|
|
* and it performs a clause-level mutation — the comparison is disarmed in an isolated copy and the
|
|
guard must stop detecting — which is what `testing.guard-ships-with-mutation-proof` asks for and
|
|
what this guard has never had.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
HOOK = REPO_ROOT / ".claude" / "hooks" / "pretooluse-bom-guard.sh"
|
|
|
|
BOM = b"\xef\xbb\xbf"
|
|
|
|
|
|
def _git(cwd: Path, *args: str) -> None:
|
|
subprocess.run(
|
|
["git", *args],
|
|
cwd=str(cwd),
|
|
check=True,
|
|
capture_output=True,
|
|
env={
|
|
**os.environ,
|
|
"GIT_AUTHOR_NAME": "t",
|
|
"GIT_AUTHOR_EMAIL": "t@e",
|
|
"GIT_COMMITTER_NAME": "t",
|
|
"GIT_COMMITTER_EMAIL": "t@e",
|
|
},
|
|
)
|
|
|
|
|
|
def _repo_with(tmp_path: Path, name: str, content: bytes) -> Path:
|
|
# The guard scopes itself to repositories whose toplevel path matches `*ersatztv*`, so the
|
|
# directory name is load-bearing, not decoration.
|
|
repo = tmp_path / "ersatztv-scratch"
|
|
repo.mkdir()
|
|
_git(repo, "init", "-q", ".")
|
|
(repo / name).write_bytes(content)
|
|
_git(repo, "add", name)
|
|
return repo
|
|
|
|
|
|
def _run(hook: Path, repo: Path, env: dict) -> tuple[int, bytes]:
|
|
payload = json.dumps(
|
|
{
|
|
"session_id": "s",
|
|
"hook_event_name": "PreToolUse",
|
|
"tool_name": "Bash",
|
|
"cwd": str(repo),
|
|
"tool_input": {"command": "git commit -m x"},
|
|
}
|
|
)
|
|
p = subprocess.run(
|
|
["bash", str(hook)], input=payload.encode(), capture_output=True, cwd=str(repo), env=env, timeout=60
|
|
)
|
|
return p.returncode, p.stdout
|
|
|
|
|
|
def _path_without_xxd(tmp_path: Path) -> dict:
|
|
"""A PATH that resolves everything the hook needs EXCEPT `xxd`.
|
|
|
|
Rebuilding PATH from symlinks rather than just dropping directories, because `xxd` usually lives
|
|
in the same directory as `git` and `od`; removing that directory would starve the hook of tools
|
|
it legitimately needs and the test would pass for the wrong reason.
|
|
"""
|
|
import re
|
|
|
|
# DERIVED from the hook, not hand-listed. The hand-listed version symlinked six tools the hook
|
|
# never calls and omitted `tail`, which it does call for `cd`-detection — so a payload whose cwd
|
|
# differs from the repo took a `tail: command not found` path and the guard ALLOWED a BOM for a
|
|
# reason that had nothing to do with xxd. A stand-in PATH that starves the subject is a test
|
|
# passing for the wrong reason, which is the whole subject of this file.
|
|
words = set(re.findall(r"\b([a-z][a-z0-9_-]*)\b", HOOK.read_text()))
|
|
bindir = tmp_path / "nobin"
|
|
bindir.mkdir()
|
|
linked = []
|
|
for tool in sorted(words - {"xxd"}):
|
|
found = shutil.which(tool)
|
|
if found and not (bindir / tool).exists():
|
|
(bindir / tool).symlink_to(found)
|
|
linked.append(tool)
|
|
# `head` is deliberately NOT here: the `od` change removed the only `head` call, and asserting a
|
|
# tool the hook no longer uses is how a stand-in PATH drifts from its subject.
|
|
for required in ("bash", "git", "od", "tr", "sort", "tail"):
|
|
assert shutil.which(required, path=str(bindir)), (
|
|
f"the stand-in PATH lost `{required}`, which the hook needs — the test would then pass "
|
|
"because the guard was starved, not because it detected anything"
|
|
)
|
|
assert not shutil.which("xxd", path=str(bindir)), "the stand-in PATH still resolves xxd"
|
|
return {**os.environ, "PATH": str(bindir)}
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# ANTI-VACUITY — if the fixture stops producing a BOM file, every assertion below is meaningless.
|
|
# ------------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_fixture_really_stages_a_BOM(tmp_path):
|
|
repo = _repo_with(tmp_path, "Bad.cs", BOM + b"class A {}\n")
|
|
assert (repo / "Bad.cs").read_bytes()[:3] == BOM
|
|
staged = subprocess.run(
|
|
["git", "diff", "--name-only", "--cached"], cwd=str(repo), capture_output=True, text=True
|
|
).stdout.split()
|
|
assert staged == ["Bad.cs"], f"nothing was staged, so the guard would have nothing to read: {staged}"
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# THE GUARD DECIDES
|
|
# ------------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_staged_BOM_is_DENIED(tmp_path):
|
|
repo = _repo_with(tmp_path, "Bad.cs", BOM + b"class A {}\n")
|
|
rc, out = _run(HOOK, repo, dict(os.environ))
|
|
assert rc == 0, "the hook communicates by printing, and must always exit 0"
|
|
assert b'"permissionDecision": "deny"' in out or b'"permissionDecision":"deny"' in out, (
|
|
f"a staged BOM-carrying .cs was not denied: {out!r}"
|
|
)
|
|
|
|
|
|
def test_a_staged_BOM_is_DENIED_when_xxd_DOES_NOT_EXIST(tmp_path):
|
|
"""THE REGRESSION. This is the case that was silently allowed on every host without vim."""
|
|
repo = _repo_with(tmp_path, "Bad.cs", BOM + b"class A {}\n")
|
|
rc, out = _run(HOOK, repo, _path_without_xxd(tmp_path))
|
|
assert rc == 0
|
|
assert b'"permissionDecision": "deny"' in out or b'"permissionDecision":"deny"' in out, (
|
|
"with `xxd` absent the guard allowed a BOM. That is the fail-open this file exists to "
|
|
f"prevent, and it is the state every non-vim host was in: {out!r}"
|
|
)
|
|
|
|
|
|
def test_a_clean_file_is_ALLOWED(tmp_path):
|
|
"""The negative control. A guard that denies everything would pass the tests above."""
|
|
repo = _repo_with(tmp_path, "Good.cs", b"class A {}\n")
|
|
rc, out = _run(HOOK, repo, _path_without_xxd(tmp_path))
|
|
assert rc == 0
|
|
assert out == b"", f"a BOM-free file was not allowed silently: {out!r}"
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# THE MUTATION PROOF — disarm the comparison alone, detection must stop
|
|
# ------------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_DISARMING_the_BOM_comparison_stops_detection(tmp_path):
|
|
"""`testing.guard-ships-with-mutation-proof`, which this guard has never carried.
|
|
|
|
The clause is disarmed in an isolated copy — the comparison is pointed at a byte sequence no
|
|
file starts with — and the guard must go quiet. If it still denies, the deny is coming from
|
|
somewhere other than the clause the guard is supposed to hang on, and the tests above prove
|
|
nothing about it.
|
|
"""
|
|
text = HOOK.read_text()
|
|
marker = '= "efbbbf" ]; then'
|
|
assert marker in text, (
|
|
"the BOM comparison has moved; retarget this mutation rather than loosening it — a mutation "
|
|
"that silently stops mutating is the failure this file is about"
|
|
)
|
|
mutated = tmp_path / "mutated-bom-guard.sh"
|
|
mutated.write_text(text.replace(marker, '= "deadbeef" ]; then', 1))
|
|
|
|
repo = _repo_with(tmp_path, "Bad.cs", BOM + b"class A {}\n")
|
|
|
|
# POSITIVE CONTROL FIRST. Without it this test passes when the guard detects NOTHING AT ALL —
|
|
# verified: run against the pre-fix hook on Linux, where xxd is absent, and "the mutant is
|
|
# silent" was trivially true. A green mutation proof over a dead check is the exact failure
|
|
# `guard-ships-with-mutation-proof` exists to stop, and the inventory row cites THIS function.
|
|
rc_live, out_live = _run(HOOK, repo, dict(os.environ))
|
|
assert rc_live == 0 and b"deny" in out_live, (
|
|
"the UNMUTATED guard did not detect the BOM, so 'the mutant is silent' proves nothing about "
|
|
f"the clause: {out_live!r}"
|
|
)
|
|
|
|
rc, out = _run(mutated, repo, dict(os.environ))
|
|
assert rc == 0
|
|
assert out == b"", f"disarming the BOM comparison did not stop detection, so it is not load-bearing: {out!r}"
|