diff --git a/AGENTS.md b/AGENTS.md index 1f5195c35..3454c815f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,9 @@ gates delivery. `make publish-transparency RELEASE_DIR=` is env-driven: operator endpoints, bucket, credentials, minisign key paths, and archive channel come from `TRANSPARENCY_*` env vars, while the public base defaults to `https://transparency.solstone.app`. +`RELEASE_DIR` must resolve to `dist/release-candidate//`; the +corresponding rail evidence is derived from `target/release-evidence//`, +which holds `ledger.json` plus the three proof receipts under `proofs/`. The public layout is fixed: `releases//v//ledger-entry.json`, diff --git a/scripts/transparency_publish.py b/scripts/transparency_publish.py index e7a0bd51a..ae88868ef 100644 --- a/scripts/transparency_publish.py +++ b/scripts/transparency_publish.py @@ -1787,6 +1787,7 @@ def publish_transparency( ) -> PublishResult: started = time.monotonic() signer.check() + transport.check() state = fetch_chain_state(config=config, transport=transport, signer=signer) already_published = ( state.pointer is not None and state.pointer.pointer["version"] == config.version @@ -1911,6 +1912,7 @@ def resign_transparency_pointer( ) -> PublishResult: started = time.monotonic() signer.check() + transport.check() bundle = fetch_verified_pointer(config=config, transport=transport, signer=signer) signed_at = format_published_utc(now or datetime.now(tz=UTC)) pointer = build_latest_pointer( @@ -2041,6 +2043,22 @@ def _config_from_args( ) ] ) + if release_dir: + supplied_release_dir = Path(release_dir).resolve() + derived_release_dir = ( + root / "dist" / "release-candidate" / str(version) + ).resolve() + if supplied_release_dir != derived_release_dir: + raise DriverError( + [ + failure( + "transparency RELEASE_DIR does not match retained path", + expected=str(derived_release_dir), + actual=str(supplied_release_dir), + repair="point RELEASE_DIR at root/dist/release-candidate/", + ) + ] + ) if not source_commit: source_commit = env.get("SOURCE_COMMIT", "") if not source_commit: diff --git a/scripts/transparency_transport.py b/scripts/transparency_transport.py index d6fc5dcdb..86a385c2c 100644 --- a/scripts/transparency_transport.py +++ b/scripts/transparency_transport.py @@ -17,6 +17,9 @@ from pathlib import Path from typing import Protocol from urllib.parse import quote +from scripts.release_candidate_driver import DriverError +from scripts.transparency_core import failure + @dataclass(frozen=True) class HttpResult: @@ -44,6 +47,8 @@ class CurlResult: class TransparencyTransport(Protocol): + def check(self) -> None: ... + def put_object( self, key: str, @@ -86,6 +91,9 @@ class DirectoryTransparencyTransport: def __post_init__(self) -> None: (self.root / "objects").mkdir(parents=True, exist_ok=True) + def check(self) -> None: + return None + @property def s3_destination(self) -> str: return f"{self.endpoint.rstrip('/')}/{self.bucket}" @@ -125,11 +133,15 @@ class DirectoryTransparencyTransport: return f'"{hashlib.sha256(body).hexdigest()}"' def _consume_failure(self, *, plane: str, op: str, key: str) -> FakeFailure | None: - for index, failure in enumerate(tuple(self.failures)): - if failure.plane == plane and failure.op == op and failure.key == key: - if failure.once: + for index, fake_failure in enumerate(tuple(self.failures)): + if ( + fake_failure.plane == plane + and fake_failure.op == op + and fake_failure.key == key + ): + if fake_failure.once: del self.failures[index] - return failure + return fake_failure return None def _record( @@ -396,6 +408,55 @@ class CurlTransparencyTransport: ) ) + def check(self) -> None: + try: + result = subprocess.run( + [self.curl, "--version"], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + raise DriverError( + [ + failure( + "transparency curl preflight failed", + expected="curl --version line 1 with curl >= 7.84", + actual=str(exc), + repair="install curl 7.84 or newer before publishing transparency", + ) + ] + ) from None + first_line = result.stdout.splitlines()[0] if result.stdout.splitlines() else "" + version_text = _curl_version_token(first_line) + version = _curl_major_minor(version_text) + if result.returncode != 0 or version is None: + raise DriverError( + [ + failure( + "transparency curl preflight failed", + expected="curl --version line 1 with curl >= 7.84", + actual=( + result.stderr.strip() + or first_line + or f"exit_code={result.returncode}" + ), + repair="install curl 7.84 or newer before publishing transparency", + ) + ] + ) + if version < MIN_CURL_VERSION: + raise DriverError( + [ + failure( + "transparency curl version is too old", + expected="curl >= 7.84", + actual=f"curl {version_text}", + repair="install curl 7.84 or newer before publishing transparency", + ) + ] + ) + def _run_curl( self, args: Sequence[str], @@ -572,9 +633,25 @@ class CurlTransparencyTransport: CURL_WRITE_OUT = "%{http_code}\t%header{etag}\n" +MIN_CURL_VERSION = (7, 84) MAX_LIST_PAGES = 1000 +def _curl_version_token(first_line: str) -> str: + parts = first_line.split() + return parts[1] if len(parts) >= 2 and parts[0] == "curl" else "" + + +def _curl_major_minor(version_text: str) -> tuple[int, int] | None: + parts = version_text.split(".") + if len(parts) < 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + def parse_curl_write_out(stdout: str) -> tuple[int, str | None]: line = stdout.splitlines()[-1] if stdout.splitlines() else "" status_text, separator, etag_text = line.partition("\t") diff --git a/tests/test_transparency_cli.py b/tests/test_transparency_cli.py index 8ed94c717..a9dfa1df3 100644 --- a/tests/test_transparency_cli.py +++ b/tests/test_transparency_cli.py @@ -61,6 +61,27 @@ def test_config_from_args_derives_source_commit_from_retained_ledger( assert config.source_commit == "b" * 40 +def test_config_from_args_rejects_mispointed_release_dir(tmp_path: Path) -> None: + version = "0.9.1" + supplied = tmp_path / "elsewhere" / version + supplied.mkdir(parents=True) + env = _env(tmp_path) + env["RELEASE_DIR"] = str(supplied) + + with pytest.raises(DriverError) as error: + publisher._config_from_args( + Namespace(root=str(tmp_path), version="", source_commit=""), + env, + ) + + failure = error.value.failures[0] + assert failure.error == "transparency RELEASE_DIR does not match retained path" + assert str(supplied.resolve()) in failure.actual + assert str((tmp_path / "dist" / "release-candidate" / version).resolve()) in ( + failure.expected + ) + + def test_check_transparency_minisign_missing_binary_fails_loudly( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_transparency_transport.py b/tests/test_transparency_transport.py index b0fa4f792..32eb47fdb 100644 --- a/tests/test_transparency_transport.py +++ b/tests/test_transparency_transport.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from scripts.release_candidate_driver import DriverError from scripts.transparency_transport import ( CurlResult, CurlTransparencyTransport, @@ -129,6 +130,79 @@ def test_directory_transport_records_exact_destination_set(tmp_path: Path) -> No ] +def test_curl_check_rejects_too_old_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def run(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["curl", "--version"], + returncode=0, + stdout="curl 7.83.1 (x86_64-redhat-linux-gnu)\n", + stderr="", + ) + + monkeypatch.setattr("scripts.transparency_transport.subprocess.run", run) + transport = CurlTransparencyTransport( + endpoint="https://r2.example.invalid", + bucket="transparency-test", + access_key_id="key", + secret_access_key="secret", + base_url="https://transparency.solstone.app", + ) + + with pytest.raises(DriverError) as error: + transport.check() + + failure = error.value.failures[0] + assert failure.error == "transparency curl version is too old" + assert failure.expected == "curl >= 7.84" + assert failure.actual == "curl 7.83.1" + + +def test_curl_check_missing_binary_fails_as_driver_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def run(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise FileNotFoundError("curl") + + monkeypatch.setattr("scripts.transparency_transport.subprocess.run", run) + transport = CurlTransparencyTransport( + endpoint="https://r2.example.invalid", + bucket="transparency-test", + access_key_id="key", + secret_access_key="secret", + base_url="https://transparency.solstone.app", + ) + + with pytest.raises(DriverError) as error: + transport.check() + + assert error.value.failures[0].error == "transparency curl preflight failed" + + +def test_curl_check_accepts_new_enough_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def run(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["curl", "--version"], + returncode=0, + stdout="curl 8.15.0 (x86_64-redhat-linux-gnu)\n", + stderr="", + ) + + monkeypatch.setattr("scripts.transparency_transport.subprocess.run", run) + transport = CurlTransparencyTransport( + endpoint="https://r2.example.invalid", + bucket="transparency-test", + access_key_id="key", + secret_access_key="secret", + base_url="https://transparency.solstone.app", + ) + + transport.check() + + def test_curl_write_out_parser_extracts_status_and_etag() -> None: assert parse_curl_write_out('200\t"abc123"\n') == (200, '"abc123"') assert parse_curl_write_out("204\t\n") == (204, None)