Skip to content

Commit cfc6a5e

Browse files
Krishcalinclaude
andcommitted
Re-saving an export must not re-identify its findings
Row order was one dimension of "the answer must not depend on something incidental" and it found four fail-opens in a day. These are the other dimensions of the same question, and the stakes are higher: a fingerprint that moves retires the finding and raises a fresh one, resetting its age, its MTTR, its acceptance and its place in the mitigation journey — silently, because both scans look healthy and the counts barely move. Six things that happen to a CSV between the system and the upload without one SAP fact changing: a UTF-8 BOM (Excel adds it on save), CRLF line endings, a trailing space after every value, lower-cased column names, every field quoted, and the cp1252 codepage the loader already falls back to. Not hypothetical — a scan of the bundled corpus reports files "decoded only via a fallback encoding" today. THE PRODUCT IS ALREADY RIGHT. 418 of 419 fingerprints are identical across every variant. That is worth a test anyway: nothing enforced the property, one careless edit breaks it, and the failure is invisible in every number a reader looks at. THE ONE DIFFERENCE IS NOT CHURN. EXPORT-002 reports files that were not valid UTF-8, so re-saving as UTF-8 genuinely fixes what it reports and it correctly stops firing — and still fires when the variant re-saves as cp1252. Export Integrity checks are excluded because their subject is the file itself, the same distinction the domain taxonomy draws in keeping them outside the twelve domains. A test proves that exclusion is not covering a check that merely stopped working. THE MUTATION IS THE PART WORTH READING. My first attempt removed the `.strip()` from `norm_name` and all eight tests still passed — the same trap the row-order invariant's first draft fell into, where a clean result meant the net had no holes rather than no fish. The load-bearing line is in `DataLoader`: k.strip().upper().replace(\" \", \"_\"): (v or \"\").strip() Drop the value strip and `trailing` fails; drop the key upper and `lower_header` fails; each mutation fails exactly its own variant and nothing else. `norm_name`'s strip is redundant behind it — belt and braces rather than a defect, but worth knowing which of the two actually holds before trusting the other, so it is recorded in the test. And not claimed: the remaining four variants guard the decode-and-parse path rather than a single line, and no equivalent one-line mutation is asserted for them. 5,510 pass with a database. 8 new tests, 7 seconds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 619362a commit cfc6a5e

