from __future__ import annotations import gzip import io import subprocess import tarfile from pathlib import Path import pytest from scripts import image_release COMMIT = "a" * 40 DIGEST = "sha256:" + "b" * 64 IMAGE_ID = "sha256:" + "c" * 64 REPO = Path(__file__).resolve().parents[2] MIGRATION_DIR = REPO / "trex_core" / "adapters" / "persistence" / "sql" class RecordingRunner: def __init__( self, *, fail_on: tuple[str, ...] | None = None, digest: str = DIGEST, revision: str = COMMIT, image_id: str = IMAGE_ID, loaded_image: str = f"trex-core-ci:{COMMIT}", ): self.commands: list[tuple[list[str], str | None, int | None]] = [] self.fail_on = fail_on self.digest = digest self.revision = revision self.image_id = image_id self.loaded_image = loaded_image def run(self, command, *, input_text=None, timeout=None): command = list(command) self.commands.append((command, input_text, timeout)) if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on): raise subprocess.CalledProcessError(1, command) if command == ["git", "rev-parse", "HEAD"]: return image_release.Result(self.revision + "\n") if command[:2] == ["docker", "load"]: return image_release.Result(f"Loaded image: {self.loaded_image}\n") if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command: return image_release.Result(self.image_id + "\n") if command[:3] == ["docker", "image", "inspect"]: return image_release.Result(self.revision + "\n") if command[:2] == ["docker", "push"]: return image_release.Result(f"digest: {self.digest} size: 1234\n") if command[:2] == ["docker", "create"]: return image_release.Result("container-id\n") return image_release.Result() def _artifact(tmp_path: Path) -> tuple[Path, Path, Path]: archive = tmp_path / "trex-core-image.tar" checksum = tmp_path / "trex-core-image.tar.sha256" image_id = tmp_path / "trex-core-image.id" archive.write_bytes(b"tested image") image_release.write_checksum(archive, checksum) image_id.write_text(IMAGE_ID + "\n", encoding="ascii") return archive, checksum, image_id def test_release_tags_are_deterministic_and_never_latest(): release = image_release.release_tags(COMMIT, "push", "refs/tags/v1.2.3") assert release == [ f"{image_release.IMAGE}:{COMMIT[:12]}", f"{image_release.IMAGE}:v1.2.3", ] assert all(not tag.endswith(":latest") for tag in release) @pytest.mark.parametrize("ref", ["refs/tags/v0.0.0", "refs/tags/v10.20.30"]) def test_strict_version_tags_accept_zero_and_nonzero_decimal_components(ref): assert image_release.validate_publication_trigger("push", ref) == ref.removeprefix( "refs/tags/" ) @pytest.mark.parametrize( ("event_name", "ref"), [ ("workflow_dispatch", "refs/tags/v1.2.3"), ("pull_request", "refs/tags/v1.2.3"), ("push", "refs/heads/main"), ("push", "refs/heads/release"), ("push", "refs/tags/1.2.3"), ("push", "refs/tags/v1.2"), ("push", "refs/tags/v1.2.3.4"), ("push", "refs/tags/v01.2.3"), ("push", "refs/tags/v1.02.3"), ("push", "refs/tags/v1.2.03"), ("push", "refs/tags/v1.2.3-rc.1"), ("push", "refs/tags/v1.2.3+build.1"), ("push", "refs/tags/v-1.2.3"), ("push", "refs/tags/v1.a.3"), ], ) def test_non_push_branch_and_malformed_version_triggers_are_rejected(event_name, ref): with pytest.raises(ValueError): image_release.validate_publication_trigger(event_name, ref) @pytest.mark.parametrize("commit", ["A" * 40, "a" * 39, "g" * 40, ""]) def test_invalid_source_commit_is_rejected(commit): with pytest.raises(ValueError): image_release.release_tags(commit, "push", "refs/tags/v1.2.3") def test_verify_checkout_requires_exact_git_head_match(): runner = RecordingRunner() assert image_release.verify_checkout(COMMIT, runner) == COMMIT assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)] def test_verify_checkout_mismatch_fails_closed(): runner = RecordingRunner(revision="c" * 40) with pytest.raises(ValueError, match="checked out HEAD does not match source commit"): image_release.verify_checkout(COMMIT, runner) assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)] def test_smoke_uses_exact_isolated_probes_and_cleans_containers(): runner = RecordingRunner() image_release.smoke_image("candidate", runner, timeout=17, migration_dir=MIGRATION_DIR) runs = [call for call in runner.commands if call[0][:2] == ["docker", "run"]] removals = [call for call in runner.commands if call[0][:2] == ["docker", "rm"]] expected_migrations = tuple(sorted(path.name for path in MIGRATION_DIR.glob("*.sql"))) assert expected_migrations assert len(runs) == len(removals) == 5 assert [call[0][call[0].index("--entrypoint") + 1 :] for call in runs] == [ ["python", "candidate", "-c", "import trex_core"], ["trex-core-daemon", "candidate", "--help"], ["trex-core", "candidate", "--help"], ["trex-core-reset", "candidate", "--help"], ["python", "candidate", "-I", "-c", image_release.migration_inventory_code(expected_migrations)], ] assert all("--network" in call[0] and "none" in call[0] for call in runs) assert all("--read-only" in call[0] and "--cap-drop" in call[0] for call in runs) assert all(call[2] == 17 for call in runs + removals) assert all("--config" not in call[0] and "--yes" not in call[0] for call in runs) def test_smoke_rejects_empty_canonical_migration_inventory(tmp_path): with pytest.raises(ValueError, match="canonical migration inventory is empty"): image_release.smoke_image("candidate", RecordingRunner(), migration_dir=tmp_path) def test_migration_inventory_probe_failure_propagates_after_cleanup(): class FifthProbeFailsRunner(RecordingRunner): def run(self, command, *, input_text=None, timeout=None): run_count = len([call for call in self.commands if call[0][:2] == ["docker", "run"]]) if list(command)[:2] == ["docker", "run"] and run_count == 4: self.commands.append((list(command), input_text, timeout)) raise subprocess.CalledProcessError(1, command) return super().run(command, input_text=input_text, timeout=timeout) runner = FifthProbeFailsRunner() with pytest.raises(RuntimeError, match="smoke probe 5 failed"): image_release.smoke_image("candidate", runner, migration_dir=MIGRATION_DIR) assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"] def test_smoke_failure_propagates_after_cleanup(): runner = RecordingRunner(fail_on=("docker", "run")) with pytest.raises(RuntimeError, match="smoke probe 1 failed"): image_release.smoke_image("candidate", runner) assert [call[0][:3] for call in runner.commands] == [ ["docker", "run", "--name"], ["docker", "rm", "--force"], ] def test_smoke_fails_if_captured_probe_output_contains_canary(): class LeakingRunner(RecordingRunner): def run(self, command, *, input_text=None, timeout=None): result = super().run(command, input_text=input_text, timeout=timeout) if list(command)[:2] == ["docker", "run"]: return image_release.Result(image_release.CANARY.decode()) return result runner = LeakingRunner() with pytest.raises(RuntimeError, match="smoke probe 1 failed"): image_release.smoke_image("candidate", runner) assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"] def test_mutated_archive_stops_before_load_or_registry(tmp_path): archive, checksum, image_id = _artifact(tmp_path) archive.write_bytes(b"mutated") runner = RecordingRunner() with pytest.raises(ValueError, match="checksum mismatch"): image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v1.2.3", actor="actor", token="fake-token", runner=runner, ) assert runner.commands == [] @pytest.mark.parametrize( ("event_name", "ref"), [ ("workflow_dispatch", "refs/tags/v1.2.3"), ("push", "refs/heads/main"), ("push", "refs/tags/v01.2.3"), ("push", "refs/tags/v1.2.3-rc.1"), ], ) def test_invalid_publication_trigger_stops_before_artifact_load_or_registry( tmp_path, event_name, ref ): runner = RecordingRunner() with pytest.raises(ValueError, match="publication"): image_release.publish_image( archive=tmp_path / "missing.tar", checksum=tmp_path / "missing.sha256", image_id=tmp_path / "missing.id", local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name=event_name, ref=ref, actor="actor", token="fake-token", runner=runner, ) assert runner.commands == [] def test_load_failure_stops_before_login_and_push(tmp_path): archive, checksum, image_id = _artifact(tmp_path) runner = RecordingRunner(fail_on=("docker", "load")) with pytest.raises(subprocess.CalledProcessError): image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v1.2.3", actor="actor", token="fake-token", runner=runner, ) assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands) def test_revision_mismatch_stops_before_login_and_push(tmp_path): archive, checksum, image_id = _artifact(tmp_path) runner = RecordingRunner(revision="c" * 40) with pytest.raises(ValueError, match="revision does not match"): image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v1.2.3", actor="actor", token="fake-token", runner=runner, ) assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands) def test_expected_image_id_mismatch_stops_before_credentials_tags_or_push(tmp_path): archive, checksum, image_id = _artifact(tmp_path) runner = RecordingRunner(image_id="sha256:" + "d" * 64) with pytest.raises(ValueError, match="image ID does not match"): image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v1.2.3", actor="actor", token="fake-token", runner=runner, ) assert not any( call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"]) for call in runner.commands ) def test_unrelated_load_output_cannot_use_stale_expected_local_tag(tmp_path): archive, checksum, image_id = _artifact(tmp_path) runner = RecordingRunner(loaded_image="unrelated:image", image_id=IMAGE_ID) with pytest.raises(ValueError, match="did not report the expected local image"): image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v1.2.3", actor="actor", token="fake-token", runner=runner, ) assert not any( call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"]) for call in runner.commands ) @pytest.mark.parametrize( "invalid_id", ["", "sha256:" + "c" * 63, "sha256:" + "C" * 64, "sha512:" + "c" * 64], ) def test_invalid_tested_image_id_stops_before_load(tmp_path, invalid_id): archive, checksum, image_id = _artifact(tmp_path) image_id.write_text(invalid_id, encoding="ascii") runner = RecordingRunner() with pytest.raises(ValueError, match="image ID evidence"): image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v1.2.3", actor="actor", token="fake-token", runner=runner, ) assert runner.commands == [] def test_record_image_id_writes_strict_inspected_id(tmp_path): output = tmp_path / "trex-core-image.id" runner = RecordingRunner() assert image_release.write_image_id("candidate", output, runner) == IMAGE_ID assert output.read_text(encoding="ascii") == IMAGE_ID + "\n" assert runner.commands == [ (["docker", "image", "inspect", "--format", "{{.Id}}", "candidate"], None, None) ] def test_valid_publish_records_exact_release_evidence_only_after_push(tmp_path): archive, checksum, image_id = _artifact(tmp_path) output = tmp_path / "output" summary = tmp_path / "summary" runner = RecordingRunner() digest = image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v2.0.0", actor="actor", token="fake-token", runner=runner, output_path=output, summary_path=summary, ) commands = [call[0][:2] for call in runner.commands] assert commands.index(["docker", "load"]) < commands.index(["docker", "login"]) assert commands.count(["docker", "push"]) == 2 assert digest == DIGEST assert output.read_text() == ( f"image={image_release.IMAGE}@{DIGEST}\n" f"digest={DIGEST}\n" f"source_commit={COMMIT}\n" "version_tag=v2.0.0\n" ) assert summary.read_text() == ( "## Published trex-core image\n\n" f"- Image: `{image_release.IMAGE}@{DIGEST}`\n" f"- Digest: `{DIGEST}`\n" f"- Source commit: `{COMMIT}`\n" "- Version tag: `v2.0.0`\n" ) @pytest.mark.parametrize("bad_digest", ["", "sha256:" + "b" * 63, "sha512:" + "b" * 64]) def test_invalid_post_push_digest_emits_no_success_evidence(tmp_path, bad_digest): archive, checksum, image_id = _artifact(tmp_path) output = tmp_path / "output" summary = tmp_path / "summary" runner = RecordingRunner(digest=bad_digest) with pytest.raises(ValueError, match="valid digest"): image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v1.2.3", actor="actor", token="fake-token", runner=runner, output_path=output, summary_path=summary, ) assert any(call[0][:2] == ["docker", "push"] for call in runner.commands) assert not output.exists() assert not summary.exists() def test_release_second_push_failure_preserves_nonzero_without_success_evidence(tmp_path): class SecondPushFailsRunner(RecordingRunner): def run(self, command, *, input_text=None, timeout=None): result = super().run(command, input_text=input_text, timeout=timeout) pushes = [call for call in self.commands if call[0][:2] == ["docker", "push"]] if list(command)[:2] == ["docker", "push"] and len(pushes) == 2: return image_release.Result("digest unavailable\n") return result archive, checksum, image_id = _artifact(tmp_path) output = tmp_path / "output" summary = tmp_path / "summary" runner = SecondPushFailsRunner() with pytest.raises(ValueError, match="valid digest"): image_release.publish_image( archive=archive, checksum=checksum, image_id=image_id, local_image=f"trex-core-ci:{COMMIT}", commit=COMMIT, event_name="push", ref="refs/tags/v2.0.0", actor="actor", token="fake-token", runner=runner, output_path=output, summary_path=summary, ) assert len([call for call in runner.commands if call[0][:2] == ["docker", "push"]]) == 2 assert not output.exists() assert not summary.exists() def test_canary_is_found_inside_nested_export_archive(tmp_path): layer = io.BytesIO() with tarfile.open(fileobj=layer, mode="w") as archive: payload = image_release.CANARY member = tarfile.TarInfo("app/data") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) saved = tmp_path / "image.tar" with tarfile.open(saved, mode="w") as archive: payload = layer.getvalue() member = tarfile.TarInfo("layer.tar") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) with pytest.raises(ValueError, match="canary marker found"): image_release._scan_tar(saved) def test_canary_is_found_inside_compressed_digest_named_blob(tmp_path): inner = io.BytesIO() with tarfile.open(fileobj=inner, mode="w") as archive: payload = image_release.CANARY member = tarfile.TarInfo("app/data") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) saved = tmp_path / "oci-image.tar" with tarfile.open(saved, mode="w") as archive: payload = gzip.compress(inner.getvalue()) member = tarfile.TarInfo("blobs/sha256/deadbeef") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) with pytest.raises(ValueError, match="canary marker found"): image_release._scan_tar(saved) def test_canary_is_found_in_symlink_metadata_inside_compressed_digest_blob(tmp_path): inner = io.BytesIO() with tarfile.open(fileobj=inner, mode="w") as archive: member = tarfile.TarInfo("app/link") member.type = tarfile.SYMTYPE member.linkname = image_release.CANARY.decode() archive.addfile(member) saved = tmp_path / "oci-image.tar" with tarfile.open(saved, mode="w") as archive: payload = gzip.compress(inner.getvalue()) member = tarfile.TarInfo("blobs/sha256/deadbeef") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) with pytest.raises(ValueError, match="canary marker found"): image_release._scan_tar(saved) @pytest.mark.parametrize( "pax_headers", [ {"comment": image_release.CANARY.decode()}, {image_release.CANARY.decode(): "safe"}, ], ) def test_canary_is_found_in_pax_metadata_inside_compressed_digest_blob(tmp_path, pax_headers): inner = io.BytesIO() with tarfile.open(fileobj=inner, mode="w", format=tarfile.PAX_FORMAT) as archive: member = tarfile.TarInfo("app/data") member.pax_headers = pax_headers archive.addfile(member) saved = tmp_path / "oci-image.tar" with tarfile.open(saved, mode="w") as archive: payload = gzip.compress(inner.getvalue()) member = tarfile.TarInfo("blobs/sha256/deadbeef") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) with pytest.raises(ValueError, match="canary marker found"): image_release._scan_tar(saved) @pytest.mark.parametrize("field", ["uname", "gname"]) def test_canary_is_found_in_owner_metadata_inside_compressed_digest_blob(tmp_path, field): inner = io.BytesIO() with tarfile.open(fileobj=inner, mode="w") as archive: member = tarfile.TarInfo("app/data") setattr(member, field, image_release.CANARY.decode()) archive.addfile(member) saved = tmp_path / "oci-image.tar" with tarfile.open(saved, mode="w") as archive: payload = gzip.compress(inner.getvalue()) member = tarfile.TarInfo("blobs/sha256/deadbeef") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) with pytest.raises(ValueError, match="canary marker found"): image_release._scan_tar(saved) def test_archive_scan_rejects_excessive_nesting_depth(tmp_path): payload = io.BytesIO() with tarfile.open(fileobj=payload, mode="w"): pass nested = payload.getvalue() for _ in range(4): wrapped = io.BytesIO() with tarfile.open(fileobj=wrapped, mode="w") as archive: member = tarfile.TarInfo("blob") member.size = len(nested) archive.addfile(member, io.BytesIO(nested)) nested = wrapped.getvalue() path = tmp_path / "deep.tar" path.write_bytes(nested) with pytest.raises(ValueError, match="archive scan resource limit exceeded") as error: image_release._scan_tar(path, image_release._ScanBudget(max_depth=2)) assert image_release.CANARY.decode() not in str(error.value) def test_archive_scan_rejects_excessive_aggregate_members(tmp_path): path = tmp_path / "members.tar" with tarfile.open(path, mode="w") as archive: for index in range(3): member = tarfile.TarInfo(f"directory-{index}") member.type = tarfile.DIRTYPE archive.addfile(member) with pytest.raises(ValueError, match="archive scan resource limit exceeded"): image_release._scan_tar(path, image_release._ScanBudget(max_members=2)) def test_archive_scan_rejects_excessive_aggregate_extracted_bytes(tmp_path): path = tmp_path / "aggregate.tar" with tarfile.open(path, mode="w") as archive: payload = b"123456" member = tarfile.TarInfo("payload") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) budget = image_release._ScanBudget(max_extracted_bytes=5, max_member_bytes=10) with pytest.raises(ValueError, match="archive scan resource limit exceeded"): image_release._scan_tar(path, budget) def test_archive_scan_rejects_excessive_per_member_bytes(tmp_path): path = tmp_path / "member.tar" with tarfile.open(path, mode="w") as archive: payload = b"123456" member = tarfile.TarInfo("payload") member.size = len(payload) archive.addfile(member, io.BytesIO(payload)) budget = image_release._ScanBudget(max_extracted_bytes=100, max_member_bytes=5) with pytest.raises(ValueError, match="archive scan resource limit exceeded"): image_release._scan_tar(path, budget) def test_archive_scan_enforces_top_level_input_bytes_and_accepts_boundary(tmp_path): path = tmp_path / "input.tar" with tarfile.open(path, mode="w"): pass input_size = path.stat().st_size image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size)) with pytest.raises(ValueError, match="archive scan resource limit exceeded"): image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size - 1)) def test_canary_failure_message_does_not_disclose_marker(tmp_path): output = tmp_path / "captured-output" output.write_bytes(image_release.CANARY) with pytest.raises(ValueError) as error: image_release.assert_clean_output([output]) assert image_release.CANARY.decode() not in str(error.value) def test_canary_scan_checks_metadata_filesystem_archive_and_cleanup(tmp_path): class ScanRunner(RecordingRunner): def run(self, command, *, input_text=None, timeout=None): result = super().run(command, input_text=input_text, timeout=timeout) if list(command)[:2] == ["docker", "export"]: output = Path(command[command.index("--output") + 1]) with tarfile.open(output, mode="w"): pass return result saved = tmp_path / "image.tar" with tarfile.open(saved, mode="w"): pass runner = ScanRunner() image_release.scan_image("candidate", saved, runner) commands = [call[0] for call in runner.commands] assert ["docker", "history", "--no-trunc", "candidate"] in commands assert ["docker", "image", "inspect", "candidate"] in commands assert ["docker", "create", "--network", "none", "candidate"] in commands assert any(command[:2] == ["docker", "export"] for command in commands) assert ["docker", "rm", "--force", "container-id"] in commands def test_canary_in_create_stderr_still_cleans_container(tmp_path): class LeakingCreateRunner(RecordingRunner): def run(self, command, *, input_text=None, timeout=None): result = super().run(command, input_text=input_text, timeout=timeout) if list(command)[:2] == ["docker", "create"]: return image_release.Result("container-id\n", image_release.CANARY.decode()) return result saved = tmp_path / "image.tar" with tarfile.open(saved, mode="w"): pass runner = LeakingCreateRunner() with pytest.raises(ValueError, match="canary marker found"): image_release.scan_image("candidate", saved, runner) assert runner.commands[-1][0] == ["docker", "rm", "--force", "container-id"]