Skip to content

Commit ec1bf93

Browse files
committed
fix(sidecar): bind audit receipt to per-file SHA256 (CR-11)
Audit receipt now includes file_records (path/size/sha256) per bucket. Upload manifest cross-checks receipt file_records against actual inventory bytes before publication, rejecting stale receipts that certify a different byte set. Regression: replacing an audited parquet with arbitrary bytes while preserving file count now fails manifest creation.
1 parent 7a2b885 commit ec1bf93

4 files changed

Lines changed: 212 additions & 16 deletions

File tree

scripts/audit_sidecar_parquet.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,10 +1589,20 @@ def _audit_file(
15891589
if stats.bad_rows:
15901590
stats.bad_files = 1
15911591

1592+
import hashlib as _hashlib
1593+
1594+
file_size = path.stat().st_size
1595+
file_digest = _hashlib.sha256()
1596+
with path.open("rb") as _fh:
1597+
while _chunk := _fh.read(8 * 1024 * 1024):
1598+
file_digest.update(_chunk)
1599+
15921600
return {
15931601
"kind": kind,
15941602
"bucket": bucket,
15951603
"path": path_str,
1604+
"size": file_size,
1605+
"sha256": file_digest.hexdigest(),
15961606
"stats": stats.as_dict(),
15971607
}
15981608

@@ -1614,19 +1624,37 @@ def _rollup(results: list[dict[str, Any]]) -> dict[str, Any]:
16141624
by_kind: dict[str, AuditStats] = {}
16151625
by_bucket: dict[str, AuditStats] = {}
16161626
by_kind_bucket: dict[str, AuditStats] = {}
1627+
file_records_by_bucket: dict[str, list[dict[str, Any]]] = {}
16171628
bad_files: list[str] = []
16181629

16191630
for result in results:
16201631
kind = result["kind"]
16211632
bucket = result["bucket"]
1633+
key = f"{kind}/{bucket}"
16221634
stats = _stats_from_dict(result["stats"])
16231635
total.add(stats)
16241636
by_kind.setdefault(kind, AuditStats()).add(stats)
16251637
by_bucket.setdefault(bucket, AuditStats()).add(stats)
1626-
by_kind_bucket.setdefault(f"{kind}/{bucket}", AuditStats()).add(stats)
1638+
by_kind_bucket.setdefault(key, AuditStats()).add(stats)
1639+
file_records_by_bucket.setdefault(key, []).append(
1640+
{
1641+
"path": result["path"],
1642+
"size": result.get("size", 0),
1643+
"sha256": result.get("sha256", ""),
1644+
}
1645+
)
16271646
if result["stats"]["bad_files"] or result["stats"]["bad_rows"]:
16281647
bad_files.append(result["path"])
16291648

1649+
by_kind_bucket_out: dict[str, Any] = {}
1650+
for k, v in sorted(by_kind_bucket.items()):
1651+
entry = v.as_dict()
1652+
entry["file_records"] = sorted(
1653+
file_records_by_bucket.get(k, []),
1654+
key=lambda r: r["path"],
1655+
)
1656+
by_kind_bucket_out[k] = entry
1657+
16301658
return {
16311659
"total": total.as_dict(),
16321660
"by_kind": {k: v.as_dict() for k, v in sorted(by_kind.items())},
@@ -1637,7 +1665,7 @@ def _rollup(results: list[dict[str, Any]]) -> dict[str, Any]:
16371665
key=lambda kv: int(kv[0]) if kv[0].isdigit() else kv[0],
16381666
)
16391667
},
1640-
"by_kind_bucket": {k: v.as_dict() for k, v in sorted(by_kind_bucket.items())},
1668+
"by_kind_bucket": by_kind_bucket_out,
16411669
"bad_files": bad_files[:10000],
16421670
}
16431671