1 file changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""Re-saving an export must not re-identify its findings.
2+
3+
WHAT IS AT STAKE. A finding's fingerprint decides whether this scan's result is
4+
the SAME defect as last scan's. If it moves, the old finding is retired and a new
5+
one raised: the age resets, the MTTR is wrong, the acceptance and the ticket
6+
reference are orphaned, and the mitigation journey restarts — silently, because
7+
both scans look perfectly healthy and the counts barely change.
8+
9+
WHY THESE VARIANTS. Row order was one dimension of "the answer must not depend
10+
on something incidental", and it found four fail-opens in one day. These are the
11+
other dimensions of the same question, all of them things that happen to a CSV
12+
between the system and the upload without a single SAP fact changing:
13+
14+
bom Excel adds a UTF-8 byte-order mark on save
15+
crlf Windows line endings
16+
trailing a trailing space after every value
17+
lower_header column names lower-cased (BNAME -> bname)
18+
quoted every field quoted, as some exporters do
19+
cp1252 saved in the codepage the loader already falls back to
20+
21+
Not hypothetical: a scan of the bundled corpus reports files "decoded only via a
22+
fallback encoding (cp1252)" today.
23+
24+
THE RESULT WHEN THIS WAS WRITTEN was that the product is already right — 418 of
25+
419 fingerprints identical across every variant. That is worth a test anyway: it
26+
is a property nothing currently enforces, one careless `strip()` removed from
27+
`norm_name` would break it, and the failure is invisible in every number a
28+
reader looks at.
29+
30+
THE ONE DIFFERENCE, and why it is excluded rather than asserted away. The
31+
Export Integrity checks report on the FILE — "these were not valid UTF-8" — so
32+
re-saving an export as UTF-8 genuinely fixes what EXPORT-002 reports and it
33+
correctly stops firing. That is a fact this rewrite really does change, unlike
34+
every other fact in the corpus. It is the same distinction the domain taxonomy
35+
draws when it keeps Export Integrity outside the twelve domains: a statement
36+
about the evidence rather than about SAP. The last test here proves that
37+
exclusion is not covering a broken check.
38+
39+
WHICH LINE ACTUALLY DEFENDS THIS, established by mutation rather than by
40+
reading. `DataLoader` normalises every row with
41+
42+
k.strip().upper().replace(" ", "_"): (v or "").strip()
43+
44+
and that one line carries two of these variants: drop the value `.strip()` and
45+
`trailing` fails; drop the key `.upper()` and `lower_header` fails. Each
46+
mutation fails exactly its own variant and nothing else.
47+
48+
`norm_name` in server/identity.py also strips, and removing ITS strip changes
49+
nothing end-to-end — the loader has already done it. That is belt and braces
50+
rather than a defect, but it is worth knowing which of the two is load-bearing
51+
before trusting the other. The remaining variants (bom, crlf, quoted, cp1252)
52+
guard the decode-and-parse path rather than a single line, and are not claimed
53+
here to have an equivalent one-line mutation.
54+
"""
55+
from __future__ import annotations
56+
57+
import contextlib
58+
import csv
59+
import importlib
60+
import io
61+
import shutil
62+
import sys
63+
from pathlib import Path
64+
65+
import pytest
66+
67+
ROOT = Path(__file__).resolve().parents[1]
68+
if str(ROOT) not in sys.path:
69+
sys.path.insert(0, str(ROOT))
70+
71+
from modules import data_loader # noqa: E402
72+
from server import identity # noqa: E402
73+
from server.ingest import AUDITORS # noqa: E402
74+
75+
#: Checks that report on the export FILE rather than on the SAP system. Their
76+
#: subject is the thing these variants deliberately change, so they are the one
77+
#: family whose behaviour is expected to differ.
78+
FILE_CHECKS = ("EXPORT-",)
79+
80+
VARIANTS = ("bom", "crlf", "trailing", "lower_header", "quoted", "cp1252")
81+
82+
83+
def _scan(directory: Path):
84+
with contextlib.redirect_stdout(io.StringIO()):
85+
data = data_loader.DataLoader(Path(directory)).load_all()
86+
out = []
87+
for name, cls in AUDITORS:
88+
auditor_cls = getattr(importlib.import_module("modules." + name), cls)
89+
auditor = None
90+
for args in ((data,), (data, None), (data, None, {})):
91+
try:
92+
auditor = auditor_cls(*args)
93+
break
94+
except TypeError:
95+
continue
96+
if auditor is None:
97+
continue
98+
try:
99+
out.extend(auditor.run_all_checks() or [])
100+
except Exception: # noqa: BLE001
101+
# A module that raises is out of scope here; the row-order
102+
# invariant is where a module raising in one arrangement and not
103+
# another is caught.
104+
pass
105+
return out
106+
107+
108+
def fingerprints(directory: Path) -> set:
109+
got = set()
110+
for f in _scan(directory):
111+
check = str(f.get("check_id") or "")
112+
if check.startswith(FILE_CHECKS):
113+
continue
114+
fingerprint, basis = identity.fingerprint_finding(
115+
f, system="PRD", client="100")
116+
got.add((check, basis, fingerprint))
117+
return got
118+
119+
120+
def _rewrite(path: Path, how: str) -> None:
121+
"""Re-save one CSV the `how` way, preserving every fact in it."""
122+
raw = path.read_bytes()
123+
try:
124+
text = raw.decode("utf-8-sig")
125+
except UnicodeDecodeError:
126+
text = raw.decode("cp1252", errors="replace")
127+
rows = list(csv.reader(io.StringIO(text)))
128+
if not rows:
129+
return
130+
if how == "lower_header":
131+
rows[0] = [c.lower() for c in rows[0]]
132+
elif how == "trailing":
133+
rows = [rows[0]] + [[c + " " for c in r] for r in rows[1:]]
134+
buf = io.StringIO()
135+
csv.writer(buf,
136+
lineterminator="\r\n" if how == "crlf" else "\n",
137+
quoting=csv.QUOTE_ALL if how == "quoted"
138+
else csv.QUOTE_MINIMAL).writerows(rows)
139+
body = buf.getvalue()
140+
if how == "cp1252":
141+
path.write_bytes(body.encode("cp1252", errors="replace"))
142+
elif how == "bom":
143+
path.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8"))
144+
else:
145+
path.write_bytes(body.encode("utf-8"))
146+
147+
148+
@pytest.fixture(scope="module")
149+
def baseline():
150+
return fingerprints(ROOT / "sample_data")
151+
152+
153+
def test_the_baseline_is_not_empty(baseline):
154+
"""Every assertion below passes vacuously against an empty set."""
155+
assert len(baseline) > 200, len(baseline)
156+
157+
158+
@pytest.mark.parametrize("how", VARIANTS)
159+
def test_re_saving_the_export_does_not_re_identify_anything(how, baseline, tmp_path):
160+
estate = tmp_path / how
161+
shutil.copytree(ROOT / "sample_data", estate)
162+
for path in estate.rglob("*.csv"):
163+
_rewrite(path, how)
164+
165+
got = fingerprints(estate)
166+
lost, gained = baseline - got, got - baseline
167+
assert not lost and not gained, (
168+
"Saving the same facts as %r changed finding identity.\n"
169+
" retired (age and MTTR reset): %s\n"
170+
" raised as new: %s\n"
171+
"Nothing about the SAP system differs between these two exports."
172+
% (how, sorted(x[0] for x in lost)[:8],
173+
sorted(x[0] for x in gained)[:8]))
174+
175+
176+
def test_a_file_check_is_allowed_to_notice_the_re_save(tmp_path):
177+
"""The exclusion above must not be hiding a broken check.
178+
179+
EXPORT-002 reports files that were not valid UTF-8. Re-saving the corpus as
180+
UTF-8 really does fix that, so it must STOP firing — and re-saving as
181+
cp1252 must keep it firing. If neither happened, the exclusion would be
182+
covering a check that had simply stopped working.
183+
"""
184+
def export_checks(estate):
185+
return {str(f.get("check_id")) for f in _scan(estate)
186+
if str(f.get("check_id") or "").startswith(FILE_CHECKS)}
187+
188+
before = export_checks(ROOT / "sample_data")
189+
if "EXPORT-002" not in before:
190+
pytest.skip("the bundled corpus no longer needs a fallback encoding")
191+
192+
as_utf8 = tmp_path / "utf8"
193+
shutil.copytree(ROOT / "sample_data", as_utf8)
194+
for path in as_utf8.rglob("*.csv"):
195+
_rewrite(path, "bom")
196+
assert "EXPORT-002" not in export_checks(as_utf8), (
197+
"the corpus was re-saved as valid UTF-8 and the fallback-encoding "
198+
"finding still fires")
199+
200+
as_cp1252 = tmp_path / "cp1252"
201+
shutil.copytree(ROOT / "sample_data", as_cp1252)
202+
for path in as_cp1252.rglob("*.csv"):
203+
_rewrite(path, "cp1252")
204+
assert "EXPORT-002" in export_checks(as_cp1252), (
205+
"the corpus was re-saved in cp1252 and the fallback-encoding finding "
206+
"did not fire")

0 commit comments

Comments
 (0)