diff --git a/.gitignore b/.gitignore index 73a2a6fd9..f3484ccd8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ tests/fixtures/journal/chronicle/*/health/*.jsonl .sandbox.journal build/ core/target/ +/dist/ +/target/ tmp/ /sol/ .sol/ diff --git a/scripts/release_digest.py b/scripts/release_digest.py new file mode 100644 index 000000000..a34caea41 --- /dev/null +++ b/scripts/release_digest.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Deterministic release-candidate digest helpers.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Mapping +from pathlib import Path + +from scripts.check_rust_release_manifest import canonical_json_bytes + + +def file_sha256_size(path: Path) -> tuple[str, int]: + digest = hashlib.sha256() + total = 0 + with path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + total += len(chunk) + digest.update(chunk) + return digest.hexdigest(), total + + +def candidate_digest(release_dir: Path) -> str: + files = sorted( + (path for path in release_dir.rglob("*") if path.is_file()), + key=lambda path: ( + path.name.encode("utf-8"), + path.relative_to(release_dir).as_posix().encode("utf-8"), + ), + ) + stream = bytearray() + for path in files: + digest, byte_count = file_sha256_size(path) + stream.extend(f"{digest} {byte_count} {path.name}\n".encode("ascii")) + return hashlib.sha256(bytes(stream)).hexdigest() + + +def bundle_digest( + candidate_digest: str, + ledger_sha256: str, + proof_sha256_by_target: Mapping[str, str], +) -> str: + payload = { + "candidate_digest": candidate_digest, + "ledger_sha256": ledger_sha256, + "proof_sha256": { + target: proof_sha256_by_target[target] + for target in sorted(proof_sha256_by_target) + }, + } + return hashlib.sha256(canonical_json_bytes(payload)).hexdigest() diff --git a/scripts/release_public_evidence.py b/scripts/release_public_evidence.py new file mode 100644 index 000000000..e4b775c11 --- /dev/null +++ b/scripts/release_public_evidence.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Validate public release evidence strings.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from scripts.check_rust_release_manifest import Failure, validate_public_evidence_text +from scripts.release_tool_pins import PRIVATE_SIGNING_POLICY_VALUES + + +def validate_public_evidence_tree(label: str, value: Any) -> list[Failure]: + failures: list[Failure] = [] + + def validate_string(path: str, text: str) -> None: + failures.extend(validate_public_evidence_text(path, text)) + for private_value in PRIVATE_SIGNING_POLICY_VALUES: + if private_value in text: + failures.append( + Failure( + error=f"{path} contains private signing policy", + expected=f"{path} public release evidence", + actual="redacted", + repair="python3 scripts/check_rust_release_manifest.py", + ) + ) + + def safe_key_path(path: str, key: object, index: int) -> str: + if not isinstance(key, str): + return f"{path}[{key!r}]" + key_failures = validate_public_evidence_text(f"{path}.", key) + contains_private_policy = any( + private_value in key for private_value in PRIVATE_SIGNING_POLICY_VALUES + ) + if key_failures or contains_private_policy: + return f"{path}." + return f"{path}.{key}" + + def visit(path: str, node: Any) -> None: + if isinstance(node, Mapping): + for index, (key, child) in enumerate(node.items()): + child_path = safe_key_path(path, key, index) + if isinstance(key, str): + validate_string(f"{child_path} key", key) + visit(child_path, child) + return + if isinstance(node, str): + validate_string(path, node) + return + if isinstance(node, Sequence) and not isinstance(node, (bytes, bytearray)): + for index, child in enumerate(node): + visit(f"{path}[{index}]", child) + + visit(label, value) + return failures diff --git a/tests/test_release_digest.py b/tests/test_release_digest.py new file mode 100644 index 000000000..b50c50c8d --- /dev/null +++ b/tests/test_release_digest.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import hashlib + +import scripts.release_digest as digest + + +def test_candidate_digest_uses_basename_sorted_two_space_lf_stream(tmp_path) -> None: + (tmp_path / "z").mkdir() + (tmp_path / "z" / "a.txt").write_bytes(b"") + (tmp_path / "b.txt").write_bytes(b"abc") + (tmp_path / "m").mkdir() + (tmp_path / "m" / "c.txt").write_bytes(b"") + + expected_stream = ( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + " 0 a.txt\n" + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + " 3 b.txt\n" + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + " 0 c.txt\n" + ) + path_sorted_stream = ( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + " 3 b.txt\n" + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + " 0 c.txt\n" + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + " 0 a.txt\n" + ) + + assert expected_stream != path_sorted_stream + assert ( + digest.candidate_digest(tmp_path) + == hashlib.sha256(expected_stream.encode("ascii")).hexdigest() + ) + assert digest.candidate_digest(tmp_path) == ( + "f9c21327effe2299f897f3638b41e6d936714d0a6e9aa3781ee577fa2949355e" + ) + + +def test_bundle_digest_excludes_self_hash_and_is_canonical() -> None: + candidate = "f9c21327effe2299f897f3638b41e6d936714d0a6e9aa3781ee577fa2949355e" + ledger = "1" * 64 + proof_hashes = { + "macos-arm64": "4" * 64, + "linux-x86_64-musl": "3" * 64, + "linux-aarch64-musl": "2" * 64, + } + + assert digest.bundle_digest(candidate, ledger, proof_hashes) == ( + "ccce5e9d1a416579b9a07c3e00a60acfd24e78d7fc38fd75f8a16962d27a1021" + ) + + with_extra_self_hash = dict(proof_hashes) + with_extra_self_hash["bundle_digest"] = "5" * 64 + assert digest.bundle_digest(candidate, ledger, with_extra_self_hash) != ( + "ccce5e9d1a416579b9a07c3e00a60acfd24e78d7fc38fd75f8a16962d27a1021" + ) diff --git a/tests/test_release_public_evidence.py b/tests/test_release_public_evidence.py new file mode 100644 index 000000000..ee7b1f218 --- /dev/null +++ b/tests/test_release_public_evidence.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import scripts.check_rust_release_manifest as checker +import scripts.release_public_evidence as public_evidence +import scripts.release_tool_pins as pins + + +def _errors(failures: list[checker.Failure]) -> set[str]: + return {failure.error for failure in failures} + + +def test_validate_public_evidence_tree_recurses_over_keys_and_values() -> None: + failures = public_evidence.validate_public_evidence_tree( + "ledger", + { + "safe": [ + {"nested": "ENVROOT/bin/solstone-core"}, + {"bad_value": "--plat-name=macosx_14_0_arm64"}, + ], + "--flag=value": "ok", + }, + ) + + errors = _errors(failures) + assert "ledger.safe[1].bad_value contains disallowed content" in errors + assert "ledger. key contains disallowed content" in errors + + +def test_validate_public_evidence_tree_allows_non_string_scalars() -> None: + assert ( + public_evidence.validate_public_evidence_tree( + "proof", {"count": 3, "ok": True, "none": None} + ) + == [] + ) + + +def test_public_evidence_canaries_match_release_constraints() -> None: + assert ( + public_evidence.validate_public_evidence_tree( + "proof", {"path": "ENVROOT/bin/solstone-core"} + ) + == [] + ) + assert ( + public_evidence.validate_public_evidence_tree( + "proof", {"checked": "2026-07-20T12:34:56Z"} + ) + == [] + ) + assert ( + public_evidence.validate_public_evidence_tree( + "proof", {"checked": "2026-07-20T12:34:56+00:00"} + ) + == [] + ) + assert ( + public_evidence.validate_public_evidence_tree( + "proof", {"swift": pins.MACOS_SWIFT_PIN} + ) + == [] + ) + + failures = public_evidence.validate_public_evidence_tree( + "proof", {"argv": ["--flag=value"]} + ) + + assert "proof.argv[0] contains disallowed content" in _errors(failures) + + +def test_private_signing_policy_values_are_rejected_in_keys_and_values() -> None: + assert ( + checker.validate_public_evidence_text("team", pins.MACOS_TEAM_IDENTIFIER) == [] + ) + + failures = public_evidence.validate_public_evidence_tree( + "ledger", + { + "outer": {"team": f"policy {pins.MACOS_TEAM_IDENTIFIER}"}, + f"key-{pins.MACOS_TEAM_IDENTIFIER}": "public", + }, + ) + + errors = _errors(failures) + assert "ledger.outer.team contains private signing policy" in errors + assert "ledger. key contains private signing policy" in errors + for failure in failures: + assert pins.MACOS_TEAM_IDENTIFIER not in failure.error + assert pins.MACOS_TEAM_IDENTIFIER not in failure.expected + assert pins.MACOS_TEAM_IDENTIFIER not in failure.actual + assert pins.MACOS_TEAM_IDENTIFIER not in failure.repair