diff --git a/.coveragerc b/.coveragerc index a152f7651..c022aafcb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,6 +6,7 @@ source = scripts.check_docstrings scripts.check_coverage scripts.prepare_agent_pr_message + scripts.release_evidence [report] fail_under = 100 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f29d3848..b6b4abbc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,16 @@ jobs: run: python3 scripts/check_workspace_contract.py - name: Validate Rust documentation contract run: python3 scripts/check_docstrings.py + - name: Generate and validate release SBOM/provenance evidence + run: | + python3 scripts/release_evidence.py generate \ + --repository-root . \ + --output-directory "${RUNNER_TEMP}/tepp-release-evidence" \ + --git-commit "${GITHUB_SHA}" + python3 scripts/release_evidence.py validate \ + --evidence-directory "${RUNNER_TEMP}/tepp-release-evidence" \ + --repository-root . \ + --expected-git-commit "${GITHUB_SHA}" rust-quality: name: Format, lint, test, rustdoc, and dependency policy diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e663d2df..3e2174e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- Repository release evidence tooling: `scripts/release_evidence.py` generates CycloneDX 1.5 SBOM, exact-head provenance, and SHA-256 checksums from `Cargo.lock`/`Cargo.toml`, with fail-closed validation and CI generation on every quality gate. - `persistence_postgres` `live-sqlx` feature: real `SQLx`/`PgPool` open/execute behind validated `DATABASE_URL` and `LiveSqlxPoolOptions`, with offline/live executor backends and CI coverage exclusion for the transport module. - `persistence_postgres` live pool open gate: validated `LiveSqlxPoolOptions`, fail-closed `open_live_sqlx_pool` / `LiveSqlxPool` (`SqlSession`) with offline test backend; optional `live-sqlx` attaches real `SQLx`/`PgPool` after `DATABASE_URL` validation. - `validation_core` recovery metrics: parameter matching, RMSE/bias with standard errors, interval coverage with Wilson bounds, relation-edge precision/recall, temporal-order accuracy, Monte Carlo summaries, and SE-aware acceptance gates with machine-readable reports. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 110028bb9..bc1e5a1b7 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -15,21 +15,12 @@ The full APA 7th standards/literature register remains `docs/research/standards- | forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main | implemented-main | | event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; full intelligence stack remaining | partial | | time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; multilevel estimators remaining | partial | -| Allen relation algebra/bounded closure | ADR 0002; temporal research | PR #9 `temporal_core` reasoner on protected main; bounded path-consistency claim only | implemented-main | -| forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main | implemented-main | -| event ontology/evidence mentions | PRD; ADR 0003 | PR #13 `event_core` mention/instance boundary on protected main; broader ontology target remains | partial | -| time-varying cross-classified multiple membership | PRD; ADR 0003 | PR #12 `membership_core` on protected main; PR #25 ESS/design-effect follow-on | partial | | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | -| recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; ADR 0007/0014; scientific acceptance | `validation_core` on protected main | implemented-main | +| recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver; full physical ERD/RLS/live CI PG remaining | partial | | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | -| versioned service/API contracts and exports | PRD; API contract; ADR 0011 | active `tepp_api` PR; HTTP service remaining accepted-target | active-PR | -| immutable split/run/reproducibility manifests | ADR 0013; ERD | future persistence/model-run artifact chain | accepted-target | -| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | PR #16 migration contracts + in-memory bitemporal adapters on main; PR #23 live SQL transport port | partial | -| known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | PR #18 `tepp_simulation` deterministic corpora + digests on protected main | implemented-main | -| immutable split/run/reproducibility manifests | ADR 0013; ERD | active API manifest contract plus future persistence/model-run artifact chain | accepted-target | -| recovery metrics and SE-aware Monte Carlo gates | PRD; Test Strategy; ADR 0014 | PR #19 `validation_core` RMSE/bias/coverage/graph/temporal metrics | active-PR | -| versioned analysis-run and export contracts | ADR 0011/0013; API contract | PR #21 `tepp_api` request/error/manifest/JSON-LD/GraphML contracts | active-PR | +| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | +| immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; persistence/model-run artifact chain remaining | partial | | multilingual shared latent semantic space | PRD; ADR 0004 | future semantic/concept/topic crates | accepted-target | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | | global P0 topic identity with activity/dormancy/reactivation | ADR 0012 | future topic lineage/activity state | accepted-target | @@ -45,20 +36,17 @@ The full APA 7th standards/literature register remains `docs/research/standards- | purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | future authorization/persistence/export/provider adapters | accepted-target | | tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | future service/persistence boundaries | accepted-target | | standalone + modular CWL MSA / no cross-service DB coupling | ADR 0011; `docs/API_CONTRACT.md` | current standalone crates; future service ports | partial | -| naruon modular artifact consumer boundary | ADR 0011; API contract | `docs/connectors/naruon-artifact-consumer.md` + example payload; HTTP service remaining | partial | +| naruon modular artifact consumer boundary | ADR 0011/0012; API contract | `docs/connectors/naruon-artifact-consumer.md` + PR #22 versioned consumer contract on protected main; HTTP service remaining | partial | | contextual-orchestrator interpretation port boundary | ADR 0010/0011; LLM orchestration | `docs/connectors/contextual-orchestrator-interpretation-port.md`; live port remaining | partial | | autonomous model proposal separated from verification/publication/review/merge | ADR 0015 | future safe OpenCode/NVIDIA autonomous-development workflow | accepted-target | -| standalone + modular CWL MSA / no cross-service DB coupling | ADR 0011; `docs/API_CONTRACT.md` | current standalone crates; active API/connector contracts; future service ports | partial | -| naruon consumer boundary | ADR 0011/0012; API contract | PR #22 versioned TEPP artifact consumer contract; no lexical heuristic substitution or cross-service DB access | active-PR | | contextual-orchestrator execution boundary | ADR 0010/0011 | provider-neutral orchestration port; TEPP retains scientific authority | accepted-target | -| autonomous model proposal separated from verification/publication/review/merge | ADR 0015 | PR #11 credential-separated NIM proposal, deterministic verifier, and publisher workflow | active-PR | -| foundation validation / release-readiness ledger | ADR 0014; Test Strategy | PR #24 `docs/validation/temporal-event-foundation.md` | active-PR | +| foundation validation / release-readiness ledger | ADR 0014; Test Strategy | PR #24 `docs/validation/temporal-event-foundation.md` on protected main | implemented-main | | scientific claim promotion separated from design/implementation/release | ADR 0014; ADR policy | documentation/CI/domain validation/release evidence | partial | | CSAP/SOC 2/ISO/NIST assurance readiness | `docs/COMPLIANCE_READINESS.md`; research register | repository controls + future deployment evidence | accepted-target / deployment-owned | | threat-model controls and scientific-integrity security | `SECURITY.md`; `docs/THREAT_MODEL.md` | deterministic security/privacy/scientific validation gates | partial | | accessible bitemporal/network/drift/invariance views | PRD/UML | future `visual_analytics`; Figma in approved visual phase | accepted-target | | 100% production line/branch/public docs | ADR 0007; AGENTS | CI/repository contracts | implemented-main and required for future source | -| SBOM/provenance/reproducible release | ADR 0014; Operability/Compliance | future exact-release evidence bundle | accepted-target | +| SBOM/provenance/reproducible release | ADR 0014; Operability/Compliance | `scripts/release_evidence.py` CycloneDX SBOM + exact-head provenance + checksums in CI; full package/image release bundle remaining | partial | ## Scientific evidence promotion diff --git a/docs/adr/0014-scientific-claim-promotion-and-release-evidence.md b/docs/adr/0014-scientific-claim-promotion-and-release-evidence.md index 45a189af1..81ebb373b 100644 --- a/docs/adr/0014-scientific-claim-promotion-and-release-evidence.md +++ b/docs/adr/0014-scientific-claim-promotion-and-release-evidence.md @@ -1,7 +1,7 @@ # ADR 0014 — Scientific claim promotion and release evidence authority **Decision status:** Accepted -**Implementation maturity:** partial +**Implementation maturity:** partial — claim/promotion authority documented; repository SBOM/provenance evidence generator and CI validation implemented; full package/image release bundle and scientific claim promotion packages remain accepted-target **Date:** 2026-08-12 **Supersedes:** None; extends ADR 0007 from repository quality tooling to product/scientific claim authority. diff --git a/docs/adr/README.md b/docs/adr/README.md index 62ec4928d..c8603db47 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,7 +19,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; no direct cross-service application-table coupling. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | | [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; optional `live-sqlx` `PgPool` driver implemented; full physical ERD/RLS/live PG CI remain accepted-target. | -| [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence authority | Accepted | partial | Separates design, implementation, scientific/product claim, and release authority. | +| [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence authority | Accepted | partial | Separates design, implementation, scientific/product claim, and release authority; repository SBOM/provenance generator implemented, full release bundle remaining. | | [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | active-PR | Separates model proposal, deterministic verification, publication, independent review, and merge/release authority. | | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | accepted-target | Separates observed evidence, detection/tracking, prediction/schema inference, temporal consistency, and promoted transition authority. | diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index d78975c07..3076fa919 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -16,14 +16,15 @@ This report tracks exact-head scientific and engineering evidence required befor | Six-clock temporal | `temporal_core` | implemented-main | — | unit + wire | Task 3 / PR #8 | | Allen path-consistency | `temporal_core` | implemented-main | — | unit + budget tests | Task 4 / PR #9 | | Event mention/instance | `event_core` | partial | — | unit + fail-closed promotion | Task 5 / PR #13 | -| Multiple membership | `membership_core` | partial | active-PR (PR #25 ESS follow-on) | unit + ESS weights | Task 7 / PR #12 + #25 | -| Forward transition DAG | `relation_graph` | accepted-target | active-PR (PR #14) | unit + cycle rejection | Task 6 / PR #14 | -| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | active-PR (PR #27 live-sqlx) | migration contracts + recording transport + optional PgPool | Task 8 / PR #16 + #23 + #26 + #27 | +| Multiple membership | `membership_core` | partial | — | unit + ESS weights | Task 7 / PR #12 + #25 | +| Forward transition DAG | `relation_graph` | implemented-main | — | unit + cycle rejection | Task 6 / PR #14 | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | — | migration contracts + recording transport + optional PgPool | Task 8 / PR #16 + #23 + #26 + #27 | | Leakage-safe splits | `corpus_split` | implemented-main | — | cutoff + co-partition tests | Task 9 / PR #17 | | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | -| Recovery metrics | `validation_core` | accepted-target | active-PR (PR #19) | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | -| Versioned API/export contracts | `tepp_api` | accepted-target | active-PR (PR #21) | unknown-field/version/limit tests | Task 12 / PR #21 | -| CWL modular connectors | `docs/connectors/*` | accepted-target | active-PR (PR #22) | contract docs + examples | PR #22 | +| Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | +| Versioned API/export contracts | `tepp_api` | implemented-main | — | unknown-field/version/limit tests | Task 12 / PR #21; HTTP service remaining | +| CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | +| Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | active-PR (PR #28) | generate+validate in CI | Task 13 partial | ## Scientific acceptance checklist (foundation) @@ -34,7 +35,8 @@ This report tracks exact-head scientific and engineering evidence required befor - [ ] CPU `f64` reference path present for every estimator (future measurement crates) - [ ] 100% production line and branch coverage on exact head - [ ] Independent review approval on each merge-critical PR -- [ ] SBOM/provenance/reproducibility package for release cut +- [x] Repository SBOM/provenance generator + CI validation (`scripts/release_evidence.py`) +- [ ] Full package/image SBOM/provenance/reproducibility package for release cut ## Release decision rule diff --git a/scripts/release_evidence.py b/scripts/release_evidence.py new file mode 100644 index 000000000..21c0df906 --- /dev/null +++ b/scripts/release_evidence.py @@ -0,0 +1,456 @@ +"""Generate and validate fail-closed release SBOM and provenance evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import tomllib +import uuid +from pathlib import Path +from typing import Any, Iterable, Mapping, MutableMapping, Sequence + +# Stable namespace so serialNumber is a deterministic RFC 4122 UUID for a lock digest. +_TEPP_SBOM_NAMESPACE = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") + + +REQUIRED_SBOM_KEYS = ( + "bomFormat", + "specVersion", + "serialNumber", + "version", + "metadata", + "components", +) +REQUIRED_PROVENANCE_KEYS = ( + "schema_version", + "evidence_kind", + "git_commit", + "cargo_lock_sha256", + "sbom_sha256", + "component_count", + "workspace_crate_count", + "workspace_crates", +) + + +def sha256_bytes(payload: bytes) -> str: + """Return the lowercase hex SHA-256 digest of *payload*.""" + + return hashlib.sha256(payload).hexdigest() + + +def sha256_text(text: str) -> str: + """Return the lowercase hex SHA-256 digest of UTF-8 *text*.""" + + return sha256_bytes(text.encode("utf-8")) + + +def sha256_file(path: Path) -> str: + """Return the lowercase hex SHA-256 digest of *path* contents.""" + + return sha256_bytes(path.read_bytes()) + + +def load_cargo_lock(path: Path) -> Mapping[str, Any]: + """Load and validate a Cargo.lock document.""" + + try: + payload = tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as error: + raise ValueError(f"Cargo.lock is not valid TOML: {error}") from error + packages = payload.get("package") + if not isinstance(packages, list) or not packages: + raise ValueError("Cargo.lock must contain a non-empty package list") + for package in packages: + if not isinstance(package, Mapping): + raise ValueError("Cargo.lock package entries must be tables") + name = package.get("name") + version = package.get("version") + if not isinstance(name, str) or not name: + raise ValueError("Cargo.lock package name must be a non-empty string") + if not isinstance(version, str) or not version: + raise ValueError("Cargo.lock package version must be a non-empty string") + return payload + + +def workspace_crate_names(cargo_toml: Path) -> list[str]: + """Return ordered workspace member crate directory names from Cargo.toml.""" + + try: + payload = tomllib.loads(cargo_toml.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as error: + raise ValueError(f"Cargo.toml is not valid TOML: {error}") from error + workspace = payload.get("workspace") + if not isinstance(workspace, Mapping): + raise ValueError("Cargo.toml must declare a workspace table") + members = workspace.get("members") + if not isinstance(members, list) or not members: + raise ValueError("Cargo.toml workspace members must be a non-empty list") + names: list[str] = [] + for member in members: + if not isinstance(member, str) or not member: + raise ValueError("workspace members must be non-empty strings") + names.append(Path(member).name) + return names + + +def component_from_package(package: Mapping[str, Any]) -> dict[str, Any]: + """Map one Cargo.lock package table into a CycloneDX component object.""" + + name = package["name"] + version = package["version"] + component: dict[str, Any] = { + "type": "library", + "name": name, + "version": version, + "bom-ref": f"pkg:cargo/{name}@{version}", + "purl": f"pkg:cargo/{name}@{version}", + } + source = package.get("source") + if isinstance(source, str) and source: + component["properties"] = [{"name": "cargo:source", "value": source}] + checksum = package.get("checksum") + if isinstance(checksum, str) and checksum: + component["hashes"] = [{"alg": "SHA-256", "content": checksum}] + return component + + +def build_sbom( + packages: Sequence[Mapping[str, Any]], + *, + git_commit: str, + cargo_lock_sha256: str, +) -> dict[str, Any]: + """Build a CycloneDX 1.5 JSON BOM for *packages*.""" + + if not git_commit or not isinstance(git_commit, str): + raise ValueError("git_commit must be a non-empty string") + if len(cargo_lock_sha256) != 64: + raise ValueError("cargo_lock_sha256 must be a 64-character hex digest") + components = [component_from_package(package) for package in packages] + serial = f"urn:uuid:{uuid.uuid5(_TEPP_SBOM_NAMESPACE, cargo_lock_sha256)}" + return { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": serial, + "version": 1, + "metadata": { + "component": { + "type": "application", + "name": "TEPP", + "version": git_commit, + }, + "properties": [ + {"name": "tepp:git_commit", "value": git_commit}, + {"name": "tepp:cargo_lock_sha256", "value": cargo_lock_sha256}, + ], + }, + "components": components, + } + + +def build_provenance( + *, + git_commit: str, + cargo_lock_sha256: str, + sbom_sha256: str, + component_count: int, + workspace_crates: Sequence[str], +) -> dict[str, Any]: + """Build the TEPP provenance evidence document for one exact head.""" + + if component_count < 0: + raise ValueError("component_count must be non-negative") + if not workspace_crates: + raise ValueError("workspace_crates must not be empty") + for name in workspace_crates: + if not isinstance(name, str) or not name: + raise ValueError("workspace crate names must be non-empty strings") + return { + "schema_version": "tepp.release_provenance.v1", + "evidence_kind": "repository_release_evidence", + "git_commit": git_commit, + "cargo_lock_sha256": cargo_lock_sha256, + "sbom_sha256": sbom_sha256, + "component_count": component_count, + "workspace_crate_count": len(workspace_crates), + "workspace_crates": list(workspace_crates), + } + + +def write_json(path: Path, payload: Mapping[str, Any]) -> str: + """Write canonical JSON for *payload* and return its SHA-256 digest.""" + + text = json.dumps(payload, indent=2, sort_keys=True) + "\n" + path.write_text(text, encoding="utf-8") + return sha256_text(text) + + +def write_checksums(path: Path, digests: Mapping[str, str]) -> None: + """Write a stable SHA-256 checksums file for *digests*.""" + + lines = [f"{digest} {name}" for name, digest in sorted(digests.items())] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def generate_evidence( + repository_root: Path, + output_directory: Path, + *, + git_commit: str, +) -> Mapping[str, str]: + """Generate SBOM, provenance, and checksums under *output_directory*.""" + + if not git_commit.strip(): + raise ValueError("git_commit must not be blank") + cargo_lock = repository_root / "Cargo.lock" + cargo_toml = repository_root / "Cargo.toml" + if not cargo_lock.is_file(): + raise ValueError("Cargo.lock must exist at the repository root") + if not cargo_toml.is_file(): + raise ValueError("Cargo.toml must exist at the repository root") + + lock_payload = load_cargo_lock(cargo_lock) + packages = lock_payload["package"] + assert isinstance(packages, list) + cargo_lock_sha256 = sha256_file(cargo_lock) + workspace_crates = workspace_crate_names(cargo_toml) + sbom = build_sbom( + packages, + git_commit=git_commit, + cargo_lock_sha256=cargo_lock_sha256, + ) + output_directory.mkdir(parents=True, exist_ok=True) + sbom_path = output_directory / "sbom.cdx.json" + provenance_path = output_directory / "provenance.json" + checksums_path = output_directory / "checksums.sha256" + sbom_sha256 = write_json(sbom_path, sbom) + provenance = build_provenance( + git_commit=git_commit, + cargo_lock_sha256=cargo_lock_sha256, + sbom_sha256=sbom_sha256, + component_count=len(packages), + workspace_crates=workspace_crates, + ) + provenance_sha256 = write_json(provenance_path, provenance) + digests = { + "sbom.cdx.json": sbom_sha256, + "provenance.json": provenance_sha256, + "Cargo.lock": cargo_lock_sha256, + } + write_checksums(checksums_path, digests) + return digests + + +def require_mapping(payload: Any, label: str) -> MutableMapping[str, Any]: + """Return *payload* when it is a JSON object mapping.""" + + if not isinstance(payload, MutableMapping): + raise ValueError(f"{label} must be a JSON object") + return payload + + +def validate_sbom(path: Path) -> str: + """Validate CycloneDX SBOM shape and return a success message.""" + + payload = require_mapping(json.loads(path.read_text(encoding="utf-8")), "SBOM") + for key in REQUIRED_SBOM_KEYS: + if key not in payload: + raise ValueError(f"SBOM is missing required key: {key}") + if payload.get("bomFormat") != "CycloneDX": + raise ValueError("SBOM bomFormat must be CycloneDX") + if payload.get("specVersion") != "1.5": + raise ValueError("SBOM specVersion must be 1.5") + components = payload.get("components") + if not isinstance(components, list) or not components: + raise ValueError("SBOM components must be a non-empty list") + for component in components: + if not isinstance(component, Mapping): + raise ValueError("SBOM components must be objects") + for field in ("type", "name", "version", "purl"): + value = component.get(field) + if not isinstance(value, str) or not value: + raise ValueError(f"SBOM component {field} must be a non-empty string") + return f"SBOM validation: PASS ({len(components)} components)" + + +def validate_provenance(path: Path, *, expected_git_commit: str | None = None) -> str: + """Validate provenance evidence shape and optional commit binding.""" + + payload = require_mapping( + json.loads(path.read_text(encoding="utf-8")), "provenance" + ) + for key in REQUIRED_PROVENANCE_KEYS: + if key not in payload: + raise ValueError(f"provenance is missing required key: {key}") + if payload.get("schema_version") != "tepp.release_provenance.v1": + raise ValueError("unsupported provenance schema_version") + if payload.get("evidence_kind") != "repository_release_evidence": + raise ValueError("unsupported provenance evidence_kind") + git_commit = payload.get("git_commit") + if not isinstance(git_commit, str) or not git_commit: + raise ValueError("provenance git_commit must be a non-empty string") + if expected_git_commit is not None and git_commit != expected_git_commit: + raise ValueError("provenance git_commit does not match expected commit") + for digest_key in ("cargo_lock_sha256", "sbom_sha256"): + digest = payload.get(digest_key) + if not isinstance(digest, str) or len(digest) != 64: + raise ValueError(f"provenance {digest_key} must be a 64-character digest") + crates = payload.get("workspace_crates") + if not isinstance(crates, list) or not crates: + raise ValueError("provenance workspace_crates must be a non-empty list") + if payload.get("workspace_crate_count") != len(crates): + raise ValueError("workspace_crate_count must match workspace_crates length") + component_count = payload.get("component_count") + if not isinstance(component_count, int) or component_count < 1: + raise ValueError("component_count must be a positive integer") + return ( + f"provenance validation: PASS (commit={git_commit}, " + f"components={component_count}, crates={len(crates)})" + ) + + + +def _read_checksum_records(path: Path) -> dict[str, str]: + """Parse a checksums.sha256 file into name -> digest mappings.""" + + records: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line: + continue + parts = line.split() + if len(parts) != 2: + raise ValueError("checksums lines must be ' '") + digest, name = parts + if len(digest) != 64: + raise ValueError("checksum digests must be 64 hex characters") + if name in records: + raise ValueError(f"duplicate checksum entry for {name}") + records[name] = digest + return records + + +def validate_checksums( + path: Path, + evidence_directory: Path, + *, + repository_root: Path | None = None, +) -> str: + """Validate checksums file digests against evidence and optional repository root.""" + + records = _read_checksum_records(path) + if "sbom.cdx.json" not in records or "provenance.json" not in records: + raise ValueError("checksums must include sbom.cdx.json and provenance.json") + if "Cargo.lock" not in records: + raise ValueError("checksums must include Cargo.lock") + for name, expected in records.items(): + if name == "Cargo.lock": + if repository_root is None: + # File binding requires repository_root; provenance cross-check + # still verifies the recorded digest in validate_evidence_bundle. + continue + target = repository_root / "Cargo.lock" + else: + target = evidence_directory / name + if not target.is_file(): + raise ValueError(f"checksum target missing: {name}") + actual = sha256_file(target) + if actual != expected: + raise ValueError(f"checksum mismatch for {name}") + return f"checksums validation: PASS ({len(records)} entries)" + + +def validate_evidence_bundle( + evidence_directory: Path, + *, + repository_root: Path, + expected_git_commit: str | None = None, +) -> list[str]: + """Validate SBOM, provenance, and checksums for one evidence directory.""" + + sbom_path = evidence_directory / "sbom.cdx.json" + provenance_path = evidence_directory / "provenance.json" + checksums_path = evidence_directory / "checksums.sha256" + for path in (sbom_path, provenance_path, checksums_path): + if not path.is_file(): + raise ValueError(f"missing evidence file: {path.name}") + messages = [ + validate_sbom(sbom_path), + validate_provenance(provenance_path, expected_git_commit=expected_git_commit), + validate_checksums( + checksums_path, + evidence_directory, + repository_root=repository_root, + ), + ] + provenance = json.loads(provenance_path.read_text(encoding="utf-8")) + sbom_digest = sha256_file(sbom_path) + if provenance.get("sbom_sha256") != sbom_digest: + raise ValueError("provenance sbom_sha256 does not match sbom.cdx.json") + checksum_records = _read_checksum_records(checksums_path) + if checksum_records.get("Cargo.lock") != provenance.get("cargo_lock_sha256"): + raise ValueError("checksums Cargo.lock digest does not match provenance") + # Cargo.lock file digest is already verified against checksums via validate_checksums. + return messages + + +def build_parser() -> argparse.ArgumentParser: + """Create the release-evidence CLI parser.""" + + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + generate = subparsers.add_parser("generate", help="Generate SBOM and provenance") + generate.add_argument("--repository-root", type=Path, default=Path.cwd()) + generate.add_argument("--output-directory", type=Path, required=True) + generate.add_argument("--git-commit", required=True) + + validate = subparsers.add_parser("validate", help="Validate an evidence bundle") + validate.add_argument("--evidence-directory", type=Path, required=True) + validate.add_argument("--expected-git-commit", default=None) + validate.add_argument( + "--repository-root", + type=Path, + required=True, + help="Repository root containing Cargo.lock for digest binding", + ) + + return parser + + +def main(arguments: Iterable[str] | None = None) -> int: + """CLI entrypoint for generate and validate subcommands.""" + + parser = build_parser() + namespace = parser.parse_args(list(arguments) if arguments is not None else None) + try: + if namespace.command == "generate": + digests = generate_evidence( + namespace.repository_root, + namespace.output_directory, + git_commit=namespace.git_commit, + ) + print( + "release evidence: PASS " + f"(sbom={digests['sbom.cdx.json'][:12]}…, " + f"provenance={digests['provenance.json'][:12]}…)" + ) + return 0 + messages = validate_evidence_bundle( + namespace.evidence_directory, + expected_git_commit=namespace.expected_git_commit, + repository_root=namespace.repository_root, + ) + for message in messages: + print(message) + return 0 + except (OSError, json.JSONDecodeError, ValueError) as error: + print(f"release evidence: FAIL: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/tests/quality/test_release_evidence.py b/tests/quality/test_release_evidence.py new file mode 100644 index 000000000..6bf5a9205 --- /dev/null +++ b/tests/quality/test_release_evidence.py @@ -0,0 +1,521 @@ +"""Tests for release SBOM and provenance evidence contracts.""" + +from __future__ import annotations + +import contextlib +import io +import json +import tempfile +import unittest +import unittest.mock as mock +from pathlib import Path + +from scripts import release_evidence as release_evidence + + +class ReleaseEvidenceTests(unittest.TestCase): + """Exercise generation, validation, and fail-closed error paths.""" + + def write_lock(self, root: Path, packages: list[dict[str, object]]) -> Path: + """Write a minimal Cargo.lock under *root*.""" + + lines = ["version = 4", ""] + for package in packages: + lines.append("[[package]]") + lines.append(f'name = "{package["name"]}"') + lines.append(f'version = "{package["version"]}"') + source = package.get("source") + if isinstance(source, str): + lines.append(f'source = "{source}"') + checksum = package.get("checksum") + if isinstance(checksum, str): + lines.append(f'checksum = "{checksum}"') + lines.append("") + path = root / "Cargo.lock" + path.write_text("\n".join(lines), encoding="utf-8") + return path + + def write_workspace(self, root: Path, members: list[str]) -> Path: + """Write a minimal workspace Cargo.toml under *root*.""" + + member_lines = ",\n".join(f' "{member}"' for member in members) + text = f"[workspace]\nmembers = [\n{member_lines}\n]\n" + path = root / "Cargo.toml" + path.write_text(text, encoding="utf-8") + return path + + def test_generate_and_validate_round_trip(self) -> None: + """Generated evidence validates and binds digests to the git commit.""" + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.write_lock( + root, + [ + { + "name": "alpha", + "version": "1.0.0", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "checksum": "a" * 64, + }, + {"name": "workspace_crate", "version": "0.1.0"}, + ], + ) + self.write_workspace(root, ["crates/workspace_crate", "crates/other"]) + output = root / "evidence" + digests = release_evidence.generate_evidence( + root, output, git_commit="abc123" + ) + self.assertIn("sbom.cdx.json", digests) + self.assertIn("provenance.json", digests) + messages = release_evidence.validate_evidence_bundle( + output, + expected_git_commit="abc123", + repository_root=root, + ) + self.assertEqual(len(messages), 3) + sbom = json.loads((output / "sbom.cdx.json").read_text(encoding="utf-8")) + self.assertEqual(sbom["bomFormat"], "CycloneDX") + self.assertTrue(str(sbom["serialNumber"]).startswith("urn:uuid:")) + uuid_part = str(sbom["serialNumber"]).removeprefix("urn:uuid:") + self.assertEqual(len(uuid_part), 36) + self.assertEqual(len(sbom["components"]), 2) + self.assertEqual(sbom["components"][0]["hashes"][0]["content"], "a" * 64) + provenance = json.loads( + (output / "provenance.json").read_text(encoding="utf-8") + ) + self.assertEqual(provenance["workspace_crate_count"], 2) + self.assertEqual( + provenance["workspace_crates"], ["workspace_crate", "other"] + ) + + def test_load_and_workspace_fail_closed(self) -> None: + """Malformed lockfiles and workspace manifests raise ValueError.""" + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + bad_lock = root / "Cargo.lock" + bad_lock.write_text("not = [toml", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "not valid TOML"): + release_evidence.load_cargo_lock(bad_lock) + empty_packages = root / "empty.lock" + empty_packages.write_text("version = 4\npackage = []\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "non-empty package list"): + release_evidence.load_cargo_lock(empty_packages) + invalid_entry = root / "invalid.lock" + invalid_entry.write_text( + 'version = 4\n[[package]]\nname = ""\nversion = "1"\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "package name"): + release_evidence.load_cargo_lock(invalid_entry) + missing_version = root / "missing-version.lock" + missing_version.write_text( + 'version = 4\n[[package]]\nname = "x"\nversion = ""\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "package version"): + release_evidence.load_cargo_lock(missing_version) + non_table = root / "non-table.lock" + non_table.write_text( + 'version = 4\npackage = ["nope"]\n', encoding="utf-8" + ) + with self.assertRaisesRegex(ValueError, "must be tables"): + release_evidence.load_cargo_lock(non_table) + + bad_toml = root / "Cargo.toml" + bad_toml.write_text("workspace = [", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "not valid TOML"): + release_evidence.workspace_crate_names(bad_toml) + no_workspace = root / "no-ws.toml" + no_workspace.write_text('name = "x"\n', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "workspace table"): + release_evidence.workspace_crate_names(no_workspace) + empty_members = root / "empty-members.toml" + empty_members.write_text( + "[workspace]\nmembers = []\n", encoding="utf-8" + ) + with self.assertRaisesRegex(ValueError, "non-empty list"): + release_evidence.workspace_crate_names(empty_members) + bad_member = root / "bad-member.toml" + bad_member.write_text( + "[workspace]\nmembers = [\"\"]\n", encoding="utf-8" + ) + with self.assertRaisesRegex(ValueError, "non-empty strings"): + release_evidence.workspace_crate_names(bad_member) + + def test_builders_and_validators_reject_invalid_inputs(self) -> None: + """Builder and validator helpers fail closed on invalid shapes.""" + + with self.assertRaisesRegex(ValueError, "non-empty string"): + release_evidence.build_sbom([], git_commit="", cargo_lock_sha256="a" * 64) + with self.assertRaisesRegex(ValueError, "64-character"): + release_evidence.build_sbom( + [{"name": "x", "version": "1"}], + git_commit="c", + cargo_lock_sha256="short", + ) + with self.assertRaisesRegex(ValueError, "non-negative"): + release_evidence.build_provenance( + git_commit="c", + cargo_lock_sha256="a" * 64, + sbom_sha256="b" * 64, + component_count=-1, + workspace_crates=["crate"], + ) + with self.assertRaisesRegex(ValueError, "must not be empty"): + release_evidence.build_provenance( + git_commit="c", + cargo_lock_sha256="a" * 64, + sbom_sha256="b" * 64, + component_count=1, + workspace_crates=[], + ) + with self.assertRaisesRegex(ValueError, "non-empty strings"): + release_evidence.build_provenance( + git_commit="c", + cargo_lock_sha256="a" * 64, + sbom_sha256="b" * 64, + component_count=1, + workspace_crates=[""], + ) + + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + with self.assertRaisesRegex(ValueError, "must be a JSON object"): + release_evidence.require_mapping([], "SBOM") + bad_sbom = directory / "sbom.cdx.json" + bad_sbom.write_text("[]\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "JSON object"): + release_evidence.validate_sbom(bad_sbom) + incomplete = directory / "incomplete.json" + incomplete.write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "missing required key"): + release_evidence.validate_sbom(incomplete) + wrong_format = { + "bomFormat": "SPDX", + "specVersion": "1.5", + "serialNumber": "x", + "version": 1, + "metadata": {}, + "components": [{"type": "library", "name": "a", "version": "1", "purl": "p"}], + } + wrong_path = directory / "wrong.json" + wrong_path.write_text(json.dumps(wrong_format), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "CycloneDX"): + release_evidence.validate_sbom(wrong_path) + wrong_spec = dict(wrong_format) + wrong_spec["bomFormat"] = "CycloneDX" + wrong_spec["specVersion"] = "1.4" + wrong_spec_path = directory / "spec.json" + wrong_spec_path.write_text(json.dumps(wrong_spec), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "1.5"): + release_evidence.validate_sbom(wrong_spec_path) + empty_components = dict(wrong_spec) + empty_components["specVersion"] = "1.5" + empty_components["components"] = [] + empty_path = directory / "empty-components.json" + empty_path.write_text(json.dumps(empty_components), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "non-empty list"): + release_evidence.validate_sbom(empty_path) + bad_component = dict(empty_components) + bad_component["components"] = ["nope"] + bad_component_path = directory / "bad-component.json" + bad_component_path.write_text(json.dumps(bad_component), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "must be objects"): + release_evidence.validate_sbom(bad_component_path) + missing_field = dict(empty_components) + missing_field["components"] = [{"type": "library", "name": "a", "version": "1"}] + missing_field_path = directory / "missing-field.json" + missing_field_path.write_text(json.dumps(missing_field), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "purl"): + release_evidence.validate_sbom(missing_field_path) + + bad_provenance = directory / "provenance.json" + bad_provenance.write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "missing required key"): + release_evidence.validate_provenance(bad_provenance) + base = { + "schema_version": "other", + "evidence_kind": "repository_release_evidence", + "git_commit": "c", + "cargo_lock_sha256": "a" * 64, + "sbom_sha256": "b" * 64, + "component_count": 1, + "workspace_crate_count": 1, + "workspace_crates": ["crate"], + } + other_schema = directory / "schema.json" + other_schema.write_text(json.dumps(base), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "schema_version"): + release_evidence.validate_provenance(other_schema) + base["schema_version"] = "tepp.release_provenance.v1" + base["evidence_kind"] = "other" + other_kind = directory / "kind.json" + other_kind.write_text(json.dumps(base), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "evidence_kind"): + release_evidence.validate_provenance(other_kind) + base["evidence_kind"] = "repository_release_evidence" + base["git_commit"] = "" + empty_commit = directory / "commit.json" + empty_commit.write_text(json.dumps(base), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "git_commit"): + release_evidence.validate_provenance(empty_commit) + base["git_commit"] = "c" + match_path = directory / "commit-match.json" + match_path.write_text(json.dumps(base), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "does not match"): + release_evidence.validate_provenance( + match_path, expected_git_commit="other" + ) + base["cargo_lock_sha256"] = "short" + short_digest = directory / "short.json" + short_digest.write_text(json.dumps(base), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "cargo_lock_sha256"): + release_evidence.validate_provenance(short_digest) + base["cargo_lock_sha256"] = "a" * 64 + base["workspace_crates"] = [] + empty_crates = directory / "crates.json" + empty_crates.write_text(json.dumps(base), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "workspace_crates"): + release_evidence.validate_provenance(empty_crates) + base["workspace_crates"] = ["crate"] + base["workspace_crate_count"] = 2 + count_mismatch = directory / "count.json" + count_mismatch.write_text(json.dumps(base), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "workspace_crate_count"): + release_evidence.validate_provenance(count_mismatch) + base["workspace_crate_count"] = 1 + base["component_count"] = 0 + zero_components = directory / "zero.json" + zero_components.write_text(json.dumps(base), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "positive integer"): + release_evidence.validate_provenance(zero_components) + + checksums = directory / "checksums.sha256" + checksums.write_text("\nnot-a-valid-line\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "checksums lines"): + release_evidence.validate_checksums(checksums, directory) + checksums.write_text(f"{'a' * 10} sbom.cdx.json\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "64 hex"): + release_evidence.validate_checksums(checksums, directory) + checksums.write_text( + f"{'a' * 64} sbom.cdx.json\n{'a' * 64} sbom.cdx.json\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "duplicate checksum"): + release_evidence.validate_checksums(checksums, directory) + checksums.write_text(f"{'a' * 64} only-one.json\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "must include"): + release_evidence.validate_checksums(checksums, directory) + missing_dir = directory / "missing-targets" + missing_dir.mkdir() + missing_checksums = missing_dir / "checksums.sha256" + missing_checksums.write_text( + f"{'a' * 64} sbom.cdx.json\n{'b' * 64} provenance.json\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "checksums must include Cargo.lock"): + release_evidence.validate_checksums(missing_checksums, missing_dir) + missing_checksums.write_text( + f"{'a' * 64} sbom.cdx.json\n" + f"{'b' * 64} provenance.json\n" + f"{'c' * 64} Cargo.lock\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "checksum target missing"): + release_evidence.validate_checksums(missing_checksums, missing_dir) + with self.assertRaisesRegex(ValueError, "checksum target missing"): + release_evidence.validate_checksums( + missing_checksums, + missing_dir, + repository_root=missing_dir, + ) + + def test_generate_missing_inputs_and_cli(self) -> None: + """Missing repository inputs and CLI paths remain usable.""" + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "out" + with self.assertRaisesRegex(ValueError, "blank"): + release_evidence.generate_evidence(root, output, git_commit=" ") + with self.assertRaisesRegex(ValueError, "Cargo.lock must exist"): + release_evidence.generate_evidence(root, output, git_commit="c") + self.write_lock( + root, + [{"name": "alpha", "version": "1.0.0"}], + ) + with self.assertRaisesRegex(ValueError, "Cargo.toml must exist"): + release_evidence.generate_evidence(root, output, git_commit="c") + self.write_workspace(root, ["crates/alpha"]) + standard_output = io.StringIO() + with contextlib.redirect_stdout(standard_output): + code = release_evidence.main( + [ + "generate", + "--repository-root", + str(root), + "--output-directory", + str(output), + "--git-commit", + "deadbeef", + ] + ) + self.assertEqual(code, 0) + self.assertIn("release evidence: PASS", standard_output.getvalue()) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual( + release_evidence.main( + [ + "validate", + "--evidence-directory", + str(output), + "--repository-root", + str(root), + "--expected-git-commit", + "deadbeef", + ] + ), + 0, + ) + # Tamper with SBOM digest binding. + provenance_path = output / "provenance.json" + payload = json.loads(provenance_path.read_text(encoding="utf-8")) + payload["sbom_sha256"] = "0" * 64 + provenance_path.write_text(json.dumps(payload), encoding="utf-8") + lock_digest = release_evidence.sha256_file(root / "Cargo.lock") + # Refresh checksums so checksum validation does not fail first. + release_evidence.write_checksums( + output / "checksums.sha256", + { + "sbom.cdx.json": release_evidence.sha256_file(output / "sbom.cdx.json"), + "provenance.json": release_evidence.sha256_file(provenance_path), + "Cargo.lock": lock_digest, + }, + ) + with self.assertRaisesRegex(ValueError, "sbom_sha256 does not match"): + release_evidence.validate_evidence_bundle( + output, repository_root=root + ) + # Cargo.lock binding fails when provenance digest is wrong. + payload["sbom_sha256"] = release_evidence.sha256_file(output / "sbom.cdx.json") + payload["cargo_lock_sha256"] = "0" * 64 + provenance_path.write_text(json.dumps(payload), encoding="utf-8") + release_evidence.write_checksums( + output / "checksums.sha256", + { + "sbom.cdx.json": release_evidence.sha256_file(output / "sbom.cdx.json"), + "provenance.json": release_evidence.sha256_file(provenance_path), + "Cargo.lock": lock_digest, + }, + ) + with self.assertRaisesRegex( + ValueError, "checksums Cargo.lock digest does not match provenance" + ): + release_evidence.validate_evidence_bundle( + output, repository_root=root + ) + + standard_error = io.StringIO() + with contextlib.redirect_stderr(standard_error): + self.assertEqual( + release_evidence.main( + [ + "validate", + "--evidence-directory", + str(root / "missing"), + "--repository-root", + str(root), + ] + ), + 1, + ) + self.assertIn("FAIL", standard_error.getvalue()) + + parser = release_evidence.build_parser() + namespace = parser.parse_args( + [ + "generate", + "--output-directory", + str(output), + "--git-commit", + "c", + ] + ) + self.assertEqual(namespace.command, "generate") + with mock.patch.object( + release_evidence.sys, + "argv", + [ + "release_evidence", + "validate", + "--evidence-directory", + str(output), + "--repository-root", + str(root), + ], + ): + # Bundle is intentionally invalid after tamper; CLI returns 1. + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(release_evidence.main(None), 1) + + # Checksum mismatch path. + good = root / "good" + release_evidence.generate_evidence(root, good, git_commit="c") + checksums = good / "checksums.sha256" + lock_digest = release_evidence.sha256_file(root / "Cargo.lock") + checksums.write_text( + f"{'0' * 64} sbom.cdx.json\n" + f"{release_evidence.sha256_file(good / 'provenance.json')} provenance.json\n" + f"{lock_digest} Cargo.lock\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + release_evidence.validate_checksums( + checksums, good, repository_root=root + ) + # Bundle missing files. + empty = root / "empty-dir" + empty.mkdir() + with self.assertRaisesRegex(ValueError, "missing evidence file"): + release_evidence.validate_evidence_bundle(empty, repository_root=root) + # Missing repository Cargo.lock when root is provided. + orphan = root / "orphan-root" + orphan.mkdir() + release_evidence.generate_evidence(root, orphan / "ev", git_commit="c") + with self.assertRaisesRegex(ValueError, "checksum target missing: Cargo.lock"): + release_evidence.validate_evidence_bundle( + orphan / "ev", repository_root=orphan + ) + # Direct checksum mismatch after records parse. + good_checksums = good / "checksums.sha256" + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + release_evidence.validate_checksums( + good_checksums, good, repository_root=root + ) + # Checksums Cargo.lock must match provenance cargo_lock_sha256. + bound = root / "bound" + release_evidence.generate_evidence(root, bound, git_commit="c") + checksums = bound / "checksums.sha256" + lines = checksums.read_text(encoding="utf-8").splitlines() + rewritten = [] + for line in lines: + if line.endswith(" Cargo.lock"): + rewritten.append(f"{'d' * 64} Cargo.lock") + else: + rewritten.append(line) + checksums.write_text("\n".join(rewritten) + "\n", encoding="utf-8") + with self.assertRaisesRegex( + ValueError, "checksums Cargo.lock digest does not match provenance" + ): + # Skip file digest compare by omitting repository_root after + # first failing path: provide root but tamper only checksums + # after regenerating matching provenance would fail earlier on + # checksum mismatch for Cargo.lock file. Use matching file digests + # with mismatched provenance by rewriting provenance after. + release_evidence.validate_evidence_bundle(bound, repository_root=None) + + +if __name__ == "__main__": # pragma: no cover + unittest.main()