diff --git a/AGENTS.md b/AGENTS.md index 287e45ec7..7505f517c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,7 +159,8 @@ policy or build work it deletes prior raw build/dist outputs and that version's stale payload/evidence. It verifies the expected source commit and lock state, gathers target evidence through configured build/proof-host channels, pair-promotes payload and evidence, and prints canonical local readiness JSON. -This is candidate evidence only, not publication authorization. +This is candidate evidence only, not publication authorization. Advisory acquisition +is a separate operator operation documented in `scripts/release_advisory_policy.py`. `bash scripts/release.sh --recover ` is retained-byte-only, read-only validation. It preserves retained payload, ledger, diff --git a/scripts/check_rust_release_manifest.py b/scripts/check_rust_release_manifest.py index 73d0651dd..306e6dbca 100644 --- a/scripts/check_rust_release_manifest.py +++ b/scripts/check_rust_release_manifest.py @@ -2174,9 +2174,12 @@ def run_fixtures_mode() -> list[Failure]: ] policy_run = PolicyRun( advisory_source_id="fixture-advisories", + db_snapshot_basename="advisory-db-fixture00000000", db_commit="a" * 40, db_archive_sha256="b" * 64, + advisory_count=1, advisory_acquired_at="2026-07-20T11:00:00Z", + db_commit_timestamp="2026-07-19T12:00:00Z", policy_checked_at="2026-07-20T12:00:00Z", result="pass", ) diff --git a/scripts/release.sh b/scripts/release.sh index 8eaf50ada..9bb14f27b 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -60,9 +60,10 @@ Required environment: --candidate: EXPECTED_RELEASE_COMMIT expected lowercase source commit RELEASE_MODEL_PACKAGES include or exclude - RELEASE_ADVISORY_MODE refresh-once or caller-provisioned RELEASE_ADVISORY_SOURCE_NAME public advisory source id RELEASE_ADVISORY_DB_URL explicit non-GitHub advisory DB source + RELEASE_ADVISORY_DB_ROOT cargo-deny advisory db parent; see + scripts/release_advisory_policy.py RELEASE_BUILD_HOST_CHANNEL external build-host adapter command RELEASE_PROOF_HOST_LINUX_X86_64_MUSL_CHANNEL external proof-host adapter command @@ -70,8 +71,6 @@ Required environment: external proof-host adapter command RELEASE_PROOF_HOST_MACOS_ARM64_CHANNEL external proof-host adapter command - RELEASE_ADVISORY_DB_ROOT caller-provisioned mode only - RELEASE_ADVISORY_ACQUIRED_AT caller-provisioned mode only --recover: VERSION and SOURCE_COMMIT are required positional selectors. Recovery does diff --git a/scripts/release_advisory_policy.py b/scripts/release_advisory_policy.py index e68583009..e148abb69 100644 --- a/scripts/release_advisory_policy.py +++ b/scripts/release_advisory_policy.py @@ -2,14 +2,36 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Advisory snapshot binding for release candidates.""" +"""Advisory snapshot binding for release candidates. + +Release candidates consume an operator-provisioned advisory database root. The root +is a plain, non-git parent directory containing exactly one cargo-deny snapshot +directory plus cargo-deny's parent-level ``db.lock``. The release rail measures git +identity inside that snapshot only: the snapshot's git top level must resolve to the +snapshot itself, and the receipt records the snapshot basename, HEAD commit, archive +digest, advisory count, FETCH_HEAD mtime, HEAD commit timestamp, check time, and pass +result. Absolute paths are never recorded. + +Freshness has two independent bounds. Fetch recency is the measured +``.git/FETCH_HEAD`` mtime and must be within 24 hours. Content age is the measured +HEAD commit timestamp and must be within 14 days. FETCH_HEAD mtime is mutable +filesystem metadata; the commit timestamp is derived from the commit object named by +``db_commit``. + +To acquire a conforming snapshot, write a cargo-deny config that sets the same +``db-path`` and non-GitHub ``db-urls`` supplied to the release rail, then run +``cargo-deny --config --manifest-path core/Cargo.toml fetch db`` twice. The +second run is intentional: cargo-deny 0.20.2 does not write ``.git/FETCH_HEAD`` on the +first clone into an empty db root, but it does on subsequent fetches. Do not run +manual ``git fetch`` or ``git reset``. ``make audit`` is not this acquisition +operation; it uses cargo-deny's default db path, not a controlled release db root. +""" from __future__ import annotations import hashlib import json import re -import shutil import subprocess import tempfile from collections.abc import Callable, Sequence @@ -18,10 +40,14 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from urllib.parse import urlparse -from scripts.check_rust_release_manifest import SHA256_RE, SOURCE_COMMIT_RE, Failure +from scripts.check_rust_release_manifest import ( + RFC3339_UTC_RE, + SHA256_RE, + SOURCE_COMMIT_RE, + Failure, +) from scripts.release_tool_pins import CARGO_DENY_PIN -PolicyMode = str Runner = Callable[..., subprocess.CompletedProcess[str]] TempPathFactory = Callable[[str], Path] Clock = Callable[[], datetime] @@ -29,27 +55,34 @@ ArchiveHasher = Callable[[Path], str] PathRemover = Callable[[Path], None] ADVISORY_TABLE_RE = re.compile(r"(?m)^\s*\[\s*advisories\s*\]\s*(?:#.*)?$") +ADVISORY_DB_DEBUG_RE = re.compile(r"Opening advisory database at '(?P[^']+)'") SOURCE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") -MAXIMUM_DB_STALENESS = "24 hours" -MAXIMUM_DB_STALENESS_DELTA = timedelta(hours=24) +MAXIMUM_DB_FETCH_STALENESS_DELTA = timedelta(hours=24) +MAXIMUM_DB_CONTENT_AGE_DELTA = timedelta(days=14) ARCHIVE_PREFIX = "advisory-db/" @dataclass(frozen=True) class PolicyRun: advisory_source_id: str + db_snapshot_basename: str db_commit: str db_archive_sha256: str + advisory_count: int advisory_acquired_at: str + db_commit_timestamp: str policy_checked_at: str result: str def __post_init__(self) -> None: - failures = validate_snapshot_identity( - "policy_run", - db_commit=self.db_commit, - db_archive_sha256=self.db_archive_sha256, - ) + failures = [ + *validate_snapshot_identity( + "policy_run", + db_commit=self.db_commit, + db_archive_sha256=self.db_archive_sha256, + ), + *_validate_policy_run_receipt(self), + ] if failures: raise ReleasePolicyError(failures) @@ -61,6 +94,64 @@ class PolicyRun: } +def _validate_policy_run_receipt(policy_run: PolicyRun) -> list[Failure]: + failures: list[Failure] = [] + if not _safe_snapshot_basename(policy_run.db_snapshot_basename): + failures.append( + _failure( + "policy_run.db_snapshot_basename is invalid", + expected="safe snapshot directory basename", + actual=repr(policy_run.db_snapshot_basename), + repair="python3 scripts/check_rust_release_manifest.py", + ) + ) + if type(policy_run.advisory_count) is not int or policy_run.advisory_count <= 0: + failures.append( + _failure( + "policy_run.advisory_count is invalid", + expected="positive integer advisory count", + actual=repr(policy_run.advisory_count), + repair="python3 scripts/check_rust_release_manifest.py", + ) + ) + for key in ( + "advisory_acquired_at", + "db_commit_timestamp", + "policy_checked_at", + ): + value = getattr(policy_run, key) + if not _is_normalized_utc_timestamp(value): + failures.append( + _failure( + f"policy_run.{key} is invalid", + expected="RFC3339 UTC timestamp normalized with Z", + actual=repr(value), + repair="python3 scripts/check_rust_release_manifest.py", + ) + ) + if policy_run.result != "pass": + failures.append( + _failure( + "policy_run.result is invalid", + expected="pass", + actual=repr(policy_run.result), + repair="python3 scripts/check_rust_release_manifest.py", + ) + ) + return failures + + +def _safe_snapshot_basename(value: object) -> bool: + return ( + isinstance(value, str) + and bool(value) + and value not in {".", ".."} + and Path(value).name == value + and "/" not in value + and "\\" not in value + ) + + class ReleasePolicyError(RuntimeError): def __init__(self, failures: Sequence[Failure]) -> None: self.failures = tuple(failures) @@ -121,10 +212,6 @@ def _unlink_path(path: Path) -> None: path.unlink() -def _remove_tree(path: Path) -> None: - shutil.rmtree(path) - - def _remove_dir(path: Path) -> None: path.rmdir() @@ -133,6 +220,27 @@ def _toml_string(value: str) -> str: return json.dumps(value, ensure_ascii=False) +def _cargo_deny_duration(delta: timedelta) -> str: + total_seconds = delta.total_seconds() + if total_seconds <= 0 or not float(total_seconds).is_integer(): + raise AssertionError("cargo-deny duration must be a positive whole second") + seconds = int(total_seconds) + if seconds % 3600 == 0: + return f"PT{seconds // 3600}H" + if seconds % 60 == 0: + return f"PT{seconds // 60}M" + return f"PT{seconds}S" + + +def _duration_label(delta: timedelta) -> str: + seconds = int(delta.total_seconds()) + if seconds % 86400 == 0: + return f"{seconds // 86400}d" + if seconds % 3600 == 0: + return f"{seconds // 3600}h" + return f"{seconds}s" + + def _advisory_host(value: str) -> str | None: parsed = urlparse(value) if parsed.scheme and parsed.hostname: @@ -231,7 +339,8 @@ def _materialized_config_bytes( f"db-path = {_toml_string(str(db_root))}\n" f"db-urls = [{urls}]\n" "git-fetch-with-cli = true\n" - f"maximum-db-staleness = {_toml_string(MAXIMUM_DB_STALENESS)}\n" + "maximum-db-staleness = " + f"{_toml_string(_cargo_deny_duration(MAXIMUM_DB_FETCH_STALENESS_DELTA))}\n" ) return prefix + block.encode("utf-8") @@ -286,8 +395,148 @@ def _run( return result -def _git_stdout(runner: Runner, db_root: Path, args: Sequence[str]) -> str: - return _run(runner, ["git", "-C", str(db_root), *args]).stdout.strip() +def _git_stdout(runner: Runner, git_root: Path, args: Sequence[str]) -> str: + return _run(runner, ["git", "-C", str(git_root), *args]).stdout.strip() + + +def _realpath(path: Path) -> Path: + return path.resolve(strict=False) + + +def _locate_advisory_snapshot(db_root: Path) -> Path: + try: + entries = sorted(db_root.iterdir(), key=lambda path: path.name) + except OSError as exc: + raise ReleasePolicyError( + [ + _failure( + "advisory db root could not be inspected", + expected="existing advisory db root containing one snapshot", + actual=type(exc).__name__, + repair="provision RELEASE_ADVISORY_DB_ROOT with cargo-deny fetch db", + ) + ] + ) from None + visible_entries = [path for path in entries if path.name != "db.lock"] + unexpected = [ + path.name for path in visible_entries if path.is_symlink() or not path.is_dir() + ] + if unexpected: + raise ReleasePolicyError( + [ + _failure( + "advisory db root contains unexpected entries", + expected="one snapshot directory plus db.lock", + actual=", ".join(unexpected), + repair="provision a clean RELEASE_ADVISORY_DB_ROOT with cargo-deny fetch db", + ) + ] + ) + snapshots = [path for path in visible_entries if path.is_dir()] + if len(snapshots) != 1: + raise ReleasePolicyError( + [ + _failure( + "advisory db snapshot count is invalid", + expected="exactly one snapshot directory under RELEASE_ADVISORY_DB_ROOT", + actual=str(len(snapshots)), + repair="provision a clean RELEASE_ADVISORY_DB_ROOT with cargo-deny fetch db", + ) + ] + ) + return snapshots[0] + + +def _assert_snapshot_git_top_level(runner: Runner, snapshot: Path) -> None: + try: + top_level = _git_stdout(runner, snapshot, ["rev-parse", "--show-toplevel"]) + except ReleasePolicyError as exc: + raise ReleasePolicyError( + [ + _failure( + "advisory db snapshot git root could not be resolved", + expected="snapshot directory is a git checkout root", + actual="git rev-parse failed", + repair="reacquire RELEASE_ADVISORY_DB_ROOT with cargo-deny fetch db", + ) + ] + ) from exc + if _realpath(Path(top_level)) != _realpath(snapshot): + raise ReleasePolicyError( + [ + _failure( + "advisory db snapshot is not an isolated git checkout", + expected=str(_realpath(snapshot)), + actual=str(_realpath(Path(top_level))), + repair="set RELEASE_ADVISORY_DB_ROOT to cargo-deny's non-git parent directory", + ) + ] + ) + + +def _assert_snapshot_clean(runner: Runner, snapshot: Path) -> None: + clean = _git_stdout( + runner, + snapshot, + [ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored=matching", + ], + ) + if clean: + raise ReleasePolicyError( + [ + _failure( + "advisory db snapshot has uncommitted or ignored material", + expected="empty git status including ignored and untracked files", + actual=clean, + repair=( + "git -C status --porcelain=v1 " + "--untracked-files=all --ignored=matching" + ), + ) + ] + ) + + +def _count_advisories(snapshot: Path) -> int: + return sum( + 1 + for path in snapshot.glob("crates/**/RUSTSEC-*.md") + if path.is_file() and not path.is_symlink() + ) + + +def _validate_advisory_count(count: int) -> None: + if count <= 0: + raise ReleasePolicyError( + [ + _failure( + "advisory db snapshot contains no advisories", + expected="at least one crates/**/RUSTSEC-*.md advisory", + actual="0", + repair="reacquire RELEASE_ADVISORY_DB_ROOT from a populated advisory mirror", + ) + ] + ) + + +def _fetch_head_mtime(snapshot: Path) -> datetime: + fetch_head = snapshot / ".git" / "FETCH_HEAD" + if fetch_head.is_symlink() or not fetch_head.is_file(): + raise ReleasePolicyError( + [ + _failure( + "advisory db FETCH_HEAD is missing", + expected="snapshot .git/FETCH_HEAD written by cargo-deny fetch db", + actual="", + repair="run cargo-deny --config --manifest-path core/Cargo.toml fetch db twice", + ) + ] + ) + return datetime.fromtimestamp(fetch_head.stat().st_mtime, UTC) def _strip_one_trailing_newline(value: str) -> str: @@ -311,6 +560,58 @@ def _git_db_commit(runner: Runner, db_root: Path) -> str: return value +def _git_db_commit_timestamp(runner: Runner, snapshot: Path) -> datetime: + value = _git_stdout(runner, snapshot, ["show", "-s", "--format=%cI", "HEAD"]) + return _parse_utc(value, label="advisory db commit timestamp") + + +def _advisory_check_argv(cargo_deny: str, config_path: Path, root: Path) -> list[str]: + return [ + cargo_deny, + "--config", + str(config_path), + "--manifest-path", + str(root / "core" / "Cargo.toml"), + "-L", + "debug", + "--locked", + "--offline", + "check", + "advisories", + ] + + +def _scanned_advisory_db(stderr: str) -> Path: + matches = [match.group("path") for match in ADVISORY_DB_DEBUG_RE.finditer(stderr)] + if len(matches) != 1: + raise ReleasePolicyError( + [ + _failure( + "cargo-deny advisory database debug line is missing", + expected="exactly one Opening advisory database at '' debug line", + actual=str(len(matches)), + repair="run the pinned cargo-deny with -L debug and inspect stderr", + ) + ] + ) + return Path(matches[0]) + + +def _assert_scanned_snapshot(stderr: str, snapshot: Path) -> None: + scanned = _scanned_advisory_db(stderr) + if _realpath(scanned) != _realpath(snapshot): + raise ReleasePolicyError( + [ + _failure( + "cargo-deny scanned a different advisory database", + expected=str(_realpath(snapshot)), + actual=str(_realpath(scanned)), + repair="provision RELEASE_ADVISORY_DB_ROOT with exactly one cargo-deny snapshot", + ) + ] + ) + + def _default_archive_hasher(db_root: Path) -> str: result = subprocess.run( [ @@ -341,16 +642,16 @@ def _default_archive_hasher(db_root: Path) -> str: return hashlib.sha256(result.stdout).hexdigest() -def _parse_utc(value: str) -> datetime: +def _parse_utc(value: str, *, label: str = "advisory acquisition time") -> datetime: try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError as exc: + except (AttributeError, ValueError) as exc: raise ReleasePolicyError( [ _failure( - "advisory acquisition time is not RFC3339", + f"{label} is not RFC3339", expected="RFC3339 timestamp with UTC offset", - actual=value or "", + actual=str(value) if value else "", repair="python3 scripts/check_rust_release_manifest.py", ) ] @@ -359,9 +660,9 @@ def _parse_utc(value: str) -> datetime: raise ReleasePolicyError( [ _failure( - "advisory acquisition time is missing an offset", + f"{label} is missing an offset", expected="RFC3339 timestamp with UTC offset", - actual=value, + actual=str(value), repair="python3 scripts/check_rust_release_manifest.py", ) ] @@ -375,32 +676,32 @@ def _format_utc(value: datetime) -> str: ) +def _is_normalized_utc_timestamp(value: object) -> bool: + if not isinstance(value, str) or not RFC3339_UTC_RE.fullmatch(value): + return False + try: + return _format_utc(_parse_utc(value)) == value + except ReleasePolicyError: + return False + + def _cleanup_temp( temp_root: Path, config_path: Path | None, *, - remove_tree: bool, unlink_path: PathRemover = _unlink_path, - remove_tree_path: PathRemover = _remove_tree, remove_dir: PathRemover = _remove_dir, ) -> None: try: - if remove_tree: - if temp_root.exists(): - remove_tree_path(temp_root) - return if config_path is not None and config_path.exists(): unlink_path(config_path) if temp_root.exists(): remove_dir(temp_root) except OSError as exc: - operation = ( - "refresh temp removal" if remove_tree else "materialized config removal" - ) raise ReleasePolicyError( [ _failure( - f"release advisory cleanup failed during {operation}", + "release advisory cleanup failed during materialized config removal", expected="owned release advisory temporary files removed", actual=type(exc).__name__, repair="python3 scripts/check_rust_release_manifest.py", @@ -421,29 +722,55 @@ def _combined_release_policy_error( def _validate_acquisition_freshness( *, advisory_acquired_at: str, - acquired: datetime, + fetch_acquired: datetime, + db_commit_timestamp: str, + db_commit_time: datetime, policy_time: datetime, ) -> None: failures: list[Failure] = [] policy_utc = policy_time.astimezone(UTC) - if acquired > policy_utc: + if fetch_acquired > policy_utc: failures.append( _failure( - "advisory acquisition time is in the future", - expected="acquisition time at or before policy check time", + "advisory fetch time is in the future", + expected="FETCH_HEAD mtime at or before policy check time", actual=advisory_acquired_at, repair="python3 scripts/check_rust_release_manifest.py", ) ) - elif policy_utc - acquired > MAXIMUM_DB_STALENESS_DELTA: + elif policy_utc - fetch_acquired > MAXIMUM_DB_FETCH_STALENESS_DELTA: failures.append( _failure( - "advisory acquisition time is stale", - expected=f"acquisition time within {MAXIMUM_DB_STALENESS}", + "advisory fetch time is stale", + expected=( + "FETCH_HEAD mtime within " + f"{_duration_label(MAXIMUM_DB_FETCH_STALENESS_DELTA)}" + ), actual=advisory_acquired_at, repair="python3 scripts/check_rust_release_manifest.py", ) ) + if db_commit_time > policy_utc: + failures.append( + _failure( + "advisory db commit timestamp is in the future", + expected="HEAD commit timestamp at or before policy check time", + actual=db_commit_timestamp, + repair="python3 scripts/check_rust_release_manifest.py", + ) + ) + elif policy_utc - db_commit_time > MAXIMUM_DB_CONTENT_AGE_DELTA: + failures.append( + _failure( + "advisory db content is stale", + expected=( + "HEAD commit timestamp within " + f"{_duration_label(MAXIMUM_DB_CONTENT_AGE_DELTA)}" + ), + actual=db_commit_timestamp, + repair="reacquire RELEASE_ADVISORY_DB_ROOT from a current advisory mirror", + ) + ) if failures: raise ReleasePolicyError(failures) @@ -453,59 +780,31 @@ def prepare_policy_run( *, advisory_source_id: str, db_urls: Sequence[str], - mode: PolicyMode, - advisory_acquired_at: str | None = None, - db_root: Path | None = None, + db_root: Path, cargo_deny: str = "cargo-deny", runner: Runner = subprocess.run, temp_path_factory: TempPathFactory = _default_temp_path_factory, clock: Clock = _utc_now, archive_hasher: ArchiveHasher = _default_archive_hasher, cleanup_unlink: PathRemover = _unlink_path, - cleanup_rmtree: PathRemover = _remove_tree, cleanup_rmdir: PathRemover = _remove_dir, ) -> PolicyRun: failures = _validate_source(advisory_source_id, db_urls) if failures: raise ReleasePolicyError(failures) - if mode not in {"refresh-once", "caller-provisioned"}: + if db_root is None: raise ReleasePolicyError( [ _failure( - "advisory policy mode is unsupported", - expected="refresh-once or caller-provisioned", - actual=mode, - repair="python3 scripts/check_rust_release_manifest.py", - ) - ] - ) - if mode == "caller-provisioned" and db_root is None: - raise ReleasePolicyError( - [ - _failure( - "caller-provisioned advisory mode has no db root", - expected="existing advisory db root", - actual="", - repair="python3 scripts/check_rust_release_manifest.py", - ) - ] - ) - if mode == "caller-provisioned" and advisory_acquired_at is None: - raise ReleasePolicyError( - [ - _failure( - "caller-provisioned advisory mode has no acquisition time", - expected="trusted advisory acquisition RFC3339 timestamp", + "release advisory db root is missing", + expected="RELEASE_ADVISORY_DB_ROOT containing one cargo-deny snapshot", actual="", - repair="python3 scripts/check_rust_release_manifest.py", + repair="provision RELEASE_ADVISORY_DB_ROOT with cargo-deny fetch db", ) ] ) - if mode == "caller-provisioned" and advisory_acquired_at is not None: - _parse_utc(advisory_acquired_at) temp_root = temp_path_factory("advisory-policy") - resolved_db_root = db_root or (temp_root / "advisory-db") config_path: Path | None = None result: PolicyRun | None = None primary_error: ReleasePolicyError | None = None @@ -513,43 +812,15 @@ def prepare_policy_run( config_path = _write_materialized_config( root, temp_root, - db_root=resolved_db_root, + db_root=db_root, db_urls=db_urls, ) - if mode == "refresh-once": - _run( - runner, - [cargo_deny, "--config", str(config_path), "fetch", "db"], - cwd=root, - ) - - clean = _git_stdout( - runner, - resolved_db_root, - [ - "status", - "--porcelain=v1", - "--untracked-files=all", - "--ignored=matching", - ], - ) - if clean: - raise ReleasePolicyError( - [ - _failure( - "advisory db has uncommitted or ignored material", - expected="empty git status including ignored and untracked files", - actual=clean, - repair=( - "git -C status --porcelain=v1 " - "--untracked-files=all --ignored=matching" - ), - ) - ] - ) - db_commit = _git_db_commit(runner, resolved_db_root) - db_archive_sha256 = archive_hasher(resolved_db_root) + snapshot = _locate_advisory_snapshot(db_root) + _assert_snapshot_git_top_level(runner, snapshot) + _assert_snapshot_clean(runner, snapshot) + db_commit = _git_db_commit(runner, snapshot) + db_archive_sha256 = archive_hasher(snapshot) failures = validate_snapshot_identity( "advisory_snapshot", db_commit=db_commit, @@ -557,38 +828,36 @@ def prepare_policy_run( ) if failures: raise ReleasePolicyError(failures) - if mode == "refresh-once": - acquisition_text = _format_utc(clock()) - else: - assert advisory_acquired_at is not None - acquisition_text = advisory_acquired_at - _parse_utc(acquisition_text) - _run( + db_commit_time = _git_db_commit_timestamp(runner, snapshot) + db_commit_timestamp_text = _format_utc(db_commit_time) + advisory_count = _count_advisories(snapshot) + _validate_advisory_count(advisory_count) + check_result = _run( runner, - [ - cargo_deny, - "--config", - str(config_path), - "--locked", - "--offline", - "check", - "advisories", - ], + _advisory_check_argv(cargo_deny, config_path, root), cwd=root, ) + _assert_scanned_snapshot(check_result.stderr, snapshot) + _assert_snapshot_clean(runner, snapshot) policy_time = clock() - acquired = _parse_utc(acquisition_text) + fetch_acquired = _fetch_head_mtime(snapshot) + acquisition_text = _format_utc(fetch_acquired) _validate_acquisition_freshness( advisory_acquired_at=acquisition_text, - acquired=acquired, + fetch_acquired=fetch_acquired, + db_commit_timestamp=db_commit_timestamp_text, + db_commit_time=db_commit_time, policy_time=policy_time, ) result = PolicyRun( advisory_source_id=advisory_source_id, + db_snapshot_basename=snapshot.name, db_commit=db_commit, db_archive_sha256=db_archive_sha256, + advisory_count=advisory_count, advisory_acquired_at=acquisition_text, + db_commit_timestamp=db_commit_timestamp_text, policy_checked_at=_format_utc(policy_time), result="pass", ) @@ -600,9 +869,7 @@ def prepare_policy_run( _cleanup_temp( temp_root, config_path, - remove_tree=mode == "refresh-once", unlink_path=cleanup_unlink, - remove_tree_path=cleanup_rmtree, remove_dir=cleanup_rmdir, ) except ReleasePolicyError as exc: diff --git a/scripts/release_candidate_driver.py b/scripts/release_candidate_driver.py index cc3a849a1..e7e9eb7cb 100644 --- a/scripts/release_candidate_driver.py +++ b/scripts/release_candidate_driver.py @@ -452,11 +452,7 @@ def _default_prepare_policy(root: Path, env: Mapping[str, str]) -> PolicyRun: root, advisory_source_id=env["RELEASE_ADVISORY_SOURCE_NAME"], db_urls=(env["RELEASE_ADVISORY_DB_URL"],), - mode=env["RELEASE_ADVISORY_MODE"], - advisory_acquired_at=env.get("RELEASE_ADVISORY_ACQUIRED_AT"), - db_root=Path(env["RELEASE_ADVISORY_DB_ROOT"]) - if env.get("RELEASE_ADVISORY_DB_ROOT") - else None, + db_root=Path(env["RELEASE_ADVISORY_DB_ROOT"]), ) @@ -1912,7 +1908,31 @@ def _validate_policy_payload(policy_run: Mapping[str, Any]) -> list[Failure]: repair="bash scripts/release.sh --recover", ) ) - for key in ("advisory_acquired_at", "policy_checked_at"): + snapshot = policy_run.get("db_snapshot_basename") + if not _safe_retained_basename(snapshot): + failures.append( + _failure( + "retained ledger db snapshot basename is invalid", + expected="safe snapshot directory basename", + actual=repr(snapshot), + repair="bash scripts/release.sh --recover", + ) + ) + advisory_count = policy_run.get("advisory_count") + if type(advisory_count) is not int or advisory_count <= 0: + failures.append( + _failure( + "retained ledger advisory count is invalid", + expected="positive integer advisory count", + actual=repr(advisory_count), + repair="bash scripts/release.sh --recover", + ) + ) + for key in ( + "advisory_acquired_at", + "db_commit_timestamp", + "policy_checked_at", + ): value = policy_run.get(key) if not isinstance(value, str) or not RFC3339_UTC_RE.fullmatch(value): failures.append( @@ -2131,9 +2151,12 @@ def _validate_deep_ledger_binding( failures.extend(_validate_policy_payload(policy_payload)) if policy_run is not None and policy_payload != { "advisory_source_id": policy_run.advisory_source_id, + "db_snapshot_basename": policy_run.db_snapshot_basename, "db_commit": policy_run.db_commit, "db_archive_sha256": policy_run.db_archive_sha256, + "advisory_count": policy_run.advisory_count, "advisory_acquired_at": policy_run.advisory_acquired_at, + "db_commit_timestamp": policy_run.db_commit_timestamp, "policy_checked_at": policy_run.policy_checked_at, "result": policy_run.result, }: diff --git a/scripts/release_ledger.py b/scripts/release_ledger.py index 8c436f275..01646957d 100644 --- a/scripts/release_ledger.py +++ b/scripts/release_ledger.py @@ -55,9 +55,12 @@ MODELS_KEYS = frozenset(("decision", "package_version")) POLICY_RUN_KEYS = frozenset( ( "advisory_source_id", + "db_snapshot_basename", "db_commit", "db_archive_sha256", + "advisory_count", "advisory_acquired_at", + "db_commit_timestamp", "policy_checked_at", "result", ) @@ -126,12 +129,15 @@ def _validate_tool_evidence( return failures -def _policy_run_payload(policy_run: PolicyRun) -> dict[str, str]: +def _policy_run_payload(policy_run: PolicyRun) -> dict[str, Any]: payload = { "advisory_source_id": policy_run.advisory_source_id, + "db_snapshot_basename": policy_run.db_snapshot_basename, "db_commit": policy_run.db_commit, "db_archive_sha256": policy_run.db_archive_sha256, + "advisory_count": policy_run.advisory_count, "advisory_acquired_at": policy_run.advisory_acquired_at, + "db_commit_timestamp": policy_run.db_commit_timestamp, "policy_checked_at": policy_run.policy_checked_at, "result": policy_run.result, } diff --git a/tests/test_release_advisory_policy.py b/tests/test_release_advisory_policy.py index 6396859bf..d935f6586 100644 --- a/tests/test_release_advisory_policy.py +++ b/tests/test_release_advisory_policy.py @@ -3,7 +3,9 @@ from __future__ import annotations +import os import subprocess +from collections.abc import Sequence from datetime import UTC, datetime from pathlib import Path @@ -12,6 +14,13 @@ import pytest import scripts.check_rust_release_manifest as checker import scripts.release_advisory_policy as policy +DB_COMMIT = "a" * 40 +DB_ARCHIVE = "b" * 64 +SNAPSHOT = "advisory-db-1234567890abcdef" +POLICY_TIME = datetime(2026, 7, 20, 12, 0, tzinfo=UTC) +FETCH_TIME = datetime(2026, 7, 20, 11, 30, tzinfo=UTC) +COMMIT_TIME = "2026-07-17T15:52:38Z" + MALFORMED_DB_COMMIT_CASES = ( ("short-39", "a" * 39), ("short-63", "a" * 63), @@ -41,46 +50,71 @@ MALFORMED_ARCHIVE_DIGESTS = tuple( class FakeRunner: def __init__( self, + snapshot: Path, *, - status: str = "", + status: str | Sequence[str] = "", fail_check: bool = False, - commit_stdout: str = "a" * 40 + "\n", + commit_stdout: str = DB_COMMIT + "\n", + commit_timestamp: str = COMMIT_TIME + "\n", + top_level: Path | None = None, + scanned_path: Path | None = None, + debug_stderr: str | None = None, ) -> None: - self.status = status + self.snapshot = snapshot + self.status_outputs = list(status) if not isinstance(status, str) else [status] self.fail_check = fail_check self.commit_stdout = commit_stdout + self.commit_timestamp = commit_timestamp + self.top_level = top_level + self.scanned_path = scanned_path or snapshot + self.debug_stderr = debug_stderr self.events: list[str] = [] self.config_bytes: bytes | None = None self.cargo_cwds: list[Path | None] = [] + self.cargo_argvs: list[list[str]] = [] def __call__(self, argv, **kwargs) -> subprocess.CompletedProcess[str]: command = list(argv) - if command[0] == "cargo-deny" and command[-2:] == ["fetch", "db"]: - self.events.append("fetch") - self.cargo_cwds.append(kwargs.get("cwd")) - self.config_bytes = Path( - command[command.index("--config") + 1] - ).read_bytes() - return subprocess.CompletedProcess(command, 0, "", "") - if command[0] == "cargo-deny" and command[-2:] == ["check", "advisories"]: - self.events.append("check") - self.cargo_cwds.append(kwargs.get("cwd")) - self.config_bytes = Path( - command[command.index("--config") + 1] - ).read_bytes() - assert "--locked" in command - assert "--offline" in command - if self.fail_check: - return subprocess.CompletedProcess(command, 1, "", "denied") - return subprocess.CompletedProcess(command, 0, "", "") - if command[:3] == ["git", "-C", command[2]]: + if command[0] == "cargo-deny": + if command[-2:] == ["fetch", "db"]: + raise AssertionError("cargo-deny fetch subcommand should be gone") + if command[-2:] == ["check", "advisories"]: + self.events.append("check") + self.cargo_cwds.append(kwargs.get("cwd")) + self.cargo_argvs.append(command) + self.config_bytes = Path( + command[command.index("--config") + 1] + ).read_bytes() + if self.fail_check: + return subprocess.CompletedProcess(command, 1, "", "denied") + stderr = self.debug_stderr + if stderr is None: + stderr = ( + "2026-07-21 14:40:36 [DEBUG] " + f"Opening advisory database at '{self.scanned_path}'\n" + ) + return subprocess.CompletedProcess(command, 0, "", stderr) + if command[:2] == ["git", "-C"]: + git_root = Path(command[2]) subcommand = command[3:] + if subcommand == ["rev-parse", "--show-toplevel"]: + self.events.append("show-toplevel") + top_level = self.top_level or git_root + return subprocess.CompletedProcess(command, 0, f"{top_level}\n", "") if subcommand[:1] == ["status"]: self.events.append("status") - return subprocess.CompletedProcess(command, 0, self.status, "") + output = self.status_outputs.pop(0) + if not self.status_outputs: + self.status_outputs.append(output) + return subprocess.CompletedProcess(command, 0, output, "") if subcommand[:2] == ["rev-parse", "--verify"]: self.events.append("rev-parse") return subprocess.CompletedProcess(command, 0, self.commit_stdout, "") + if subcommand == ["show", "-s", "--format=%cI", "HEAD"]: + self.events.append("commit-timestamp") + return subprocess.CompletedProcess( + command, 0, self.commit_timestamp, "" + ) raise AssertionError(f"unexpected command: {command}") @@ -94,77 +128,152 @@ def _repo(tmp_path: Path, deny_text: str | None = None) -> Path: return root -class ClockSequence: - def __init__(self, events: list[str], *values: tuple[str, datetime]) -> None: - self.events = events - self.values = list(values) - - def __call__(self) -> datetime: - label, value = self.values.pop(0) - self.events.append(label) +def _clock(events: list[str] | None = None, value: datetime = POLICY_TIME): + def now() -> datetime: + if events is not None: + events.append("policy-clock") return value + return now -def _clock(events: list[str]): - def now() -> datetime: - events.append("policy-clock") - return datetime(2026, 7, 20, 12, 0, tzinfo=UTC) - return now +def _write_snapshot( + db_root: Path, + *, + name: str = SNAPSHOT, + advisory_count: int = 1, + fetch_time: datetime | None = FETCH_TIME, +) -> Path: + snapshot = db_root / name + (snapshot / ".git").mkdir(parents=True) + for index in range(advisory_count): + advisory = ( + snapshot / "crates" / f"probe{index}" / f"RUSTSEC-2026-{index:04d}.md" + ) + advisory.parent.mkdir(parents=True, exist_ok=True) + advisory.write_text( + "```toml\n" + "[advisory]\n" + f'id = "RUSTSEC-2026-{index:04d}"\n' + f'package = "probe{index}"\n' + 'date = "2026-01-01"\n' + 'url = "https://example.invalid/RUSTSEC-2026-0001"\n' + 'categories = ["unmaintained"]\n' + "keywords = []\n\n" + "[versions]\n" + "patched = []\n" + "```\n", + encoding="utf-8", + ) + if fetch_time is not None: + fetch_head = snapshot / ".git" / "FETCH_HEAD" + fetch_head.write_text("", encoding="utf-8") + timestamp = fetch_time.timestamp() + os.utime(fetch_head, (timestamp, timestamp)) + return snapshot -def test_materialized_config_appends_advisories_and_replaces_config( +def _db_root( tmp_path: Path, -) -> None: - runner = FakeRunner() - events = runner.events - repo = _repo(tmp_path) + *, + advisory_count: int = 1, + fetch_time: datetime | None = FETCH_TIME, +) -> tuple[Path, Path]: + root = tmp_path / "db-root" + root.mkdir() + (root / "db.lock").write_text("", encoding="utf-8") + return root, _write_snapshot( + root, + advisory_count=advisory_count, + fetch_time=fetch_time, + ) + +def _prepare( + tmp_path: Path, + *, + runner: FakeRunner | None = None, + db_root: Path | None = None, + snapshot: Path | None = None, + clock=None, + archive: str = DB_ARCHIVE, + cleanup_unlink=policy._unlink_path, + cleanup_rmdir=policy._remove_dir, +) -> tuple[policy.PolicyRun, FakeRunner, Path]: + repo = _repo(tmp_path) + if db_root is None or snapshot is None: + db_root, snapshot = _db_root(tmp_path) + runner = runner or FakeRunner(snapshot) result = policy.prepare_policy_run( repo, - advisory_source_id="internal-feed", - db_urls=("ssh://example.test/advisory-db.git",), - mode="refresh-once", + advisory_source_id="internal", + db_urls=("ssh://example.test/db.git",), + db_root=db_root, runner=runner, temp_path_factory=lambda label: tmp_path / label, - clock=ClockSequence( - events, - ("acquisition-clock", datetime(2026, 7, 20, 11, 30, tzinfo=UTC)), - ("policy-clock", datetime(2026, 7, 20, 12, 0, tzinfo=UTC)), + clock=clock or _clock(runner.events), + archive_hasher=lambda observed: ( + runner.events.append(f"archive:{observed.name}") or archive ), - archive_hasher=lambda _db: events.append("archive") or "b" * 64, + cleanup_unlink=cleanup_unlink, + cleanup_rmdir=cleanup_rmdir, ) + return result, runner, repo + + +def test_materialized_config_and_advisory_check_argv(tmp_path: Path) -> None: + result, runner, repo = _prepare(tmp_path) assert result.result == "pass" + assert result.db_snapshot_basename == SNAPSHOT + assert result.advisory_count == 1 assert result.advisory_acquired_at == "2026-07-20T11:30:00Z" + assert result.db_commit_timestamp == COMMIT_TIME assert runner.config_bytes is not None text = runner.config_bytes.decode("utf-8") assert text.startswith('[licenses]\nallow = ["MIT"]\n\n[advisories]\n') - assert 'db-urls = ["ssh://example.test/advisory-db.git"]' in text + assert 'db-urls = ["ssh://example.test/db.git"]' in text assert "git-fetch-with-cli = true" in text - assert 'maximum-db-staleness = "24 hours"' in text - assert events == [ - "fetch", + assert 'maximum-db-staleness = "PT24H"' in text + assert "24 hours" not in text + + argv = runner.cargo_argvs[0] + assert argv == [ + "cargo-deny", + "--config", + argv[2], + "--manifest-path", + str(repo / "core" / "Cargo.toml"), + "-L", + "debug", + "--locked", + "--offline", + "check", + "advisories", + ] + assert "fetch" not in runner.events + assert runner.events == [ + "show-toplevel", "status", "rev-parse", - "archive", - "acquisition-clock", + f"archive:{SNAPSHOT}", + "commit-timestamp", "check", + "status", "policy-clock", ] - assert runner.cargo_cwds == [repo, repo] + assert runner.cargo_cwds == [repo] def test_core_deny_toml_advisories_table_fails_loudly(tmp_path: Path) -> None: + db_root, _snapshot = _db_root(tmp_path) with pytest.raises(policy.ReleasePolicyError) as exc: policy.prepare_policy_run( _repo(tmp_path, '[advisories]\ndb-path = "x"\n'), advisory_source_id="internal-feed", db_urls=("ssh://example.test/advisory-db.git",), - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:00:00Z", - db_root=tmp_path / "db", - runner=FakeRunner(), + db_root=db_root, + runner=FakeRunner(db_root / SNAPSHOT), temp_path_factory=lambda label: tmp_path / label, ) @@ -199,134 +308,166 @@ def test_empty_and_github_advisory_sources_are_rejected( _repo(tmp_path), advisory_source_id=source_id, db_urls=db_urls, - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:00:00Z", db_root=tmp_path / "db", - runner=FakeRunner(), + runner=FakeRunner(tmp_path / "db" / SNAPSHOT), temp_path_factory=lambda label: tmp_path / label, ) assert any(failure.error == error for failure in exc.value.failures) -def test_caller_provisioned_cache_requires_clean_including_ignored( - tmp_path: Path, -) -> None: +def test_snapshot_count_must_be_exactly_one(tmp_path: Path) -> None: + db_root = tmp_path / "empty-db" + db_root.mkdir() + with pytest.raises(policy.ReleasePolicyError) as exc: policy.prepare_policy_run( _repo(tmp_path), advisory_source_id="internal", db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:00:00Z", - db_root=tmp_path / "db", - runner=FakeRunner(status="!! ignored\n?? untracked\n"), + db_root=db_root, + runner=FakeRunner(db_root / SNAPSHOT), temp_path_factory=lambda label: tmp_path / label, ) + assert exc.value.failures[0].error == "advisory db snapshot count is invalid" + assert exc.value.failures[0].actual == "0" + + db_root = tmp_path / "multi-db" + db_root.mkdir() + first = _write_snapshot(db_root, name="advisory-db-one") + _write_snapshot(db_root, name="advisory-db-two") + with pytest.raises(policy.ReleasePolicyError) as exc: + policy.prepare_policy_run( + _repo(tmp_path), + advisory_source_id="internal", + db_urls=("ssh://example.test/db.git",), + db_root=db_root, + runner=FakeRunner(first), + temp_path_factory=lambda label: tmp_path / f"multi-{label}", + ) + assert exc.value.failures[0].error == "advisory db snapshot count is invalid" + assert exc.value.failures[0].actual == "2" + + +def test_non_top_level_snapshot_fails_walk_up_check(tmp_path: Path) -> None: + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot, top_level=tmp_path) + + with pytest.raises(policy.ReleasePolicyError) as exc: + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) assert ( - exc.value.failures[0].error == "advisory db has uncommitted or ignored material" + exc.value.failures[0].error + == "advisory db snapshot is not an isolated git checkout" ) + assert "check" not in runner.events -def test_refresh_clock_order_and_policy_before_acquisition_fails( - tmp_path: Path, -) -> None: - runner = FakeRunner() +def test_scanned_advisory_db_must_match_measured_snapshot(tmp_path: Path) -> None: + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot, scanned_path=tmp_path / "other-db") - result = policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="refresh-once", - runner=runner, - temp_path_factory=lambda label: tmp_path / label, - clock=ClockSequence( - runner.events, - ("acquisition-clock", datetime(2026, 7, 20, 12, 0, tzinfo=UTC)), - ("policy-clock", datetime(2026, 7, 20, 12, 1, tzinfo=UTC)), - ), - archive_hasher=lambda _db: runner.events.append("archive") or "b" * 64, + with pytest.raises(policy.ReleasePolicyError) as exc: + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) + + assert ( + exc.value.failures[0].error + == "cargo-deny scanned a different advisory database" ) - assert result.advisory_acquired_at == "2026-07-20T12:00:00Z" - assert result.policy_checked_at == "2026-07-20T12:01:00Z" - assert runner.events.index("acquisition-clock") < runner.events.index("check") - assert runner.events.index("check") < runner.events.index("policy-clock") + +def test_missing_debug_line_fails_closed(tmp_path: Path) -> None: + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot, debug_stderr="debug without database path\n") with pytest.raises(policy.ReleasePolicyError) as exc: - policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="refresh-once", - runner=FakeRunner(), - temp_path_factory=lambda label: tmp_path / f"early-{label}", - clock=ClockSequence( - [], - ("acquisition-clock", datetime(2026, 7, 20, 12, 0, tzinfo=UTC)), - ("policy-clock", datetime(2026, 7, 20, 11, 59, tzinfo=UTC)), - ), - archive_hasher=lambda _db: "b" * 64, - ) + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) - assert exc.value.failures[0].error == "advisory acquisition time is in the future" - - -def test_stale_and_future_caller_acquisition_times_fail(tmp_path: Path) -> None: - for acquired, expected_error in ( - ("2026-07-18T11:59:59Z", "advisory acquisition time is stale"), - ("2026-07-20T12:00:01Z", "advisory acquisition time is in the future"), - ): - with pytest.raises(policy.ReleasePolicyError) as exc: - policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at=acquired, - db_root=tmp_path / "caller-db", - runner=FakeRunner(), - temp_path_factory=lambda label: tmp_path / f"{acquired}-{label}", - clock=_clock([]), - archive_hasher=lambda _db: "b" * 64, - ) - assert exc.value.failures[0].error == expected_error + assert ( + exc.value.failures[0].error + == "cargo-deny advisory database debug line is missing" + ) -def test_old_commit_with_fresh_acquisition_passes(tmp_path: Path) -> None: - runner = FakeRunner() +def test_absent_fetch_head_fails_closed(tmp_path: Path) -> None: + db_root, snapshot = _db_root(tmp_path, fetch_time=None) + runner = FakeRunner(snapshot) - result = policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:30:00Z", - db_root=tmp_path / "caller-db", + with pytest.raises(policy.ReleasePolicyError) as exc: + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) + + assert exc.value.failures[0].error == "advisory db FETCH_HEAD is missing" + + +def test_stale_fetch_head_fails(tmp_path: Path) -> None: + db_root, snapshot = _db_root( + tmp_path, + fetch_time=datetime(2026, 7, 18, 11, 59, 59, tzinfo=UTC), + ) + + with pytest.raises(policy.ReleasePolicyError) as exc: + _prepare(tmp_path, db_root=db_root, snapshot=snapshot) + + assert exc.value.failures[0].error == "advisory fetch time is stale" + + +def test_over_age_content_fails(tmp_path: Path) -> None: + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot, commit_timestamp="2026-07-05T11:59:59Z\n") + + with pytest.raises(policy.ReleasePolicyError) as exc: + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) + + assert exc.value.failures[0].error == "advisory db content is stale" + + +def test_zero_advisory_count_fails(tmp_path: Path) -> None: + db_root, snapshot = _db_root(tmp_path, advisory_count=0) + runner = FakeRunner(snapshot) + + with pytest.raises(policy.ReleasePolicyError) as exc: + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) + + assert exc.value.failures[0].error == "advisory db snapshot contains no advisories" + assert "check" not in runner.events + + +def test_post_run_dirty_snapshot_fails(tmp_path: Path) -> None: + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot, status=("", "?? late-file\n")) + + with pytest.raises(policy.ReleasePolicyError) as exc: + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) + + assert ( + exc.value.failures[0].error + == "advisory db snapshot has uncommitted or ignored material" + ) + assert runner.events.count("status") == 2 + + +def test_fresh_fetch_and_four_day_content_pass(tmp_path: Path) -> None: + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot, commit_timestamp="2026-07-16T12:00:00Z\n") + + result, _runner, _repo_path = _prepare( + tmp_path, + db_root=db_root, + snapshot=snapshot, runner=runner, - temp_path_factory=lambda label: tmp_path / label, - clock=_clock(runner.events), - archive_hasher=lambda _db: runner.events.append("archive") or "b" * 64, ) - assert result.db_commit == "a" * 40 assert result.advisory_acquired_at == "2026-07-20T11:30:00Z" - assert "log" not in runner.events + assert result.db_commit_timestamp == "2026-07-16T12:00:00Z" def test_prepare_policy_run_accepts_sha256_db_commit(tmp_path: Path) -> None: - result = policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:30:00Z", - db_root=tmp_path / "caller-db", - runner=FakeRunner(commit_stdout="a" * 64 + "\n"), - temp_path_factory=lambda label: tmp_path / label, - clock=_clock([]), - archive_hasher=lambda _db: "b" * 64, + db_root, snapshot = _db_root(tmp_path) + result, _runner, _repo_path = _prepare( + tmp_path, + db_root=db_root, + snapshot=snapshot, + runner=FakeRunner(snapshot, commit_stdout="a" * 64 + "\n"), ) assert result.db_commit == "a" * 64 @@ -337,21 +478,11 @@ def test_prepare_policy_run_rejects_malformed_db_commit_observation( tmp_path: Path, commit_stdout: str, ) -> None: - runner = FakeRunner(commit_stdout=commit_stdout) + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot, commit_stdout=commit_stdout) with pytest.raises(policy.ReleasePolicyError) as exc: - policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:30:00Z", - db_root=tmp_path / "caller-db", - runner=runner, - temp_path_factory=lambda label: tmp_path / label, - clock=_clock(runner.events), - archive_hasher=lambda _db: "b" * 64, - ) + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) assert exc.value.failures[0].error == "advisory_snapshot.db_commit is invalid" assert exc.value.failures[0].expected == "exactly 40 or 64 lowercase hex characters" @@ -365,20 +496,16 @@ def test_prepare_policy_run_rejects_malformed_archive_digest_observation( tmp_path: Path, digest: str, ) -> None: - runner = FakeRunner() + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot) with pytest.raises(policy.ReleasePolicyError) as exc: - policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:30:00Z", - db_root=tmp_path / "caller-db", + _prepare( + tmp_path, + db_root=db_root, + snapshot=snapshot, runner=runner, - temp_path_factory=lambda label: tmp_path / label, - clock=_clock(runner.events), - archive_hasher=lambda _db: digest, + archive=digest, ) assert ( @@ -393,9 +520,12 @@ def test_prepare_policy_run_rejects_malformed_archive_digest_observation( def test_policy_run_constructor_accepts_sha256_db_commit() -> None: result = policy.PolicyRun( advisory_source_id="internal", + db_snapshot_basename=SNAPSHOT, db_commit="a" * 64, - db_archive_sha256="b" * 64, + db_archive_sha256=DB_ARCHIVE, + advisory_count=1, advisory_acquired_at="2026-07-20T11:30:00Z", + db_commit_timestamp=COMMIT_TIME, policy_checked_at="2026-07-20T12:00:00Z", result="pass", ) @@ -424,18 +554,51 @@ def test_policy_run_constructor_accepts_sha256_db_commit() -> None: ) for name, value in MALFORMED_ARCHIVE_DIGEST_CASES ), + pytest.param( + "db_snapshot_basename", + "../db", + "policy_run.db_snapshot_basename is invalid", + id="snapshot-path", + ), + pytest.param( + "db_snapshot_basename", + "", + "policy_run.db_snapshot_basename is invalid", + id="snapshot-empty", + ), + pytest.param( + "advisory_count", + 0, + "policy_run.advisory_count is invalid", + id="count-zero", + ), + pytest.param( + "advisory_count", + True, + "policy_run.advisory_count is invalid", + id="count-bool", + ), + pytest.param( + "db_commit_timestamp", + "2026-07-19T12:00:00-06:00", + "policy_run.db_commit_timestamp is invalid", + id="commit-time-not-normalized", + ), ], ) -def test_policy_run_constructor_rejects_malformed_snapshot_identity( +def test_policy_run_constructor_rejects_malformed_receipt_identity( field: str, - value: str, + value: object, error: str, ) -> None: kwargs = { "advisory_source_id": "internal", - "db_commit": "a" * 40, - "db_archive_sha256": "b" * 64, + "db_snapshot_basename": SNAPSHOT, + "db_commit": DB_COMMIT, + "db_archive_sha256": DB_ARCHIVE, + "advisory_count": 1, "advisory_acquired_at": "2026-07-20T11:30:00Z", + "db_commit_timestamp": COMMIT_TIME, "policy_checked_at": "2026-07-20T12:00:00Z", "result": "pass", } @@ -445,7 +608,7 @@ def test_policy_run_constructor_rejects_malformed_snapshot_identity( policy.PolicyRun(**kwargs) assert exc.value.failures[0].error == error - if value and value[0].isupper(): + if isinstance(value, str) and value and value[0].isupper(): assert exc.value.failures[0].actual == value @@ -453,63 +616,38 @@ def _cleanup_failure(_path: Path) -> None: raise OSError(5, "cleanup failed", "/private/tmp/release-advisory-secret") -def _policy_kwargs( - tmp_path: Path, - *, - mode: str, - primary_failure: bool, -) -> dict: - runner = FakeRunner(status="?? dirty\n" if primary_failure else "") - kwargs = { - "advisory_source_id": "internal", - "db_urls": ("ssh://example.test/db.git",), - "mode": mode, - "runner": runner, - "temp_path_factory": lambda label: tmp_path / f"{mode}-{label}", - "clock": ClockSequence( - runner.events, - ("acquisition-clock", datetime(2026, 7, 20, 11, 30, tzinfo=UTC)), - ("policy-clock", datetime(2026, 7, 20, 12, 0, tzinfo=UTC)), - ) - if mode == "refresh-once" - else _clock(runner.events), - "archive_hasher": lambda _db: "b" * 64, - } - if mode == "caller-provisioned": - kwargs["advisory_acquired_at"] = "2026-07-20T11:30:00Z" - kwargs["db_root"] = tmp_path / "caller-db" - return kwargs - - +@pytest.mark.parametrize("primary_failure", (False, True), ids=("success", "primary")) @pytest.mark.parametrize( - ("cleanup_name", "mode", "cleanup_kwargs"), + ("cleanup_name", "cleanup_kwargs"), [ - ("unlink", "caller-provisioned", {"cleanup_unlink": _cleanup_failure}), - ("rmdir", "caller-provisioned", {"cleanup_rmdir": _cleanup_failure}), - ("rmtree", "refresh-once", {"cleanup_rmtree": _cleanup_failure}), + ("unlink", {"cleanup_unlink": _cleanup_failure}), + ("rmdir", {"cleanup_rmdir": _cleanup_failure}), ], ) -@pytest.mark.parametrize("primary_failure", (False, True), ids=("success", "primary")) def test_cleanup_failures_surface_without_masking_primary_errors( tmp_path: Path, cleanup_name: str, - mode: str, cleanup_kwargs: dict, primary_failure: bool, ) -> None: + db_root, snapshot = _db_root(tmp_path) + runner = FakeRunner(snapshot, status="?? dirty\n" if primary_failure else "") + with pytest.raises(policy.ReleasePolicyError) as exc: - policy.prepare_policy_run( - _repo(tmp_path), - **_policy_kwargs(tmp_path, mode=mode, primary_failure=primary_failure), + _prepare( + tmp_path, + db_root=db_root, + snapshot=snapshot, + runner=runner, **cleanup_kwargs, ) errors = [failure.error for failure in exc.value.failures] if primary_failure: - assert "advisory db has uncommitted or ignored material" in errors + assert "advisory db snapshot has uncommitted or ignored material" in errors else: assert errors == [ - f"release advisory cleanup failed during {'refresh temp removal' if cleanup_name == 'rmtree' else 'materialized config removal'}" + "release advisory cleanup failed during materialized config removal" ] assert any(error.startswith("release advisory cleanup failed") for error in errors) assert ( @@ -520,14 +658,6 @@ def test_cleanup_failures_surface_without_masking_primary_errors( ) -def _write_caller_db(root: Path) -> list[tuple[str, str, bytes]]: - root.mkdir(parents=True) - (root / "db.txt").write_bytes(b"caller db\n") - (root / "nested").mkdir() - (root / "nested" / "ignored.bin").write_bytes(b"still caller owned\n") - return _caller_db_inventory(root) - - def _caller_db_inventory(root: Path) -> list[tuple[str, str, bytes]]: items: list[tuple[str, str, bytes]] = [] for path in root.rglob("*"): @@ -553,123 +683,48 @@ def test_caller_owned_db_root_is_preserved( primary_failure: bool, cleanup_kwargs: dict, ) -> None: - caller_db = tmp_path / "caller-db" - before = _write_caller_db(caller_db) - kwargs = _policy_kwargs( - tmp_path, - mode="caller-provisioned", - primary_failure=primary_failure, - ) + db_root, snapshot = _db_root(tmp_path) + before = _caller_db_inventory(db_root) + runner = FakeRunner(snapshot, status="?? dirty\n" if primary_failure else "") if primary_failure or cleanup_kwargs: with pytest.raises(policy.ReleasePolicyError): - policy.prepare_policy_run(_repo(tmp_path), **kwargs, **cleanup_kwargs) + _prepare( + tmp_path, + db_root=db_root, + snapshot=snapshot, + runner=runner, + **cleanup_kwargs, + ) else: - policy.prepare_policy_run(_repo(tmp_path), **kwargs) + _prepare(tmp_path, db_root=db_root, snapshot=snapshot, runner=runner) - assert _caller_db_inventory(caller_db) == before - - -def test_caller_timestamp_is_preserved_and_required(tmp_path: Path) -> None: - exact = "2026-07-20T11:30:00+00:00" - result = policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at=exact, - db_root=tmp_path / "caller-db", - runner=FakeRunner(), - temp_path_factory=lambda label: tmp_path / label, - clock=_clock([]), - archive_hasher=lambda _db: "b" * 64, - ) - assert result.advisory_acquired_at == exact - - for value, error in ( - (None, "caller-provisioned advisory mode has no acquisition time"), - ("not-a-time", "advisory acquisition time is not RFC3339"), - ): - with pytest.raises(policy.ReleasePolicyError) as exc: - policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at=value, - db_root=tmp_path / "caller-db", - runner=FakeRunner(), - temp_path_factory=lambda label: tmp_path / f"{value}-{label}", - ) - assert exc.value.failures[0].error == error + assert _caller_db_inventory(db_root) == before def test_policy_temps_are_cleaned_without_removing_caller_db(tmp_path: Path) -> None: - refresh_root = tmp_path / "refresh-temp" - policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="refresh-once", - runner=FakeRunner(), - temp_path_factory=lambda _label: refresh_root, - clock=ClockSequence( - [], - ("acquisition-clock", datetime(2026, 7, 20, 11, 30, tzinfo=UTC)), - ("policy-clock", datetime(2026, 7, 20, 12, 0, tzinfo=UTC)), - ), - archive_hasher=lambda _db: "b" * 64, - ) - assert not refresh_root.exists() - - failed_refresh_root = tmp_path / "failed-refresh-temp" - with pytest.raises(policy.ReleasePolicyError): - policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="refresh-once", - runner=FakeRunner(status="?? dirty\n"), - temp_path_factory=lambda _label: failed_refresh_root, - clock=ClockSequence( - [], - ("acquisition-clock", datetime(2026, 7, 20, 11, 30, tzinfo=UTC)), - ("policy-clock", datetime(2026, 7, 20, 12, 0, tzinfo=UTC)), - ), - archive_hasher=lambda _db: "b" * 64, - ) - assert not failed_refresh_root.exists() - - caller_db = tmp_path / "caller-owned-db" - caller_db.mkdir() - caller_temp = tmp_path / "caller-temp" - policy.prepare_policy_run( - _repo(tmp_path), - advisory_source_id="internal", - db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:30:00Z", - db_root=caller_db, - runner=FakeRunner(), - temp_path_factory=lambda _label: caller_temp, + db_root, snapshot = _db_root(tmp_path) + temp_root = tmp_path / "policy-temp" + _prepare( + tmp_path, + db_root=db_root, + snapshot=snapshot, + runner=FakeRunner(snapshot), clock=_clock([]), - archive_hasher=lambda _db: "b" * 64, ) - assert caller_db.is_dir() - assert not caller_temp.exists() + assert db_root.is_dir() + assert not (tmp_path / "advisory-policy").exists() with pytest.raises(policy.ReleasePolicyError): policy.prepare_policy_run( _repo(tmp_path), advisory_source_id="internal", db_urls=("ssh://example.test/db.git",), - mode="caller-provisioned", - advisory_acquired_at="2026-07-20T11:30:00Z", - db_root=caller_db, - runner=FakeRunner(status="?? dirty\n"), - temp_path_factory=lambda _label: caller_temp, + db_root=db_root, + runner=FakeRunner(snapshot, status="?? dirty\n"), + temp_path_factory=lambda _label: temp_root, clock=_clock([]), - archive_hasher=lambda _db: "b" * 64, + archive_hasher=lambda _db: DB_ARCHIVE, ) - assert caller_db.is_dir() - assert not caller_temp.exists() + assert db_root.is_dir() + assert not temp_root.exists() diff --git a/tests/test_release_candidate_driver.py b/tests/test_release_candidate_driver.py index 55464cc12..b3b040d7c 100644 --- a/tests/test_release_candidate_driver.py +++ b/tests/test_release_candidate_driver.py @@ -83,9 +83,9 @@ def _env() -> GuardedEnv: "EXPECTED_RELEASE_COMMIT": SOURCE_COMMIT, "SOURCE_COMMIT": "b" * 40, "RELEASE_MODEL_PACKAGES": "exclude", - "RELEASE_ADVISORY_MODE": "caller-provisioned", "RELEASE_ADVISORY_SOURCE_NAME": "fixture", "RELEASE_ADVISORY_DB_URL": "ssh://example.test/db.git", + "RELEASE_ADVISORY_DB_ROOT": "/advisory-db", } ) @@ -93,9 +93,12 @@ def _env() -> GuardedEnv: def _policy() -> PolicyRun: return PolicyRun( advisory_source_id="fixture", + db_snapshot_basename="advisory-db-fixture00000000", db_commit="b" * 40, db_archive_sha256="c" * 64, + advisory_count=1, advisory_acquired_at="2026-07-20T11:00:00Z", + db_commit_timestamp="2026-07-19T12:00:00Z", policy_checked_at="2026-07-20T12:00:00Z", result="pass", ) diff --git a/tests/test_release_ledger.py b/tests/test_release_ledger.py index dd0524ca8..220e6de33 100644 --- a/tests/test_release_ledger.py +++ b/tests/test_release_ledger.py @@ -46,9 +46,12 @@ MALFORMED_ARCHIVE_DIGESTS = tuple( def _policy() -> PolicyRun: return PolicyRun( advisory_source_id="internal", + db_snapshot_basename="advisory-db-fixture00000000", db_commit="b" * 40, db_archive_sha256="c" * 64, + advisory_count=1, advisory_acquired_at="2026-07-20T11:00:00Z", + db_commit_timestamp="2026-07-19T12:00:00Z", policy_checked_at="2026-07-20T12:00:00Z", result="pass", )