diff --git a/.github/workflows/qar_d3_daily_preview_artifact.yml b/.github/workflows/qar_d3_daily_preview_artifact.yml new file mode 100644 index 0000000..e777b1a --- /dev/null +++ b/.github/workflows/qar_d3_daily_preview_artifact.yml @@ -0,0 +1,103 @@ +name: QAR D3 daily preview artifact + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - ".github/workflows/qar_d3_daily_preview_artifact.yml" + - "pyproject.toml" + - "uv.lock" + - "scripts/d3_*.py" + - "src/quant_advisor_research/**" + - "tests/test_d3_exact_bundle.py" + - "examples/political_events.example.csv" + - "examples/political_watchlist.example.csv" + workflow_dispatch: + inputs: + as_of: + description: "Daily representative fixture as_of" + required: false + default: "2026-06-20" + frozen_generated_at: + description: "Harness-only deterministic generated_at" + required: false + default: "2026-07-15T00:00:00Z" + +permissions: + contents: read + +jobs: + build-and-verify: + runs-on: ubuntu-latest + env: + INPUT_AS_OF: ${{ inputs.as_of || '2026-06-20' }} + FROZEN_GENERATED_AT: ${{ inputs.frozen_generated_at || '2026-07-15T00:00:00Z' }} + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + - name: Set up uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.19" + - name: Sync locked test environment + run: uv sync --locked --extra test + - name: Build representative previews + id: build + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} + HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + BUILD_ROOT: ${{ runner.temp }}/qar-d3 + run: | + mkdir -p "$BUILD_ROOT" + uv run --no-sync python scripts/d3_build_daily_preview.py \ + --as-of "$INPUT_AS_OF" --political-events examples/political_events.example.csv \ + --political-watchlist examples/political_watchlist.example.csv \ + --frozen-generated-at "$FROZEN_GENERATED_AT" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \ + --uv-version "$(uv --version)" --lock-path uv.lock --repo-root "$GITHUB_WORKSPACE" \ + --temp-root "$BUILD_ROOT" --evidence-path "$BUILD_ROOT/build-evidence.json" \ + --workspace-path-file "$BUILD_ROOT/workspace-path.txt" + echo "workspace=$(cat "$BUILD_ROOT/workspace-path.txt")" >> "$GITHUB_OUTPUT" + echo "evidence=$BUILD_ROOT/build-evidence.json" >> "$GITHUB_OUTPUT" + - name: Upload exact preview members + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qar-d3-preview-bundle + path: | + ${{ steps.build.outputs.workspace }}/report.json + ${{ steps.build.outputs.workspace }}/report.html + ${{ steps.build.outputs.workspace }}/manifest.json + include-hidden-files: true + if-no-files-found: error + - name: Upload build evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qar-d3-build-evidence + path: ${{ steps.build.outputs.evidence }} + if-no-files-found: error + - name: Download preview members + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: qar-d3-preview-bundle + path: ${{ runner.temp }}/qar-d3-download/bundle + - name: Download build evidence + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: qar-d3-build-evidence + path: ${{ runner.temp }}/qar-d3-download/evidence + - name: Verify downloaded artifact + run: | + uv run --no-sync python scripts/d3_verify_daily_preview.py \ + --workspace "${{ runner.temp }}/qar-d3-download/bundle" \ + --evidence-path "${{ runner.temp }}/qar-d3-download/evidence/build-evidence.json" \ + --base-sha "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}" \ + --head-sha "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" \ + --uv-version "$(uv --version)" --lock-path uv.lock --repo-root "$GITHUB_WORKSPACE" \ + --as-of "$INPUT_AS_OF" --political-events examples/political_events.example.csv \ + --political-watchlist examples/political_watchlist.example.csv --frozen-generated-at "$FROZEN_GENERATED_AT" + - name: Cleanup private preview workspaces + if: always() + run: rm -rf "${{ runner.temp }}/qar-d3" diff --git a/scripts/d3_build_daily_preview.py b/scripts/d3_build_daily_preview.py new file mode 100644 index 0000000..cb29fb8 --- /dev/null +++ b/scripts/d3_build_daily_preview.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Build two frozen-clock representative daily previews and bind evidence.""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import platform +import re +import tempfile +from pathlib import Path +from unittest.mock import patch + +from d3_evidence import DEPENDENCY_INVENTORY, EVIDENCE_VERSION, BundleSnapshot, EvidenceContractError, locked_environment_evidence, repository_file_hashes, validate_exact_bundle, validate_preview_snapshot +from quant_advisor_research.advisory_report import build_advisory_report +from quant_advisor_research.preview_workspace import build_preview_workspace + + +def _distributions() -> list[str]: + return sorted({f"{name}=={dist.version}" for dist in importlib.metadata.distributions() if (name := dist.metadata.get("Name"))}) + + +def _build_report(args: argparse.Namespace) -> dict[str, object]: + with patch("quant_advisor_research.advisory_report.utc_now_iso", return_value=args.frozen_generated_at): + return build_advisory_report(as_of=args.as_of, cadence="daily", political_events_path=Path(args.political_events), political_watchlist_path=Path(args.political_watchlist)) + + +def _bundle_hashes(snapshot: BundleSnapshot) -> dict[str, dict[str, object]]: + return {item.name: {"sha256": item.sha256, "size": len(item.content)} for item in snapshot.members} + + +def build_evidence(args: argparse.Namespace) -> None: + try: + dependency_files = repository_file_hashes(args.repo_root, DEPENDENCY_INVENTORY) + lock_sha = hashlib.sha256(Path(args.lock_path).read_bytes()).hexdigest() + environment = locked_environment_evidence(lock_sha256=lock_sha, uv_version=args.uv_version, python_version=platform.python_version(), distributions=_distributions()) + if not re.fullmatch(r"[0-9a-f]{40}", args.base_sha) or not re.fullmatch(r"[0-9a-f]{40}", args.head_sha): + raise EvidenceContractError("provenance_sha_invalid") + first_parent = Path(tempfile.mkdtemp(prefix="qar-d3-parent-a-", dir=args.temp_root)) + second_parent = Path(tempfile.mkdtemp(prefix="qar-d3-parent-b-", dir=args.temp_root)) + first = build_preview_workspace(_build_report(args), first_parent) + second = build_preview_workspace(_build_report(args), second_parent) + if first == second or first.stat().st_ino == second.stat().st_ino: + raise EvidenceContractError("repeat_workspace_not_distinct") + first_snapshot = validate_exact_bundle(first) + second_snapshot = validate_exact_bundle(second) + validate_preview_snapshot(first_snapshot); validate_preview_snapshot(second_snapshot) + first_files, second_files = _bundle_hashes(first_snapshot), _bundle_hashes(second_snapshot) + if first_files != second_files or any(first_snapshot.member(name).content != second_snapshot.member(name).content for name in ("manifest.json", "report.html", "report.json")): + raise EvidenceContractError("repeat_build_not_equal") + manifest = json.loads(first_snapshot.member("manifest.json").content.decode("utf-8")) + source = {"schema_version": "5", "contract_version": "model_recommendations.v5", "cadence": "daily", "as_of": args.as_of, "generated_at": args.frozen_generated_at} + if manifest.get("bundle_contract") != "qar.preview_bundle.v1" or manifest.get("source") != source: + raise EvidenceContractError("source_contract_mismatch") + evidence = { + "evidence_version": EVIDENCE_VERSION, "base_sha": args.base_sha, "head_sha": args.head_sha, + "source": {"fixture_paths": [Path(args.political_events).as_posix(), Path(args.political_watchlist).as_posix()], "provenance": "repository_representative_fixture"}, + "deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2}, + "workflow_dependency_inventory": DEPENDENCY_INVENTORY, "dependency_files": dependency_files, + "locked_environment": environment, + "bundle": {"contract": "qar.preview_bundle.v1", "source": source, "files": first_files}, + "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": second_files}, + } + evidence_path = Path(args.evidence_path) + evidence_path.parent.mkdir(parents=True, exist_ok=True) + evidence_path.write_text(json.dumps(evidence, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8") + Path(args.workspace_path_file).write_text(str(first) + "\n", encoding="utf-8") + print(json.dumps({"workspace": str(first), "evidence": str(evidence_path), "base_sha": args.base_sha, "head_sha": args.head_sha}, sort_keys=True)) + except EvidenceContractError as exc: + raise RuntimeError(exc.code) from None + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser() + for name in ("as-of", "political-events", "political-watchlist", "frozen-generated-at", "base-sha", "head-sha", "uv-version", "lock-path", "repo-root", "temp-root", "evidence-path", "workspace-path-file"): + p.add_argument(f"--{name}", required=True) + return p.parse_args() + + +if __name__ == "__main__": + build_evidence(parse_args()) diff --git a/scripts/d3_evidence.py b/scripts/d3_evidence.py new file mode 100644 index 0000000..3378184 --- /dev/null +++ b/scripts/d3_evidence.py @@ -0,0 +1,206 @@ +"""Pure D3 evidence helpers, including the shared exact-bundle validator.""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path + +FIXED_FILES = ("manifest.json", "report.html", "report.json") +EVIDENCE_VERSION = "qar.d3.build_evidence.v4" +DIST_RE = re.compile(r"^([A-Za-z0-9_.-]+)==([^\s=]+)$") +DEPENDENCY_INVENTORY = [ + ".github/workflows/qar_d3_daily_preview_artifact.yml", "pyproject.toml", "uv.lock", + "scripts/d3_build_daily_preview.py", "scripts/d3_verify_daily_preview.py", "scripts/d3_evidence.py", + "src/quant_advisor_research/advisory_report.py", "src/quant_advisor_research/artifact_integrity.py", + "src/quant_advisor_research/artifacts.py", "src/quant_advisor_research/contracts.py", + "src/quant_advisor_research/csv_utils.py", "src/quant_advisor_research/period_contract.py", + "src/quant_advisor_research/preview_bundle.py", "src/quant_advisor_research/preview_workspace.py", + "src/quant_advisor_research/time_contract.py", "tests/test_d3_exact_bundle.py", + "examples/political_events.example.csv", "examples/political_watchlist.example.csv", +] + + +class EvidenceContractError(ValueError): + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + +@dataclass(frozen=True, slots=True) +class BundleMemberSnapshot: + name: str + content: bytes + sha256: str + + +@dataclass(frozen=True, slots=True) +class BundleSnapshot: + members: tuple[BundleMemberSnapshot, ...] + + def member(self, name: str) -> BundleMemberSnapshot: + for item in self.members: + if item.name == name: + return item + raise EvidenceContractError("bundle_member_set_invalid") + + +def _directory_flags() -> int: + required = ("O_DIRECTORY", "O_CLOEXEC", "O_NOFOLLOW") + if any(not hasattr(os, name) for name in required): + raise EvidenceContractError("filesystem_unsupported") + return os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW + + +def _member_flags() -> int: + if any(not hasattr(os, name) for name in ("O_CLOEXEC", "O_NOFOLLOW")): + raise EvidenceContractError("filesystem_unsupported") + return os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC | os.O_NOFOLLOW + + +def validate_exact_bundle(workspace: str | Path) -> BundleSnapshot: + """Return an immutable FD-bound snapshot after validating all members.""" + root_fd = -1 + try: + root_fd = os.open(workspace, _directory_flags()) + root_info = os.fstat(root_fd) + if not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode): + raise EvidenceContractError("bundle_directory_invalid") + names = os.listdir(root_fd) + if set(names) != set(FIXED_FILES) or len(names) != len(FIXED_FILES): + raise EvidenceContractError("bundle_member_set_invalid") + result: list[BundleMemberSnapshot] = [] + for name in FIXED_FILES: + fd = -1 + try: + fd = os.open(name, _member_flags(), dir_fd=root_fd) + info = os.fstat(fd) + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise EvidenceContractError("bundle_member_invalid") + chunks: list[bytes] = [] + while chunk := os.read(fd, 1024 * 1024): + chunks.append(chunk) + content = b"".join(chunks) + result.append(BundleMemberSnapshot(name, content, hashlib.sha256(content).hexdigest())) + finally: + if fd >= 0: + os.close(fd) + if set(os.listdir(root_fd)) != set(FIXED_FILES): + raise EvidenceContractError("bundle_member_set_invalid") + return BundleSnapshot(tuple(result)) + except EvidenceContractError: + raise + except (OSError, TypeError, ValueError): + raise EvidenceContractError("bundle_member_invalid") from None + finally: + if root_fd >= 0: + os.close(root_fd) + + +def validate_preview_snapshot(snapshot: BundleSnapshot) -> tuple[Mapping[str, object], Mapping[str, object]]: + """Readback validation using only the immutable member bytes.""" + try: + from quant_advisor_research.preview_bundle import _canonical_json, _manifest, _render_html, _validated_source + report_bytes = snapshot.member("report.json").content + html_bytes = snapshot.member("report.html").content + manifest_bytes = snapshot.member("manifest.json").content + report = json.loads(report_bytes.decode("utf-8")) + if not isinstance(report, Mapping): + raise EvidenceContractError("readback_invalid") + validated = _validated_source(report) + if _canonical_json(validated) != report_bytes: + raise EvidenceContractError("report_bytes_noncanonical") + manifest = json.loads(manifest_bytes.decode("utf-8")) + if not isinstance(manifest, Mapping) or manifest != _manifest(validated, report_bytes, html_bytes): + raise EvidenceContractError("manifest_mismatch") + if html_bytes != _render_html(validated, report_bytes) or html_bytes.count(b'href="report.json"') != 1 or html_bytes.count(b'href="manifest.json"') != 1: + raise EvidenceContractError("html_links_invalid") + return validated, manifest + except EvidenceContractError: + raise + except (UnicodeError, json.JSONDecodeError, TypeError, ValueError, OverflowError, RecursionError): + raise EvidenceContractError("readback_invalid") from None + + +def canonical_distribution_snapshot(values: Iterable[str]) -> tuple[list[str], str]: + try: + raw = list(values) + normalized: dict[str, str] = {} + for value in raw: + if type(value) is not str or not (match := DIST_RE.fullmatch(value)): + raise EvidenceContractError("distribution_snapshot_invalid") + name = re.sub(r"[-_.]+", "-", match.group(1)).lower() + item = f"{name}=={match.group(2)}" + if name in normalized and normalized[name] != item: + raise EvidenceContractError("distribution_snapshot_conflict") + normalized[name] = item + snapshot = sorted(normalized.values()) + digest = hashlib.sha256("\n".join(snapshot).encode()).hexdigest() + return snapshot, digest + except EvidenceContractError: + raise + except (TypeError, ValueError, UnicodeError): + raise EvidenceContractError("distribution_snapshot_invalid") from None + + +def locked_environment_evidence(*, lock_sha256: str, uv_version: str, python_version: str, distributions: Iterable[str]) -> dict[str, object]: + if type(lock_sha256) is not str or not re.fullmatch(r"[0-9a-f]{64}", lock_sha256): + raise EvidenceContractError("lock_digest_invalid") + if type(uv_version) is not str or not uv_version.startswith("uv "): + raise EvidenceContractError("uv_version_invalid") + if type(python_version) is not str or not re.fullmatch(r"3\.11(?:\.\d+)?", python_version): + raise EvidenceContractError("python_version_invalid") + snapshot, digest = canonical_distribution_snapshot(distributions) + return {"lock_sha256": lock_sha256, "uv_version": uv_version, "python_version": python_version, "installed_distributions": snapshot, "installed_distributions_sha256": digest} + + +def repository_file_hashes(repo_root: str | Path, paths: Iterable[str]) -> dict[str, str]: + root_fd = -1 + opened: list[int] = [] + try: + root_fd = os.open(repo_root, _directory_flags()) + if not stat.S_ISDIR(os.fstat(root_fd).st_mode): + raise EvidenceContractError("dependency_root_invalid") + result: dict[str, str] = {} + for raw in paths: + relative = Path(raw) + if type(raw) is not str or not raw or relative.is_absolute() or any(part in ("", ".", "..") for part in relative.parts) or raw in result: + raise EvidenceContractError("dependency_path_invalid") + parent_fd = root_fd + traversed: list[int] = [] + try: + for part in relative.parts[:-1]: + next_fd = os.open(part, _directory_flags(), dir_fd=parent_fd) + traversed.append(next_fd) + parent_fd = next_fd + file_fd = os.open(relative.parts[-1], _member_flags(), dir_fd=parent_fd) + opened.append(file_fd) + info = os.fstat(file_fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise EvidenceContractError("dependency_file_invalid") + digest = hashlib.sha256() + while chunk := os.read(file_fd, 1024 * 1024): + digest.update(chunk) + result[raw] = digest.hexdigest() + finally: + for fd in reversed(traversed): + os.close(fd) + return dict(sorted(result.items())) + except EvidenceContractError: + raise + except (OSError, TypeError, ValueError, UnicodeError): + raise EvidenceContractError("dependency_file_invalid") from None + finally: + for fd in opened: + os.close(fd) + if root_fd >= 0: + os.close(root_fd) + + +def require_exact_locked_environment(value: object, expected: Mapping[str, object]) -> None: + if not isinstance(value, Mapping) or dict(value) != dict(expected): + raise EvidenceContractError("locked_environment_mismatch") diff --git a/scripts/d3_verify_daily_preview.py b/scripts/d3_verify_daily_preview.py new file mode 100644 index 0000000..5886623 --- /dev/null +++ b/scripts/d3_verify_daily_preview.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Verify downloaded D3 bundle and complete evidence binding.""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import platform +from pathlib import Path + +from d3_evidence import DEPENDENCY_INVENTORY, EVIDENCE_VERSION, BundleSnapshot, EvidenceContractError, locked_environment_evidence, repository_file_hashes, validate_exact_bundle, validate_preview_snapshot + +EXPECTED_KEYS = frozenset({"evidence_version", "base_sha", "head_sha", "source", "deterministic_clock", "workflow_dependency_inventory", "dependency_files", "locked_environment", "bundle", "repeat_build"}) + + +def _distributions() -> list[str]: + return sorted({f"{name}=={dist.version}" for dist in importlib.metadata.distributions() if (name := dist.metadata.get("Name"))}) + + +def _hashes(snapshot: BundleSnapshot) -> dict[str, dict[str, object]]: + return {item.name: {"sha256": item.sha256, "size": len(item.content)} for item in snapshot.members} + + +def verify(args: argparse.Namespace) -> None: + try: + evidence = json.loads(Path(args.evidence_path).read_text(encoding="utf-8")) + if not isinstance(evidence, dict) or set(evidence) != EXPECTED_KEYS: + raise EvidenceContractError("build_evidence_shape_invalid") + dependency_files = repository_file_hashes(args.repo_root, DEPENDENCY_INVENTORY) + if evidence["workflow_dependency_inventory"] != DEPENDENCY_INVENTORY or evidence["dependency_files"] != dependency_files: + raise EvidenceContractError("dependency_evidence_mismatch") + environment = locked_environment_evidence(lock_sha256=hashlib.sha256(Path(args.lock_path).read_bytes()).hexdigest(), uv_version=args.uv_version, python_version=platform.python_version(), distributions=_distributions()) + expected_source = {"schema_version": "5", "contract_version": "model_recommendations.v5", "cadence": "daily", "as_of": args.as_of, "generated_at": args.frozen_generated_at} + snapshot = validate_exact_bundle(args.workspace) + _, manifest = validate_preview_snapshot(snapshot) + if manifest.get("bundle_contract") != "qar.preview_bundle.v1" or manifest.get("source") != expected_source: + raise EvidenceContractError("source_contract_mismatch") + files = _hashes(snapshot) + expected = { + "evidence_version": EVIDENCE_VERSION, "base_sha": args.base_sha, "head_sha": args.head_sha, + "source": {"fixture_paths": [Path(args.political_events).as_posix(), Path(args.political_watchlist).as_posix()], "provenance": "repository_representative_fixture"}, + "deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2}, + "workflow_dependency_inventory": DEPENDENCY_INVENTORY, "dependency_files": dependency_files, + "locked_environment": environment, + "bundle": {"contract": "qar.preview_bundle.v1", "source": expected_source, "files": files}, + "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": files}, + } + if evidence != expected: + raise EvidenceContractError("build_evidence_mismatch") + print(json.dumps({"status": "passed", "evidence_binding": "exact_full_payload_and_bundle_members", "base_sha": args.base_sha, "head_sha": args.head_sha, "files": files}, sort_keys=True)) + except EvidenceContractError as exc: + raise RuntimeError(exc.code) from None + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser() + for name in ("workspace", "evidence-path", "base-sha", "head-sha", "uv-version", "lock-path", "repo-root", "as-of", "political-events", "political-watchlist", "frozen-generated-at"): + p.add_argument(f"--{name}", required=True) + return p.parse_args() + + +if __name__ == "__main__": + verify(parse_args()) diff --git a/tests/test_d3_exact_bundle.py b/tests/test_d3_exact_bundle.py new file mode 100644 index 0000000..6c08cf5 --- /dev/null +++ b/tests/test_d3_exact_bundle.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +from d3_evidence import EvidenceContractError, canonical_distribution_snapshot, validate_exact_bundle + + +def _normal_bundle(path: Path) -> None: + path.mkdir() + for name in ("manifest.json", "report.html", "report.json"): + (path / name).write_text(name) + + +def test_exact_bundle_validator_returns_only_regular_single_link_members(tmp_path): + bundle = tmp_path / "bundle" + _normal_bundle(bundle) + snapshot = validate_exact_bundle(bundle) + assert tuple(item.name for item in snapshot.members) == ("manifest.json", "report.html", "report.json") + assert snapshot.member("report.json").content == b"report.json" + + +def test_snapshot_is_immutable_after_path_replacement(tmp_path): + bundle = tmp_path / "bundle" + _normal_bundle(bundle) + snapshot = validate_exact_bundle(bundle) + (bundle / "report.json").unlink() + (bundle / "report.json").write_text("replacement") + assert snapshot.member("report.json").content == b"report.json" + + +@pytest.mark.parametrize("mutation", [ + lambda p: (p / "extra.txt").write_text("x"), + lambda p: (p / "report.json").unlink(), + lambda p: (p / "extra").symlink_to(p / "report.json"), + lambda p: (p / "report.json").unlink() or (p / "report.json").symlink_to(p / "manifest.json"), +]) +def test_exact_bundle_validator_rejects_extra_missing_and_symlink(tmp_path, mutation): + bundle = tmp_path / "bundle" + _normal_bundle(bundle) + mutation(bundle) + with pytest.raises(EvidenceContractError): + validate_exact_bundle(bundle) + + +def test_exact_bundle_validator_rejects_hardlink_and_nonregular(tmp_path): + bundle = tmp_path / "bundle" + _normal_bundle(bundle) + (bundle / "alias").hardlink_to(bundle / "report.json") + with pytest.raises(EvidenceContractError): + validate_exact_bundle(bundle) + + bundle = tmp_path / "fifo-bundle" + _normal_bundle(bundle) + (bundle / "report.html").unlink() + (bundle / "report.html").mkdir() + with pytest.raises(EvidenceContractError): + validate_exact_bundle(bundle) + + +def test_distribution_snapshot_uses_pep503_and_rejects_conflict(): + values, _ = canonical_distribution_snapshot(["Zed==1.0", "alpha==2.0", "ALPHA==2.0"]) + assert values == ["alpha==2.0", "zed==1.0"] + with pytest.raises(EvidenceContractError, match="distribution_snapshot_conflict"): + canonical_distribution_snapshot(["alpha_.==1.0", "ALPHA-==2.0"]) + + +def test_workflow_uses_exact_paths_and_immutable_actions(): + workflow = (Path(__file__).parents[1] / ".github/workflows/qar_d3_daily_preview_artifact.yml").read_text() + assert "pull_request:" in workflow and "workflow_dispatch:" in workflow + assert "uv sync --locked --extra test" in workflow and "pip install" not in workflow + assert "report.json" in workflow and "report.html" in workflow and "manifest.json" in workflow + assert "steps.build.outputs.workspace }}/*" not in workflow + assert "--base-sha" in workflow and "--head-sha" in workflow + for action in ("actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", "actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1", "astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e", "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", "actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131"): + assert action in workflow