diff --git a/scripts/transparency_head_log.py b/scripts/transparency_head_log.py index 36f0f3e9f..fc0834d05 100644 --- a/scripts/transparency_head_log.py +++ b/scripts/transparency_head_log.py @@ -131,20 +131,42 @@ def git_witness_status( *, runner: Runner = subprocess.run, ) -> WitnessStatus: - command = ["git", "diff", "--quiet", "--exit-code", "HEAD", "--", HEAD_LOG] - result = runner( - command, + tracked = runner( + ["git", "ls-files", "--error-unmatch", "--", HEAD_LOG], cwd=root, capture_output=True, text=True, check=False, ) - if result.returncode == 0: + if tracked.returncode == 1: + return WitnessStatus( + state="written-untracked", + message=( + f"{HEAD_LOG} row is present but untracked; run: " + f"git add {HEAD_LOG} && git commit" + ), + ) + if tracked.returncode != 0: + return WitnessStatus( + state="witness-unavailable", + message=( + "git witness status unavailable; verify the transparency head row " + f"with: git ls-files --error-unmatch -- {HEAD_LOG}" + ), + ) + diff = runner( + ["git", "diff", "--quiet", "--exit-code", "HEAD", "--", HEAD_LOG], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if diff.returncode == 0: return WitnessStatus( state="written-and-committed", message=f"{HEAD_LOG} row is present and committed", ) - if result.returncode == 1: + if diff.returncode == 1: return WitnessStatus( state="written-uncommitted", message=( diff --git a/scripts/transparency_publish.py b/scripts/transparency_publish.py index f1141f9dd..d5c8260ba 100644 --- a/scripts/transparency_publish.py +++ b/scripts/transparency_publish.py @@ -77,6 +77,7 @@ from scripts.transparency_head_log import ( append_head_row, git_witness_status, highest_seq, + read_head_log, ) from scripts.transparency_signing import ( LocalMinisignSigner, @@ -1543,6 +1544,25 @@ def _write_mutable_objects( ) +def _assert_head_witness_baseline_committed(config: PublishConfig) -> None: + rows = [row for row in read_head_log(config.root) if row.product == config.product] + if not rows: + return + witness = git_witness_status(config.root) + if witness.state == "written-and-committed": + return + row = rows[-1] + fail_closed( + "transparency publication blocked because head witness baseline is uncommitted", + expected=( + f"committed {HEAD_LOG} row product={row.product} seq={row.seq} " + f"version={row.version} entry_sha256={row.entry_sha256}" + ), + actual=witness.message, + repair=f"commit {HEAD_LOG} before publishing; run: git add {HEAD_LOG} && git commit", + ) + + def publish_transparency( *, config: PublishConfig, @@ -1582,6 +1602,7 @@ def publish_transparency( now=now or datetime.now(tz=UTC), ) _assert_stage_extends_state(stage, state) + _assert_head_witness_baseline_committed(config) _clear_publish_call_log(transport) archive_digest = archive_stage( stage=stage, diff --git a/tests/test_transparency_head_log.py b/tests/test_transparency_head_log.py index c9531504d..59c9a41d9 100644 --- a/tests/test_transparency_head_log.py +++ b/tests/test_transparency_head_log.py @@ -1,15 +1,43 @@ from __future__ import annotations +import subprocess from pathlib import Path from scripts.transparency_core import HEAD_LOG, PRODUCT, canonical_json_bytes from scripts.transparency_head_log import ( HeadLogRow, append_head_row, + git_witness_status, head_log_path, ) +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + + +def _init_repo(repo: Path) -> None: + _git(repo, "init", "-q") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + _git(repo, "add", "README.md") + _git( + repo, + "-c", + "user.name=solstone-test", + "-c", + "user.email=solstone@example.invalid", + "commit", + "-qm", + "initial", + ) + + def _row(seq: int, *, entry_sha256: str | None = None) -> HeadLogRow: return HeadLogRow( product=PRODUCT, @@ -35,3 +63,53 @@ def test_append_head_row_preserves_prior_bytes(tmp_path: Path) -> None: expected_new = canonical_json_bytes(new_row.as_dict(), label=HEAD_LOG) assert path.read_bytes() == prior + expected_new + + +def test_git_witness_status_committed(tmp_path: Path) -> None: + _init_repo(tmp_path) + append_head_row(tmp_path, _row(1)) + _git(tmp_path, "add", HEAD_LOG) + _git( + tmp_path, + "-c", + "user.name=solstone-test", + "-c", + "user.email=solstone@example.invalid", + "commit", + "-qm", + "commit head log", + ) + + status = git_witness_status(tmp_path) + + assert status.state == "written-and-committed" + + +def test_git_witness_status_tracked_but_modified(tmp_path: Path) -> None: + _init_repo(tmp_path) + append_head_row(tmp_path, _row(1)) + _git(tmp_path, "add", HEAD_LOG) + _git( + tmp_path, + "-c", + "user.name=solstone-test", + "-c", + "user.email=solstone@example.invalid", + "commit", + "-qm", + "commit head log", + ) + append_head_row(tmp_path, _row(2)) + + status = git_witness_status(tmp_path) + + assert status.state == "written-uncommitted" + + +def test_git_witness_status_untracked(tmp_path: Path) -> None: + _init_repo(tmp_path) + append_head_row(tmp_path, _row(1)) + + status = git_witness_status(tmp_path) + + assert status.state == "written-untracked" diff --git a/tests/test_transparency_publish.py b/tests/test_transparency_publish.py index 77fab89d2..ea0bcfd77 100644 --- a/tests/test_transparency_publish.py +++ b/tests/test_transparency_publish.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import json import logging +import subprocess from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -53,6 +54,16 @@ def _sha(path: Path) -> tuple[str, int]: return hashlib.sha256(data).hexdigest(), len(data) +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + + def _candidate( root: Path, *, @@ -963,6 +974,65 @@ def test_stale_stage_fails_poisoned_version_before_mutable_write( assert mutable_puts == [] +def test_publish_blocks_before_archive_when_head_witness_baseline_untracked( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _candidate(tmp_path, version="0.9.2") + _patch_recover(monkeypatch, version="0.9.2") + _git(tmp_path, "init", "-q") + (tmp_path / "README.md").write_text("fixture\n", encoding="utf-8") + _git(tmp_path, "add", "README.md") + _git( + tmp_path, + "-c", + "user.name=solstone-test", + "-c", + "user.email=solstone@example.invalid", + "commit", + "-qm", + "initial", + ) + signer = FakeTransparencySigner() + transport = DirectoryTransparencyTransport(tmp_path / "remote") + previous = _install_remote_entry( + transport=transport, + signer=signer, + tmp_path=tmp_path, + seq=1, + version="0.9.1", + ) + append_head_row( + tmp_path, + HeadLogRow( + product=PRODUCT, + seq=1, + version="0.9.1", + entry_sha256=previous.sha256, + published_utc="2026-07-22T00:00:00Z", + ), + ) + transport.call_log.clear() + + with pytest.raises(DriverError) as error: + publisher.publish_transparency( + config=_config(tmp_path, version="0.9.2", genesis=None), + transport=transport, + signer=signer, + archive_runner=_archive_ok, + now=datetime(2026, 7, 23, 12, 0, tzinfo=UTC), + ) + + failure = error.value.failures[0] + assert ( + failure.error + == "transparency publication blocked because head witness baseline is uncommitted" + ) + assert f"seq=1 version=0.9.1 entry_sha256={previous.sha256}" in failure.expected + assert "untracked" in failure.actual + assert all(call["op"] not in {"ARCHIVE", "PUT"} for call in transport.call_log) + + def test_candidate_revalidation_failure_stops_before_upload( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,