scripts/sidecar_manifest_contract.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ def validate_audit_receipt(
201201
receipt: object,
202202
*,
203203
selected_keys: Sequence[str],
204+
inventory: Mapping[str, Sequence[Mapping[str, object]]] | None = None,
204205
) -> dict[str, Any]:
205206
if not isinstance(receipt, dict):
206207
raise ValueError("sidecar audit receipt must be an object")
@@ -225,9 +226,47 @@ def validate_audit_receipt(
225226
raise ValueError(f"sidecar audit bucket is not green: {key}")
226227
if int(row.get("files", 0)) <= 0 or int(row.get("valid_tokens", 0)) <= 0:
227228
raise ValueError(f"sidecar audit bucket has no verified data: {key}")
229+
if inventory is not None:
230+
_validate_receipt_file_records(by_kind_bucket, selected_keys, inventory)
228231
return receipt
229232

230233

234+
def _validate_receipt_file_records(
235+
by_kind_bucket: dict,
236+
selected_keys: Sequence[str],
237+
inventory: Mapping[str, Sequence[Mapping[str, object]]],
238+
) -> None:
239+
"""Cross-check per-file path/size/SHA256 records in the receipt against
240+
the actual upload inventory, binding the receipt to exact bytes."""
241+
for key in selected_keys:
242+
row = by_kind_bucket[key]
243+
file_records = row.get("file_records")
244+
inv_records = inventory.get(key)
245+
if inv_records is None:
246+
continue
247+
if file_records is None:
248+
raise ValueError(
249+
f"sidecar audit receipt bucket {key} lacks file_records; "
250+
f"cannot bind receipt to inventory bytes"
251+
)
252+
receipt_set = {
253+
(str(r["path"]), int(r["size"]), str(r["sha256"]))
254+
for r in file_records
255+
}
256+
inventory_set = {
257+
(str(r["path"]), int(r["size"]), str(r["sha256"]))
258+
for r in inv_records
259+
}
260+
if receipt_set != inventory_set:
261+
stale = sorted(receipt_set - inventory_set)
262+
new = sorted(inventory_set - receipt_set)
263+
raise ValueError(
264+
f"sidecar audit receipt file_records do not match inventory "
265+
f"for bucket {key}: "
266+
f"stale={stale[:3]} new={new[:3]}"
267+
)
268+
269+
231270
def expected_bucket_remotes() -> set[str]:
232271
return {
233272
f"parquet/{kind}/{bucket}"

scripts/upload_verified_sidecar_to_nebius_s3.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -422,11 +422,8 @@ def _write_manifest(
422422
if len(audit_receipts) != 1:
423423
raise SystemExit("exactly one final audit receipt is required")
424424
audit_path = audit_receipts[0]
425-
audit_payload = validate_audit_receipt(
426-
json.loads(audit_path.read_text(encoding="utf-8")),
427-
selected_keys=selected_keys,
428-
)
429425
inventory: list[dict[str, object]] = []
426+
inventory_by_bucket: dict[str, list[dict[str, object]]] = {}
430427
for remote, local in selections:
431428
record = inventory_directory(local, remote=remote)
432429
inventory.append(
@@ -438,10 +435,25 @@ def _write_manifest(
438435
)
439436
key = remote.removeprefix("parquet/") if remote.startswith("parquet/") else None
440437
if key is not None:
438+
inventory_by_bucket[key] = [
439+
{"path": f["path"], "size": f["size"], "sha256": f["sha256"]}
440+
for f in record["files"]
441+
]
442+
audit_payload = validate_audit_receipt(
443+
json.loads(audit_path.read_text(encoding="utf-8")),
444+
selected_keys=selected_keys,
445+
inventory=inventory_by_bucket,
446+
)
447+
for remote, local in selections:
448+
key = remote.removeprefix("parquet/") if remote.startswith("parquet/") else None
449+
if key is not None:
450+
inv_record = next(
451+
r for r in inventory if r["remote"] == remote
452+
)
441453
expected_files = int(audit_payload["by_kind_bucket"][key]["files"])
442-
if int(record["file_count"]) != expected_files:
454+
if int(inv_record["file_count"]) != expected_files:
443455
raise SystemExit(
444-
f"inventory file count for {remote}={record['file_count']} "
456+
f"inventory file count for {remote}={inv_record['file_count']} "
445457
f"differs from audit receipt={expected_files}"
446458
)
447459
audit_selection = next(

tests/test_verified_sidecar_manifest_selection.py

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
inventory_directory,
2525
inventory_sha256,
2626
selection_policy,
27+
sha256_file,
28+
validate_audit_receipt,
2729
validate_manifest,
2830
)
2931

@@ -130,26 +132,33 @@ def _build_receipt(
130132
valid_tokens: dict[str, int],
131133
bad: dict[str, tuple[int, int]] | None = None,
132134
omit: set[str] | None = None,
135+
file_records: dict[str, list[dict[str, object]]] | None = None,
133136
) -> Path:
134137
"""Write a real audit receipt JSON with a ``by_kind_bucket`` map.
135138
136139
``bad`` maps a bucket key to ``(bad_files, bad_rows)``; ``omit`` drops keys
137140
entirely (stale/narrow receipt). The aggregate ``total`` is kept green on
138141
purpose to prove the gate is per-selected-bucket, not the coarse aggregate.
142+
``file_records`` maps a bucket key to per-file identity records
143+
(path/size/sha256) that bind the receipt to exact bytes.
139144
"""
140145
bad = bad or {}
141146
omit = omit or set()
142-
by_kind_bucket: dict[str, dict[str, int]] = {}
147+
file_records = file_records or {}
148+
by_kind_bucket: dict[str, dict[str, object]] = {}
143149
for key, tokens in valid_tokens.items():
144150
if key in omit:
145151
continue
146152
bf, br = bad.get(key, (0, 0))
147-
by_kind_bucket[key] = {
153+
entry: dict[str, object] = {
148154
"valid_tokens": tokens,
149155
"files": 1,
150156
"bad_files": bf,
151157
"bad_rows": br,
152158
}
159+
if key in file_records:
160+
entry["file_records"] = file_records[key]
161+
by_kind_bucket[key] = entry
153162
payload = {
154163
"schema": AUDIT_SCHEMA,
155164
"status": "verified",
@@ -258,18 +267,31 @@ def _download_fixture(
258267
return finalize_manifest(payload), root
259268

260269

261-
def _populate_upload_sources(selections: tuple[tuple[str, Path], ...]) -> None:
270+
def _populate_upload_sources(
271+
selections: tuple[tuple[str, Path], ...],
272+
) -> dict[str, list[dict[str, object]]]:
273+
"""Create source directories and return per-bucket file identity records."""
274+
file_records: dict[str, list[dict[str, object]]] = {}
262275
for index, (remote, local) in enumerate(selections, 1):
263276
local.mkdir(parents=True, exist_ok=True)
264277
if remote.startswith("parquet/"):
265278
key = remote.removeprefix("parquet/")
266279
bucket = int(remote.rsplit("/", 1)[1])
280+
shard_path = local / f"part-{index:03d}.parquet"
267281
_write_green_shard(
268-
local / f"part-{index:03d}.parquet",
282+
shard_path,
269283
bucket=bucket,
270284
valid_tokens=_BUCKET_VALID_TOKENS[key],
271285
source_id=index,
272286
)
287+
file_records.setdefault(key, []).append(
288+
{
289+
"path": shard_path.name,
290+
"size": shard_path.stat().st_size,
291+
"sha256": sha256_file(shard_path),
292+
}
293+
)
294+
return file_records
273295

274296

275297
def test_token_total_is_profile_aware_subset(tmp_path: Path) -> None:
@@ -494,10 +516,14 @@ def test_upload_main_dry_run_writes_manifest_and_prints_targets(
494516
monkeypatch.chdir(tmp_path)
495517
# Every selected source dir must exist or _existing_sources RAISEs; this
496518
# also creates the audit dir that holds the receipt.
497-
_populate_upload_sources(upload.ALL_VALID_SELECTIONS)
519+
fr = _populate_upload_sources(upload.ALL_VALID_SELECTIONS)
498520
# Real temp receipt at the relative path the script reads (_audit_receipts),
499521
# covering every selected parquet bucket and green.
500-
_build_receipt(upload._audit_receipts()[0], valid_tokens=_BUCKET_VALID_TOKENS)
522+
_build_receipt(
523+
upload._audit_receipts()[0],
524+
valid_tokens=_BUCKET_VALID_TOKENS,
525+
file_records=fr,
526+
)
501527

502528
manifest_path = tmp_path / "manifest.json"
503529
rc = upload.main(
@@ -549,11 +575,12 @@ def test_upload_main_dry_run_does_not_gate_default_on_standalone_pr_bucket(
549575
tmp_path: Path, monkeypatch
550576
) -> None:
551577
monkeypatch.chdir(tmp_path)
552-
_populate_upload_sources(upload.ALL_VALID_SELECTIONS)
578+
fr = _populate_upload_sources(upload.ALL_VALID_SELECTIONS)
553579
_build_receipt(
554580
upload._audit_receipts()[0],
555581
valid_tokens=_BUCKET_VALID_TOKENS,
556582
bad={"pr/8192": (1, 0)},
583+
file_records=fr,
557584
)
558585

559586
rc = upload.main(
@@ -574,13 +601,14 @@ def test_upload_main_dry_run_raises_on_non_green_explicit_standalone_pr_bucket(
574601
tmp_path: Path, monkeypatch
575602
) -> None:
576603
monkeypatch.chdir(tmp_path)
577-
_populate_upload_sources(
604+
fr = _populate_upload_sources(
578605
upload.CODE_COMMIT_SELECTIONS + upload.STANDALONE_PR_SELECTIONS
579606
)
580607
_build_receipt(
581608
upload._audit_receipts()[0],
582609
valid_tokens=_BUCKET_VALID_TOKENS,
583610
bad={"pr/8192": (1, 0)},
611+
file_records=fr,
584612
)
585613

586614
with pytest.raises(SystemExit) as exc:
@@ -777,3 +805,92 @@ def test_download_main_dry_run_copies_manifest_uri(
777805
# syncs code/commit buckets, but not standalone PR diagnostics.
778806
assert "s3://mybucket/myprefix/parquet/code/1024/" in out
779807
assert "parquet/pr/" not in out
808+
809+
810+
# --- CR-11: audit receipt must bind to per-file bytes -------------------------
811+
812+
813+
def test_audit_receipt_rejects_stale_file_records_after_byte_swap(
814+
tmp_path: Path,
815+
) -> None:
816+
"""A green receipt whose file_records reference the original bytes must be
817+
rejected when the underlying parquet is replaced with different content
818+
(same file count). A fresh receipt matching the new bytes must pass.
819+
"""
820+
shard_dir = tmp_path / "code" / "1024"
821+
shard_dir.mkdir(parents=True)
822+
shard = shard_dir / "part-001.parquet"
823+
_write_green_shard(shard, bucket=1024, valid_tokens=42, source_id=1)
824+
825+
original_sha = sha256_file(shard)
826+
original_size = shard.stat().st_size
827+
original_records = [
828+
{"path": "part-001.parquet", "size": original_size, "sha256": original_sha}
829+
]
830+
831+
receipt_path = tmp_path / "sidecar_parquet_audit.json"
832+
_build_receipt(
833+
receipt_path,
834+
valid_tokens={"code/1024": 42},
835+
file_records={"code/1024": original_records},
836+
)
837+
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
838+
839+
# Inventory matching the original bytes passes.
840+
inventory_ok = {"code/1024": original_records}
841+
validate_audit_receipt(receipt, selected_keys=["code/1024"], inventory=inventory_ok)
842+
843+
# Replace the shard with different bytes (same file count).
844+
shard.write_bytes(b"\x00" * original_size)
845+
swapped_sha = sha256_file(shard)
846+
assert swapped_sha != original_sha
847+
swapped_records = [
848+
{"path": "part-001.parquet", "size": original_size, "sha256": swapped_sha}
849+
]
850+
inventory_swapped = {"code/1024": swapped_records}
851+
852+
# The stale receipt must be rejected against the swapped inventory.
853+
with pytest.raises(ValueError, match="file_records do not match inventory"):
854+
validate_audit_receipt(
855+
receipt, selected_keys=["code/1024"], inventory=inventory_swapped
856+
)
857+
858+
# A fresh receipt binding to the new bytes must pass.
859+
fresh_receipt_path = tmp_path / "fresh_audit.json"
860+
_build_receipt(
861+
fresh_receipt_path,
862+
valid_tokens={"code/1024": 42},
863+
file_records={"code/1024": swapped_records},
864+
)
865+
fresh_receipt = json.loads(fresh_receipt_path.read_text(encoding="utf-8"))
866+
validate_audit_receipt(
867+
fresh_receipt, selected_keys=["code/1024"], inventory=inventory_swapped
868+
)
869+
870+
871+
def test_audit_receipt_rejects_missing_file_records_when_inventory_supplied(
872+
tmp_path: Path,
873+
) -> None:
874+
"""When inventory is supplied but the receipt bucket lacks file_records,
875+
validation must fail rather than silently skipping the byte binding.
876+
"""
877+
receipt_path = tmp_path / "sidecar_parquet_audit.json"
878+
_build_receipt(receipt_path, valid_tokens={"code/1024": 42})
879+
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
880+
881+
shard_dir = tmp_path / "code" / "1024"
882+
shard_dir.mkdir(parents=True)
883+
shard = shard_dir / "part-001.parquet"
884+
_write_green_shard(shard, bucket=1024, valid_tokens=42, source_id=1)
885+
inv = {
886+
"code/1024": [
887+
{
888+
"path": "part-001.parquet",
889+
"size": shard.stat().st_size,
890+
"sha256": sha256_file(shard),
891+
}
892+
]
893+
}
894+
895+
with pytest.raises(ValueError, match="lacks file_records"):
896+
validate_audit_receipt(receipt, selected_keys=["code/1024"], inventory=inv)

0 commit comments

Comments
 (0)