diff --git a/scripts/ci/activity_acceptance_output.py b/scripts/ci/activity_acceptance_output.py index 8a2ab25..ef38831 100644 --- a/scripts/ci/activity_acceptance_output.py +++ b/scripts/ci/activity_acceptance_output.py @@ -2,12 +2,14 @@ from __future__ import annotations +import hashlib import json import os import stat from pathlib import Path RESULT_NAME = "result.json" +RESULT_LIMIT = 128 * 1024 class Output: @@ -83,9 +85,25 @@ def finish(self, report): except ValueError: report["output_failure"] = "identity_changed" report["complete"] = False - encoded = json.dumps(report, indent=2) + "\n" - if len(encoded.encode()) > 128 * 1024: - raise ValueError("terminal result exceeded its bound") + encoded = (json.dumps(report, indent=2) + "\n").encode() + if len(encoded) > RESULT_LIMIT: + report["complete"] = False + receipt = { + "complete": False, "output_failure": "terminal_result_exceeded_bound", + "oversized_report_bytes": len(encoded), "oversized_report_sha256": hashlib.sha256(encoded).hexdigest(), + } + if "output_failure" in report: + receipt["output_boundary_failure"] = str(report["output_failure"])[:1024] + for name in ("scenario", "artifact_source", "source_commit", "started_at", "finished_at", + "runtime_id", "consumer_id", "network_id"): + value = report.get(name) + if isinstance(value, str) and len(value) <= 1024: + receipt[name] = value + cleanup = json.dumps(report.get("cleanup"), sort_keys=True).encode() + receipt["cleanup"] = report.get("cleanup") if len(cleanup) <= RESULT_LIMIT // 2 else { + "retained": False, "bytes": len(cleanup), "sha256": hashlib.sha256(cleanup).hexdigest(), + } + encoded = (json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n").encode() self._write(self.files[RESULT_NAME], encoded) def close(self): diff --git a/scripts/ci/published-activity-acceptance.py b/scripts/ci/published-activity-acceptance.py index 851730b..026d4a2 100644 --- a/scripts/ci/published-activity-acceptance.py +++ b/scripts/ci/published-activity-acceptance.py @@ -148,6 +148,15 @@ def packet_file(data): return {"base64": base64.b64encode(data).decode(), "sha256": hashlib.sha256(data).hexdigest()} +def retain_consumer_ready(files, report, ready): + # The complete environment inventory can exceed the terminal receipt bound. + # Keep its bytes independently and bind them rather than duplicating them. + data = json.dumps({key: value for key, value in ready.items() if key != "sdk_inventory"}, indent=2).encode() + name = "consumer-ready.json" + files.write(name, data) + report["consumer_ready"] = {"file": name, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + + def bind_bootstrap(captured): source = captured["bootstrap.py"].decode() for name, filename in (("CAPTURED_VERIFIER", VERIFIER_FILENAME), ("CAPTURED_ENTRY", "entry.py")): @@ -525,7 +534,7 @@ def run_case(owned, selected, images, packet, bootstrap, report): name = "identity" if SCENARIO == "activity-identity" else SCENARIO owned.files.write(name + ".log", result["case_output"]) owned.files.write(name + ".xml", result["junit"]) - report["consumer_ready"] = {key: value for key, value in ready.items() if key != "sdk_inventory"} + retain_consumer_ready(owned.files, report, ready) session.send({"release": True}) code = session.finish() report["consumer_exit"] = code diff --git a/tests/test_activity_acceptance_authority.py b/tests/test_activity_acceptance_authority.py index f910618..034ea3e 100644 --- a/tests/test_activity_acceptance_authority.py +++ b/tests/test_activity_acceptance_authority.py @@ -78,3 +78,45 @@ def test_replaced_result_cannot_become_a_successful_terminal_receipt(tmp_path): assert (output.path / "result.json").read_text() == "foreign" finally: output.close() + + +def test_oversized_report_retains_bounded_nonpass_and_exact_cleanup_ids(tmp_path): + output = files.Output(tmp_path / "result") + cleanup = {"a" * 64: {"absent": True, "oom_killed": False}, "remaining-owned-labels": {"absent": True}} + report = {"complete": True, "scenario": "pagination", "runtime_id": "a" * 64, + "consumer_id": "b" * 64, "network_id": "c" * 64, "cleanup": cleanup, + "large_inventory": "x" * (files.RESULT_LIMIT + 1)} + try: + output.finish(report) + data = (output.path / "result.json").read_bytes() + receipt = json.loads(data) + assert len(data) <= files.RESULT_LIMIT + assert receipt["complete"] is report["complete"] is False + assert receipt["output_failure"] == "terminal_result_exceeded_bound" + assert receipt["runtime_id"] == "a" * 64 + assert receipt["consumer_id"] == "b" * 64 + assert receipt["network_id"] == "c" * 64 + assert receipt["cleanup"] == cleanup + assert receipt["oversized_report_bytes"] > files.RESULT_LIMIT + finally: + output.close() + + +def test_oversized_unbounded_cleanup_is_explicitly_unqualified_and_cannot_overwrite_foreign_path(tmp_path): + output = files.Output(tmp_path / "result") + original = tmp_path / "retained" + output.path.rename(original) + output.path.mkdir() + (output.path / "result.json").write_text("foreign") + report = {"complete": True, "cleanup": {"untrusted": "x" * (files.RESULT_LIMIT * 2)}} + try: + output.finish(report) + data = (original / "result.json").read_bytes() + receipt = json.loads(data) + assert len(data) <= files.RESULT_LIMIT + assert receipt["complete"] is False + assert receipt["output_boundary_failure"] == "identity_changed" + assert receipt["cleanup"]["retained"] is False + assert (output.path / "result.json").read_text() == "foreign" + finally: + output.close() diff --git a/tests/test_published_activity_acceptance.py b/tests/test_published_activity_acceptance.py index 1dbe4a8..88684ae 100644 --- a/tests/test_published_activity_acceptance.py +++ b/tests/test_published_activity_acceptance.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import importlib.util import json from copy import deepcopy @@ -17,6 +18,26 @@ runtime = runner.runtime +def test_large_readiness_inventory_is_retained_and_hash_bound_without_terminal_duplication(tmp_path): + ready = {"distributions": "fixed locked wheels", "environment_inventory": {"entry": "x" * 200_000}, + "sdk_inventory": {"already": "retained separately"}} + report = {"complete": True} + files = runner.output_files.Output(tmp_path / "result") + try: + runner.retain_consumer_ready(files, report, ready) + files.finish(report) + data = (files.path / "consumer-ready.json").read_bytes() + assert json.loads(data) == {key: value for key, value in ready.items() if key != "sdk_inventory"} + assert report["consumer_ready"] == { + "file": "consumer-ready.json", "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), + } + result = (files.path / "result.json").read_bytes() + assert len(result) < runner.output_files.RESULT_LIMIT + assert json.loads(result)["complete"] is True + finally: + files.close() + + def configuration(role="runtime"): image = { "Id": "sha256:" + "f" * 64,