From 7ecf1f17b0b77cc1e0ff0f7abd3ed121cf295397 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 21:37:14 +0200 Subject: [PATCH] Harden release source execution --- tests/test_release_execution.py | 710 +++++ tests/test_release_module_directory.py | 23 + tests/test_release_source_provenance.py | 48 +- tools/release/govoplan_release/git_state.py | 39 +- .../govoplan_release/module_directory.py | 10 +- tools/release/govoplan_release/publisher.py | 39 +- .../govoplan_release/release_execution.py | 2300 +++++++++++++++++ .../govoplan_release/repository_tag.py | 31 +- .../govoplan_release/selective_catalog.py | 170 +- .../govoplan_release/source_provenance.py | 11 +- .../govoplan_release/version_alignment.py | 8 +- 11 files changed, 3331 insertions(+), 58 deletions(-) create mode 100644 tests/test_release_execution.py create mode 100644 tools/release/govoplan_release/release_execution.py diff --git a/tests/test_release_execution.py b/tests/test_release_execution.py new file mode 100644 index 0000000..8ad0f34 --- /dev/null +++ b/tests/test_release_execution.py @@ -0,0 +1,710 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +META_ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = META_ROOT / "tools" / "release" +if str(RELEASE_ROOT) not in sys.path: + sys.path.insert(0, str(RELEASE_ROOT)) + +from govoplan_release.model import RepositorySpec # noqa: E402 +from govoplan_release.git_state import sanitized_git_environment # noqa: E402 +from govoplan_release.release_execution import ( # noqa: E402 + ExecutorSpec, + ReleaseExecutionAmbiguous, + ReleaseExecutionBlocked, + build_selected_wheels, + execute_repository_step, + executor_spec, + reconciled_repository_receipt, + repository_state_receipt, + require_trusted_release_runtime, + verify_frozen_repository_tag_receipt, + verify_repository_preflight_binding, + _clone_catalog_sources, + _checkout_frozen_source, + _verify_exact_publication_delta, +) +from govoplan_release.selective_catalog import ( # noqa: E402 + enforce_frozen_selected_source_provenance, + enforce_selected_source_provenance, +) + + +class ReleaseExecutionTests(unittest.TestCase): + def test_git_environment_ignores_inherited_redirection_and_commands(self) -> None: + with patch.dict( + "os.environ", + { + "GIT_DIR": "/attacker/repository", + "GIT_CONFIG_GLOBAL": "/attacker/config", + "GIT_SSH_COMMAND": "/attacker/ssh", + "PATH": "/attacker/bin", + "SSH_AUTH_SOCK": "/operator/agent.sock", + }, + clear=False, + ): + environment = sanitized_git_environment() + + self.assertNotIn("GIT_DIR", environment) + self.assertEqual("/dev/null", environment["GIT_CONFIG_GLOBAL"]) + self.assertEqual("/usr/bin:/bin", environment["PATH"]) + self.assertEqual( + "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8", + environment["GIT_SSH_COMMAND"], + ) + self.assertEqual("/operator/agent.sock", environment["SSH_AUTH_SOCK"]) + + def test_publication_reconciliation_rejects_unrelated_commit_delta(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + website = Path(temp_dir) / "website" + _git(Path(temp_dir), "init", "-b", "main", str(website)) + _git(website, "config", "user.name", "Release Test") + _git(website, "config", "user.email", "release@example.test") + catalog = website / "public/catalogs/v1/channels/stable.json" + keyring = website / "public/catalogs/v1/keyring.json" + modules = website / "public/catalogs/v1/modules" + catalog.parent.mkdir(parents=True) + modules.mkdir(parents=True) + catalog.write_text("{}\n", encoding="utf-8") + keyring.write_text("{}\n", encoding="utf-8") + (website / "README.md").write_text("before\n", encoding="utf-8") + _git(website, "add", ".") + _git(website, "commit", "-m", "Base") + parent = _git_output(website, "rev-parse", "HEAD") + + expected = { + "public/catalogs/v1/channels/stable.json": b'{"new": true}\n', + "public/catalogs/v1/keyring.json": b"{}\n", + } + catalog.write_bytes(expected["public/catalogs/v1/channels/stable.json"]) + (website / "README.md").write_text("unrelated\n", encoding="utf-8") + _git(website, "add", ".") + _git(website, "commit", "-m", "Catalog plus unrelated change") + commit = _git_output(website, "rev-parse", "HEAD") + + with self.assertRaisesRegex( + ReleaseExecutionBlocked, + "outside the exact derived delta", + ): + _verify_exact_publication_delta( + web_root=website, + frozen_parent=parent, + commit=commit, + expected_blobs=expected, + module_root=modules, + ) + + def test_runtime_trust_rejects_replaceable_workspace_boundary(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + meta = root / "govoplan" + workspace = root / "workspace" + _git(root, "init", "-b", "main", str(meta)) + (meta / "tools" / "release").mkdir(parents=True) + (meta / "tools" / "checks").mkdir(parents=True) + (meta / "tools" / "release" / "tool.py").write_text( + "# trusted\n", encoding="utf-8" + ) + (meta / "tools" / "checks" / "check.py").write_text( + "# trusted\n", encoding="utf-8" + ) + registry = meta / "repositories.json" + registry.write_text('{"repositories": []}\n', encoding="utf-8") + workspace.mkdir(mode=0o700) + + with ( + patch( + "govoplan_release.release_execution.META_ROOT", + meta, + ), + patch( + "govoplan_release.release_execution.REPOSITORIES_FILE", + registry, + ), + patch( + "govoplan_release.release_execution.publication_runtime_trust_issues", + return_value=(), + ), + ): + require_trusted_release_runtime(workspace_root=workspace) + workspace.chmod(0o777) + with self.assertRaisesRegex( + ReleaseExecutionBlocked, "operator-owned" + ): + require_trusted_release_runtime(workspace_root=workspace) + + def test_durable_binding_rejects_group_writable_git_configuration(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + repository = workspace / "govoplan-files" + remote = workspace / "files.git" + _git(workspace, "init", "--bare", str(remote)) + _git(workspace, "init", "-b", "main", str(repository)) + _git(repository, "config", "user.name", "Release Test") + _git(repository, "config", "user.email", "release@example.test") + (repository / "README.md").write_text("release\n", encoding="utf-8") + _git(repository, "add", "README.md") + _git(repository, "commit", "-m", "Initial") + _git(repository, "remote", "add", "origin", str(remote)) + (repository / ".git" / "config").chmod(0o664) + + with patch( + "govoplan_release.release_execution.load_repository_specs", + return_value=(_repository_spec(remote),), + ): + with self.assertRaisesRegex( + ReleaseExecutionBlocked, "group/world writable" + ): + repository_state_receipt( + repo="govoplan-files", + target_tag="v1.2.3", + workspace_root=workspace, + ) + + def test_durable_binding_rejects_nested_writable_git_authority(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + repository = workspace / "govoplan-files" + remote = workspace / "files.git" + _git(workspace, "init", "--bare", str(remote)) + _git(workspace, "init", "-b", "main", str(repository)) + _git(repository, "config", "user.name", "Release Test") + _git(repository, "config", "user.email", "release@example.test") + (repository / "README.md").write_text("release\n", encoding="utf-8") + _git(repository, "add", "README.md") + _git(repository, "commit", "-m", "Initial") + _git(repository, "remote", "add", "origin", str(remote)) + (repository / ".git" / "refs" / "heads").chmod(0o777) + + with patch( + "govoplan_release.release_execution.load_repository_specs", + return_value=(_repository_spec(remote),), + ): + with self.assertRaisesRegex( + ReleaseExecutionBlocked, + "Git metadata directory", + ): + repository_state_receipt( + repo="govoplan-files", + target_tag="v1.2.3", + workspace_root=workspace, + ) + + def test_creation_binding_rejects_head_and_push_remote_drift(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + repository = workspace / "govoplan-files" + remote = workspace / "files.git" + _git(workspace, "init", "--bare", str(remote)) + _git(workspace, "init", "-b", "main", str(repository)) + _git(repository, "config", "user.name", "Release Test") + _git(repository, "config", "user.email", "release@example.test") + (repository / "README.md").write_text("one\n", encoding="utf-8") + _git(repository, "add", "README.md") + _git(repository, "commit", "-m", "Initial") + _git(repository, "remote", "add", "origin", str(remote)) + _git(repository, "push", "-u", "origin", "main") + spec = _repository_spec(remote) + + with patch( + "govoplan_release.release_execution.load_repository_specs", + return_value=(spec,), + ): + frozen = repository_state_receipt( + repo="govoplan-files", + target_tag="v1.2.3", + workspace_root=workspace, + ) + step = { + "repo": "govoplan-files", + "source_binding": frozen, + } + (repository / "README.md").write_text("two\n", encoding="utf-8") + _git(repository, "add", "README.md") + _git(repository, "commit", "-m", "Drift") + with self.assertRaisesRegex( + ReleaseExecutionBlocked, "state drifted" + ): + verify_repository_preflight_binding( + plan_step=step, + version="1.2.3", + workspace_root=workspace, + ) + + _git( + repository, + "remote", + "set-url", + "--add", + "--push", + "origin", + str(workspace / "other.git"), + ) + with self.assertRaisesRegex( + ReleaseExecutionBlocked, "registered release remote" + ): + repository_state_receipt( + repo="govoplan-files", + target_tag="v1.2.3", + workspace_root=workspace, + ) + + def test_reconciliation_proves_annotated_local_and_remote_tag(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + repository = workspace / "govoplan-files" + remote = workspace / "files.git" + _git(workspace, "init", "--bare", str(remote)) + _git(workspace, "init", "-b", "main", str(repository)) + _git(repository, "config", "user.name", "Release Test") + _git(repository, "config", "user.email", "release@example.test") + (repository / "README.md").write_text("release\n", encoding="utf-8") + _git(repository, "add", "README.md") + _git(repository, "commit", "-m", "Initial") + _git(repository, "remote", "add", "origin", str(remote)) + _git(repository, "push", "-u", "origin", "main") + spec = _repository_spec(remote) + plan_step = { + "repo": "govoplan-files", + "source_binding": {}, + } + tag_spec = ExecutorSpec("tag", "TAG", "tag_created", True) + push_spec = ExecutorSpec("push", "PUBLISH", "tag_published", True) + + with patch( + "govoplan_release.release_execution.load_repository_specs", + return_value=(spec,), + ): + before = repository_state_receipt( + repo="govoplan-files", + target_tag="v1.2.3", + workspace_root=workspace, + ) + _git(repository, "tag", "-a", "v1.2.3", "-m", "Release") + tagged = reconciled_repository_receipt( + spec=tag_spec, + plan_step=plan_step, + version="1.2.3", + workspace_root=workspace, + expected_receipt=before, + ) + self.assertIsNotNone(tagged["tag_object"]) + with self.assertRaisesRegex( + ReleaseExecutionBlocked, "Remote annotated tag" + ): + reconciled_repository_receipt( + spec=push_spec, + plan_step=plan_step, + version="1.2.3", + workspace_root=workspace, + expected_receipt=tagged, + ) + _git( + repository, + "push", + "--atomic", + "origin", + "HEAD:refs/heads/main", + "refs/tags/v1.2.3", + ) + published = reconciled_repository_receipt( + spec=push_spec, + plan_step=plan_step, + version="1.2.3", + workspace_root=workspace, + expected_receipt=tagged, + ) + self.assertEqual(tagged, published) + (repository / "README.md").write_text( + "uncommitted drift\n", encoding="utf-8" + ) + self.assertEqual( + tagged, + verify_frozen_repository_tag_receipt( + receipt=tagged, + workspace_root=workspace, + ), + ) + (repository / "README.md").write_text( + "remote branch drift\n", encoding="utf-8" + ) + _git(repository, "add", "README.md") + _git(repository, "commit", "-m", "Remote branch drift") + drift_commit = _git_output(repository, "rev-parse", "HEAD") + _git( + repository, + "push", + "origin", + f"{drift_commit}:refs/heads/drift-source", + ) + _git(remote, "update-ref", "refs/heads/main", drift_commit) + _git(repository, "reset", "--hard", str(tagged["head"])) + with self.assertRaisesRegex( + ReleaseExecutionBlocked, + "Remote branch", + ): + reconciled_repository_receipt( + spec=push_spec, + plan_step=plan_step, + version="1.2.3", + workspace_root=workspace, + expected_receipt=tagged, + ) + + def test_post_tag_receipt_failure_is_ambiguous(self) -> None: + spec = ExecutorSpec("tag", "TAG", "tag_created", True) + plan_step = { + "id": "govoplan-files:tag", + "repo": "govoplan-files", + "source_binding": {"kind": "repository_state"}, + } + with ( + patch( + "govoplan_release.release_execution.require_trusted_release_runtime" + ), + patch( + "govoplan_release.release_execution.verify_repository_step_precondition" + ), + patch( + "govoplan_release.release_execution.tag_repositories", + return_value={ + "status": "tagged", + "repositories": [{"status": "tagged"}], + }, + ), + patch( + "govoplan_release.release_execution.repository_state_receipt", + side_effect=ReleaseExecutionBlocked("post-effect probe failed"), + ), + ): + with self.assertRaisesRegex( + ReleaseExecutionAmbiguous, "post-effect repository receipt" + ): + execute_repository_step( + spec=spec, + plan_step=plan_step, + repo_versions={"govoplan-files": "1.2.3"}, + workspace_root=Path("/workspace"), + remote="origin", + expected_receipt={"kind": "repository_state"}, + ) + + def test_commit_step_has_no_durable_executor(self) -> None: + self.assertIsNone( + executor_spec( + { + "id": "govoplan-files:commit", + "status": "planned", + "mutating": True, + "repo": "govoplan-files", + "source_binding": { + "kind": "repository_state", + }, + } + ) + ) + + def test_frozen_checkout_uses_receipt_commit_not_live_worktree(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + repository = workspace / "govoplan-files" + repository.mkdir() + _git(repository, "init", "-b", "main") + _git(repository, "config", "user.name", "Release Test") + _git(repository, "config", "user.email", "release@example.test") + (repository / "version.txt").write_text("frozen\n", encoding="utf-8") + _git(repository, "add", "version.txt") + _git(repository, "commit", "-m", "Frozen") + frozen_commit = _git_output(repository, "rev-parse", "HEAD") + _git(repository, "tag", "-a", "v1.2.3", "-m", "Release") + tag_object = _git_output(repository, "rev-parse", "refs/tags/v1.2.3") + (repository / "version.txt").write_text("live drift\n", encoding="utf-8") + + source_root = workspace / "isolated" + source_root.mkdir() + checkout = _checkout_frozen_source( + repo="govoplan-files", + source_remote=repository, + commit=frozen_commit, + tag="v1.2.3", + tag_object=tag_object, + source_root=source_root, + ) + + self.assertEqual( + "frozen\n", (checkout / "version.txt").read_text(encoding="utf-8") + ) + + def test_frozen_provenance_accepts_only_exact_clean_detached_tag(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + repository = workspace / "govoplan-files" + remote = workspace / "files.git" + _git(workspace, "init", "--bare", str(remote)) + _git(workspace, "init", "-b", "main", str(repository)) + _git(repository, "config", "user.name", "Release Test") + _git(repository, "config", "user.email", "release@example.test") + (repository / "pyproject.toml").write_text( + '[project]\nname = "govoplan-files"\nversion = "1.2.3"\n', + encoding="utf-8", + ) + _git(repository, "add", "pyproject.toml") + _git(repository, "commit", "-m", "Release") + commit = _git_output(repository, "rev-parse", "HEAD") + _git(repository, "tag", "-a", "v1.2.3", "-m", "Release") + tag_object = _git_output( + repository, "rev-parse", "refs/tags/v1.2.3" + ) + _git(repository, "remote", "add", "origin", str(remote)) + _git(repository, "push", "origin", "main", "refs/tags/v1.2.3") + _git(repository, "checkout", "--detach", commit) + spec = _repository_spec(remote) + + with ( + patch( + "govoplan_release.source_provenance.load_repository_specs", + return_value=(spec,), + ), + patch( + "govoplan_release.selective_catalog.load_repository_specs", + return_value=(spec,), + ), + ): + with self.assertRaisesRegex(ValueError, "named branch"): + enforce_selected_source_provenance( + repo_versions={"govoplan-files": "1.2.3"}, + workspace=workspace, + ) + observed = enforce_frozen_selected_source_provenance( + repo_versions={"govoplan-files": "1.2.3"}, + expected_provenance={ + "govoplan-files": { + "commit_sha": commit, + "tag_object_sha": tag_object, + } + }, + workspace=workspace, + ) + + self.assertEqual(commit, observed["govoplan-files"]["commit_sha"]) + self.assertEqual( + tag_object, observed["govoplan-files"]["tag_object_sha"] + ) + + def test_wheel_staging_skips_selected_tag_only_repository(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + python_repo = workspace / "govoplan-files" + tag_only_repo = workspace / "govoplan-meta-notes" + python_repo.mkdir() + tag_only_repo.mkdir() + (python_repo / "pyproject.toml").write_text( + '[project]\nname = "govoplan-files"\nversion = "1.2.3"\n', + encoding="utf-8", + ) + specs = ( + RepositorySpec( + name="govoplan-files", + category="module", + subtype="infrastructure", + remote="git@example.test/files.git", + path="govoplan-files", + ), + RepositorySpec( + name="govoplan-meta-notes", + category="system", + subtype="metadata", + remote="git@example.test/notes.git", + path="govoplan-meta-notes", + ), + ) + candidate = workspace / "candidate" + + def build(command: tuple[str, ...]) -> int: + wheel_dir = Path(command[command.index("--bind") + 1]) + ( + wheel_dir / "govoplan_files-1.2.3-py3-none-any.whl" + ).write_bytes(b"wheel") + return 0 + + with ( + patch( + "govoplan_release.release_execution.load_repository_specs", + return_value=specs, + ), + patch( + "govoplan_release.release_execution.isolated_builder_launcher", + return_value=("/usr/bin/bwrap",), + ), + patch( + "govoplan_release.release_execution._run_isolated_wheel_builder", + side_effect=build, + ) as runner, + patch( + "govoplan_release.release_execution.inspect_python_wheel", + return_value=SimpleNamespace( + package_name="govoplan-files", + package_version="1.2.3", + ), + ), + ): + artifacts = build_selected_wheels( + repo_versions={ + "govoplan-files": "1.2.3", + "govoplan-meta-notes": "1.0.0", + }, + workspace_root=workspace, + candidate_output=candidate, + source_receipts={ + "govoplan-files": { + "head": "a" * 40, + "target_tag": "v1.2.3", + "tag_object": "c" * 40, + }, + "govoplan-meta-notes": { + "head": "b" * 40, + "target_tag": "v1.0.0", + "tag_object": "d" * 40, + }, + }, + frozen_sources={ + "govoplan-files": python_repo, + "govoplan-meta-notes": tag_only_repo, + }, + ) + + self.assertEqual({"govoplan-files"}, set(artifacts)) + self.assertEqual(1, runner.call_count) + command = runner.call_args.args[0] + self.assertIn(str(python_repo), command) + self.assertIn("--unshare-all", command) + self.assertIn("PIP_NO_INDEX", command) + self.assertNotIn(str(Path.home()), command) + + def test_catalog_synthesis_clones_selected_and_unchanged_base_sources( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + files_repo, files_remote = _tagged_repository( + workspace, + repo="govoplan-files", + bare="files.git", + version="1.2.3", + ) + core_repo, core_remote = _tagged_repository( + workspace, + repo="govoplan-core", + bare="core.git", + version="1.0.0", + ) + specs = ( + _repository_spec(files_remote), + RepositorySpec( + name="govoplan-core", + category="core", + subtype="core", + remote=str(core_remote), + path="govoplan-core", + ), + ) + destination = workspace / "synthesis" + destination.mkdir() + + with patch( + "govoplan_release.release_execution.load_repository_specs", + return_value=specs, + ): + receipt = repository_state_receipt( + repo="govoplan-files", + target_tag="v1.2.3", + workspace_root=workspace, + ) + cloned = _clone_catalog_sources( + source_versions={ + "govoplan-core": "1.0.0", + "govoplan-files": "1.2.3", + }, + repo_versions={"govoplan-files": "1.2.3"}, + source_receipts={"govoplan-files": receipt}, + workspace_root=workspace, + destination=destination, + ) + + self.assertEqual( + _git_output(files_repo, "rev-parse", "refs/tags/v1.2.3^{commit}"), + _git_output(cloned["govoplan-files"], "rev-parse", "HEAD"), + ) + self.assertEqual( + _git_output(core_repo, "rev-parse", "refs/tags/v1.0.0^{commit}"), + _git_output(cloned["govoplan-core"], "rev-parse", "HEAD"), + ) + self.assertEqual( + "HEAD", + _git_output(cloned["govoplan-core"], "rev-parse", "--abbrev-ref", "HEAD"), + ) + + +def _repository_spec(remote: Path) -> RepositorySpec: + return RepositorySpec( + name="govoplan-files", + category="module", + subtype="infrastructure", + remote=str(remote), + path="govoplan-files", + ) + + +def _tagged_repository( + workspace: Path, *, repo: str, bare: str, version: str +) -> tuple[Path, Path]: + repository = workspace / repo + remote = workspace / bare + _git(workspace, "init", "--bare", str(remote)) + _git(workspace, "init", "-b", "main", str(repository)) + _git(repository, "config", "user.name", "Release Test") + _git(repository, "config", "user.email", "release@example.test") + (repository / "pyproject.toml").write_text( + f'[project]\nname = "{repo}"\nversion = "{version}"\n', + encoding="utf-8", + ) + _git(repository, "add", "pyproject.toml") + _git(repository, "commit", "-m", "Release") + _git(repository, "tag", "-a", f"v{version}", "-m", "Release") + _git(repository, "remote", "add", "origin", str(remote)) + _git(repository, "push", "-u", "origin", "main", f"refs/tags/v{version}") + return repository, remote + + +def _git(cwd: Path, *arguments: str) -> None: + subprocess.run( # noqa: S603 + ("git", *arguments), + cwd=cwd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def _git_output(cwd: Path, *arguments: str) -> str: + return subprocess.run( # noqa: S603 + ("git", *arguments), + cwd=cwd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ).stdout.strip() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_module_directory.py b/tests/test_release_module_directory.py index f4d0b58..8775e34 100644 --- a/tests/test_release_module_directory.py +++ b/tests/test_release_module_directory.py @@ -18,6 +18,29 @@ from govoplan_release.module_directory import ( # noqa: E402 class ReleaseModuleDirectoryTests(unittest.TestCase): + def test_signed_catalog_timestamp_makes_derived_files_reproducible(self) -> None: + catalog = { + "generated_at": "2026-07-22T12:00:00Z", + "sequence": 7, + "modules": [], + } + first = module_directory_payloads( + catalog_payload=catalog, + keyring_payload={}, + channel="stable", + ) + second = module_directory_payloads( + catalog_payload=catalog, + keyring_payload={}, + channel="stable", + ) + + self.assertEqual(first, second) + self.assertEqual( + "2026-07-22T12:00:00Z", + first[-1][1]["generated_at"], + ) + def test_dot_segments_and_noncanonical_ids_never_become_paths(self) -> None: for value in (".", "..", "../escape", "/absolute", "module/child"): with self.subTest(value=value), self.assertRaises(ValueError): diff --git a/tests/test_release_source_provenance.py b/tests/test_release_source_provenance.py index bca1370..235904e 100644 --- a/tests/test_release_source_provenance.py +++ b/tests/test_release_source_provenance.py @@ -23,6 +23,7 @@ from govoplan_release.catalog import canonical_hash # noqa: E402 from govoplan_release.selective_catalog import ( # noqa: E402 build_selective_catalog_candidate, enforce_selected_source_provenance, + parse_signing_key, public_key_base64, signature, ) @@ -159,6 +160,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase): encryption_algorithm=serialization.NoEncryption(), ) ) + key_path.chmod(0o600) output = self.root / "candidate" with patch( @@ -181,6 +183,25 @@ class ReleaseSourceProvenanceTests(unittest.TestCase): self.assertEqual(git_text(core, "rev-parse", "refs/tags/v1.2.3"), selected["tag_object_sha"]) self.assertEqual("test-key", payload["signatures"][0]["key_id"]) + def test_catalog_signing_key_must_be_operator_private(self) -> None: + private_key = Ed25519PrivateKey.generate() + key_path = self.root / "release.pem" + key_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + key_path.chmod(0o644) + + with self.assertRaisesRegex(ValueError, "inaccessible to other users"): + parse_signing_key(f"test-key={key_path}") + + key_path.chmod(0o600) + key_id, _parsed = parse_signing_key(f"test-key={key_path}") + self.assertEqual("test-key", key_id) + def test_catalog_publication_checks_every_preserved_source_ref(self) -> None: core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3") git(core, "tag", "-a", "v1.2.3", "-m", "Core 1.2.3") @@ -235,22 +256,21 @@ class ReleaseSourceProvenanceTests(unittest.TestCase): self.assertIn("Array.isArray(result.notes)", ui) self.assertIn("Validation and provenance details", ui) - def test_console_returns_actionable_conflict_for_candidate_provenance_gate(self) -> None: - with patch( - "server.app.build_selective_catalog_candidate", - side_effect=ValueError("Complete catalog source provenance gate failed: govoplan-core v1.2.3: missing"), - ): - with TestClient(create_app(workspace_root=self.workspace)) as client: - response = client.post( - "/api/catalog-candidates", - json={ - "repo_versions": {"govoplan-core": "1.2.3"}, - "signing_keys": ["test=/unused/key.pem"], - }, - ) + def test_console_disables_unclaimed_legacy_candidate_signing(self) -> None: + with TestClient( + create_app(workspace_root=self.workspace, token="token") + ) as client: + response = client.post( + "/api/catalog-candidates", + headers={"X-Release-Console-Token": "token"}, + json={ + "repo_versions": {"govoplan-core": "1.2.3"}, + "signing_keys": ["test=/unused/key.pem"], + }, + ) self.assertEqual(409, response.status_code) - self.assertIn("govoplan-core v1.2.3: missing", response.json()["detail"]) + self.assertIn("durable release run", response.json()["detail"]) def test_read_only_ref_checks_can_reuse_the_same_gitea_https_endpoint(self) -> None: self.assertEqual( diff --git a/tools/release/govoplan_release/git_state.py b/tools/release/govoplan_release/git_state.py index 029d9a2..3433a8e 100644 --- a/tools/release/govoplan_release/git_state.py +++ b/tools/release/govoplan_release/git_state.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from pathlib import Path import re import subprocess @@ -165,16 +166,50 @@ def git_success(path: Path, *args: str, timeout: int = 10) -> bool: def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]: try: return subprocess.run( - ["git", *args], + ["/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args], cwd=path, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, + env=sanitized_git_environment(), ) except subprocess.TimeoutExpired as exc: - return subprocess.CompletedProcess(["git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out") + return subprocess.CompletedProcess(["/usr/bin/git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out") + + +def sanitized_git_environment( + source: dict[str, str] | None = None, +) -> dict[str, str]: + """Keep only deliberate process/auth inputs and neutralize Git redirection.""" + + environment = os.environ if source is None else source + result = { + key: environment[key] + for key in ( + "HOME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "SSH_AUTH_SOCK", + ) + if environment.get(key) + } + result.update( + { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_COUNT": "0", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "GIT_PAGER": "cat", + "GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8", + "GIT_TERMINAL_PROMPT": "0", + "PATH": "/usr/bin:/bin", + } + ) + return result def git_error(result: subprocess.CompletedProcess[str]) -> str: diff --git a/tools/release/govoplan_release/module_directory.py b/tools/release/govoplan_release/module_directory.py index 7c0e356..f18745f 100644 --- a/tools/release/govoplan_release/module_directory.py +++ b/tools/release/govoplan_release/module_directory.py @@ -44,7 +44,15 @@ def module_directory_payloads( public_base_url: str = DEFAULT_PUBLIC_BASE_URL, ) -> tuple[tuple[Path, dict[str, Any]], ...]: channel = validate_release_channel(channel) - generated_at = json_datetime(datetime.now(tz=UTC)) + # Publication artifacts must be reproducible from the signed catalog so an + # interrupted push can later be reconciled against the immutable commit. + # Older/ad-hoc callers without a catalog timestamp retain the old fallback. + catalog_generated_at = catalog_payload.get("generated_at") + generated_at = ( + catalog_generated_at + if isinstance(catalog_generated_at, str) and catalog_generated_at + else json_datetime(datetime.now(tz=UTC)) + ) modules = module_rows(catalog_payload) compatibility = compatibility_rows(modules) signatures = signature_rows(catalog_payload, keyring_payload) diff --git a/tools/release/govoplan_release/publisher.py b/tools/release/govoplan_release/publisher.py index 9320bc7..f4d205c 100644 --- a/tools/release/govoplan_release/publisher.py +++ b/tools/release/govoplan_release/publisher.py @@ -1024,11 +1024,18 @@ def publication_runtime_trust_issues() -> tuple[str, ...]: ancestor_issue = _operator_controlled_ancestor_issue(path) if ancestor_issue: return (f"{label}: {ancestor_issue}",) - executable_issue = _trusted_runtime_executable_issue( - Path(sys.executable), permitted_owners=runtime_owners - ) - if executable_issue: - return (executable_issue,) + for executable, label in ( + (Path(sys.executable), "Python executable"), + (Path("/usr/bin/git"), "Git executable"), + (Path("/usr/bin/ssh"), "SSH executable"), + ): + executable_issue = _trusted_runtime_executable_issue( + executable, + permitted_owners=runtime_owners, + label=label, + ) + if executable_issue: + return (executable_issue,) required = ( (META_ROOT, True, "meta repository root"), (META_ROOT / "repositories.json", False, "repository registry"), @@ -1093,32 +1100,32 @@ def _loaded_package_root(module: object, *, label: str) -> Path: def _trusted_runtime_executable_issue( - path: Path, *, permitted_owners: set[int] + path: Path, *, permitted_owners: set[int], label: str = "Python executable" ) -> str | None: executable = path.absolute() ancestor_issue = _operator_controlled_ancestor_issue(executable.parent) if ancestor_issue: - return f"Python executable: {ancestor_issue}" + return f"{label}: {ancestor_issue}" try: link_metadata = executable.lstat() except OSError: - return f"Python executable cannot be inspected safely: {executable}" + return f"{label} cannot be inspected safely: {executable}" if stat.S_ISLNK(link_metadata.st_mode): if link_metadata.st_uid not in permitted_owners: - return f"Python executable symlink has an untrusted owner: {executable}" + return f"{label} symlink has an untrusted owner: {executable}" try: target = executable.resolve(strict=True) except OSError: - return f"Python executable symlink cannot be resolved safely: {executable}" + return f"{label} symlink cannot be resolved safely: {executable}" else: target = executable ancestor_issue = _operator_controlled_ancestor_issue(target.parent) if ancestor_issue: - return f"Python executable target: {ancestor_issue}" + return f"{label} target: {ancestor_issue}" return _operator_control_issue( target, directory=False, - label="Python executable target", + label=f"{label} target", permitted_owners=permitted_owners, ) @@ -1866,7 +1873,9 @@ def _sanitized_git_environment( source = base if base is not None else os.environ environment = { - key: value for key, value in source.items() if not key.startswith("GIT_") + key: source[key] + for key in ("HOME", "LANG", "LC_ALL", "LC_CTYPE", "SSH_AUTH_SOCK") + if source.get(key) } environment.update( { @@ -1875,7 +1884,9 @@ def _sanitized_git_environment( "GIT_CONFIG_NOSYSTEM": "1", "GIT_NO_REPLACE_OBJECTS": "1", "GIT_PAGER": "cat", + "GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8", "GIT_TERMINAL_PROMPT": "0", + "PATH": "/usr/bin:/bin", } ) if isolate_config: @@ -1911,7 +1922,7 @@ def _git_bytes( ) result = subprocess.run( [ - "git", + "/usr/bin/git", "-C", str(path), "-c", diff --git a/tools/release/govoplan_release/release_execution.py b/tools/release/govoplan_release/release_execution.py new file mode 100644 index 0000000..a611eec --- /dev/null +++ b/tools/release/govoplan_release/release_execution.py @@ -0,0 +1,2300 @@ +"""Narrow durable-run adapters for existing confirmed release executors.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +import os +from pathlib import Path +import shutil +import signal +import stat +import subprocess +import tempfile +import tomllib +from typing import Any + +from .artifact_identity import ( + MAX_WHEEL_BYTES, + inspect_python_wheel, + normalize_package_name, + selected_artifact_identity_issues, +) +from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash +from .candidate_artifact import ( + CandidateArtifactReceipt, + candidate_output_path, + harden_private_candidate_tree, + issue_candidate_receipt, + validate_release_channel, +) +from .git_state import ( + collect_repository_snapshot, + git, + git_text, + sanitized_git_environment, +) +from .model import to_jsonable +from .module_directory import module_directory_payloads +from .publisher import ( + bind_publication_remote, + catalog_public_base_url, + default_tag_name, + publication_runtime_trust_issues, + publish_catalog_candidate, + registered_website_remote, + remote_publication_identity, + validate_catalog_payload, + verify_committed_publication, +) +from .repository_tag import ref_commit, remote_tag_commit, tag_repositories +from .selective_catalog import ( + build_selective_catalog_candidate, + configured_signer_public_keys, + load_authenticated_catalog_base, + parse_signing_key, + trusted_keys_from_keyring, +) +from .source_provenance import catalog_source_selection +from .workspace import ( + META_ROOT, + REPOSITORIES_FILE, + load_repository_specs, + resolve_repo_path, + website_root, +) + + +MAX_EXECUTOR_OUTPUT = 4_000 +MAX_SIGNING_KEYS = 8 +MAX_REMOTE_METADATA_BYTES = 16 * 1024 +MAX_CATALOG_SOURCE_REPOSITORIES = 128 +MAX_TRUSTED_RUNTIME_ENTRIES = 10_000 +MAX_TRUSTED_GIT_ENTRIES = 500_000 +DURABLE_REMOTE = "origin" + + +class ReleaseExecutionError(RuntimeError): + """Base error for a plan step that cannot be executed safely.""" + + +class ReleaseExecutionBlocked(ReleaseExecutionError): + """The executor proved that no intended external effect was completed.""" + + +class ReleaseExecutionAmbiguous(ReleaseExecutionError): + """The executor may have produced an effect and requires reconciliation.""" + + +@dataclass(frozen=True, slots=True) +class ExecutorSpec: + kind: str + confirmation: str + result_code: str + mutating: bool + + +EXECUTOR_SPECS = { + "preflight": ExecutorSpec("preflight", "", "preflight_valid", False), + "tag": ExecutorSpec("tag", "TAG", "tag_created", True), + "push": ExecutorSpec("push", "PUBLISH", "tag_published", True), + "catalog:selective-generator": ExecutorSpec( + "catalog_generate", "GENERATE", "catalog_candidate_ready", True + ), + "catalog:validate-sign-publish": ExecutorSpec( + "catalog_publish", "PUSH", "catalog_published", True + ), +} + + +def executor_spec(plan_step: dict[str, Any]) -> ExecutorSpec | None: + step_id = plan_step.get("id") + if step_id in EXECUTOR_SPECS: + spec = EXECUTOR_SPECS[step_id] + elif isinstance(step_id, str) and ":" in step_id: + spec = EXECUTOR_SPECS.get(step_id.rsplit(":", 1)[1]) + else: + spec = None + if spec is None or plan_step.get("status") != "planned": + return None + if plan_step.get("mutating") is not spec.mutating: + return None + if spec.kind == "catalog_generate": + return spec if plan_step.get("repo") is None else None + if spec.kind == "catalog_publish": + binding = plan_step.get("source_binding") + return ( + spec + if plan_step.get("repo") is None + and isinstance(binding, dict) + and binding.get("repo") == "addideas-govoplan-website" + else None + ) + repo = plan_step.get("repo") + binding = plan_step.get("source_binding") + return ( + spec + if isinstance(repo, str) + and repo + and isinstance(binding, dict) + and binding.get("kind") == "repository_state" + else None + ) + + +def require_trusted_release_runtime(*, workspace_root: Path) -> None: + """Require an operator-controlled console/config and workspace boundary.""" + + dependency_issues = publication_runtime_trust_issues() + if dependency_issues: + raise ReleaseExecutionBlocked( + "Release runtime is not operator-controlled: " + + "; ".join(dependency_issues) + ) + _require_operator_checkout(META_ROOT) + _require_trusted_tree(META_ROOT / "tools" / "release") + _require_trusted_tree(META_ROOT / "tools" / "checks") + _require_trusted_regular_file(REPOSITORIES_FILE, label="repository registry") + migration_baseline = META_ROOT / "docs" / "migration-release-baselines.json" + if migration_baseline.exists(): + _require_trusted_regular_file( + migration_baseline, + label="migration release baseline", + ) + _require_trusted_directory(workspace_root, label="release workspace") + _require_trusted_ancestor_chain(workspace_root) + + +def bind_plan_source_states( + *, + plan: dict[str, Any], + repo_versions: dict[str, str], + workspace_root: Path, +) -> dict[str, Any]: + """Freeze creation-time Git identities into the immutable plan snapshot.""" + + plan["runtime_binding"] = trusted_release_runtime_receipt( + workspace_root=workspace_root + ) + steps = plan.get("dry_run_steps") + if not isinstance(steps, list): + raise ReleaseExecutionBlocked("Release plan steps are unavailable.") + bindings: dict[str, dict[str, Any] | None] = {} + for repo, version in repo_versions.items(): + try: + bindings[repo] = repository_state_receipt( + repo=repo, + target_tag=f"v{version.removeprefix('v')}", + workspace_root=workspace_root, + ) + except ReleaseExecutionBlocked: + bindings[repo] = None + try: + website_binding = repository_state_receipt( + repo="addideas-govoplan-website", + target_tag="", + workspace_root=workspace_root, + include_website=True, + ) + except ReleaseExecutionBlocked: + website_binding = None + for step in steps: + if not isinstance(step, dict): + continue + repo = step.get("repo") + if isinstance(repo, str): + step["source_binding"] = bindings.get(repo) + elif step.get("id") == "catalog:validate-sign-publish": + step["source_binding"] = website_binding + return plan + + +def trusted_release_runtime_receipt(*, workspace_root: Path) -> dict[str, Any]: + """Bind trusted release code/config to one clean origin branch commit.""" + + require_trusted_release_runtime(workspace_root=workspace_root) + specs = {item.name: item for item in load_repository_specs(include_website=False)} + meta_spec = specs.get("govoplan") + if meta_spec is None or resolve_repo_path(meta_spec, workspace_root) != META_ROOT: + raise ReleaseExecutionBlocked( + "Trusted release tooling is not the registered workspace meta repository." + ) + receipt = repository_state_receipt( + repo="govoplan", + target_tag="", + workspace_root=workspace_root, + ) + if not receipt["worktree_clean"]: + raise ReleaseExecutionBlocked( + "Release tooling checkout must be clean before a durable run." + ) + path = resolve_repo_path(meta_spec, workspace_root) + remote_head = _remote_branch_commit(path, branch=str(receipt["branch"])) + if remote_head != receipt["head"]: + raise ReleaseExecutionBlocked( + "Release tooling HEAD must equal its registered origin branch." + ) + return receipt + + +def verify_release_runtime_binding( + *, expected: object, workspace_root: Path +) -> dict[str, Any]: + current = trusted_release_runtime_receipt(workspace_root=workspace_root) + _require_receipt_match(current, expected, operation="release execution") + return current + + +def execute_repository_step( + *, + spec: ExecutorSpec, + plan_step: dict[str, Any], + repo_versions: dict[str, str], + workspace_root: Path, + remote: str, + expected_receipt: dict[str, Any] | None, +) -> tuple[dict[str, Any], dict[str, Any]]: + require_trusted_release_runtime(workspace_root=workspace_root) + if remote != DURABLE_REMOTE: + raise ReleaseExecutionBlocked("Durable release execution uses only origin.") + repo = str(plan_step["repo"]) + version = repo_versions.get(repo) + if version is None: + raise ReleaseExecutionBlocked( + "The frozen plan step has no matching repository version." + ) + if spec.kind == "preflight": + result, receipt = repository_preflight( + repo=repo, + version=version, + workspace_root=workspace_root, + ) + _require_receipt_match( + receipt, + plan_step.get("source_binding"), + operation="repository preflight", + ) + return result, receipt + verify_repository_step_precondition( + spec=spec, + plan_step=plan_step, + version=version, + workspace_root=workspace_root, + expected_receipt=expected_receipt, + ) + if spec.kind == "tag": + result = tag_repositories( + repos=(repo,), + repo_versions={repo: version}, + workspace_root=workspace_root, + remote=remote, + apply=True, + push=False, + ) + payload = _known_or_ambiguous_result( + result, + success={"tagged", "noop"}, + operation="release tag creation", + ) + try: + receipt = repository_state_receipt( + repo=repo, + target_tag=f"v{version.removeprefix('v')}", + workspace_root=workspace_root, + ) + if receipt["tag_object"] is None: + raise ReleaseExecutionBlocked( + "Tag executor returned success without a verifiable annotated " + "tag object." + ) + _require_same_repository_base(receipt, expected_receipt) + except ReleaseExecutionBlocked as exc: + raise ReleaseExecutionAmbiguous( + "The tag executor reported success, but its post-effect repository " + "receipt could not be verified; reconcile the local tag." + ) from exc + return payload, receipt + if spec.kind == "push": + result = tag_repositories( + repos=(repo,), + repo_versions={repo: version}, + workspace_root=workspace_root, + remote=remote, + apply=True, + push=True, + ) + payload = _known_or_ambiguous_result( + result, + success={"published"}, + operation="atomic release tag publication", + ) + try: + receipt = verified_repository_publication_receipt( + repo=repo, + version=version, + workspace_root=workspace_root, + expected_receipt=expected_receipt, + ) + except ReleaseExecutionBlocked as exc: + raise ReleaseExecutionAmbiguous( + "The push executor reported success, but its post-effect remote " + "receipt could not be verified; reconcile the local and remote tag." + ) from exc + return payload, receipt + raise ReleaseExecutionBlocked("No repository executor matches this plan step.") + + +def repository_preflight( + *, repo: str, version: str, workspace_root: Path +) -> tuple[dict[str, Any], dict[str, Any]]: + specs = {item.name: item for item in load_repository_specs(include_website=False)} + repository = specs.get(repo) + if repository is None: + raise ReleaseExecutionBlocked("Repository is not registered.") + path = resolve_repo_path(repository, workspace_root) + _require_operator_checkout(path) + snapshot = collect_repository_snapshot( + repository, + workspace_root=workspace_root, + target_tag=f"v{version.removeprefix('v')}", + online=False, + ) + if not snapshot.exists or not snapshot.is_git: + raise ReleaseExecutionBlocked("Repository is missing or is not a Git checkout.") + if snapshot.safe_directory_required: + raise ReleaseExecutionBlocked("Git safe.directory approval is required.") + if not snapshot.has_head or not snapshot.branch: + raise ReleaseExecutionBlocked("Repository has no named-branch HEAD.") + tag = f"v{version.removeprefix('v')}" + receipt = repository_state_receipt( + repo=repo, + target_tag=tag, + workspace_root=workspace_root, + ) + return { + "status": "inspected", + "repo": repo, + "head": receipt["head"], + "branch": receipt["branch"], + "ahead": int(snapshot.ahead or 0), + "behind": int(snapshot.behind or 0), + "dirty_count": len(snapshot.dirty_entries), + "target_tag": tag, + "local_tag_commit": ref_commit( + resolve_repo_path(repository, workspace_root), f"refs/tags/{tag}" + ), + }, receipt + + +def repository_state_receipt( + *, + repo: str, + target_tag: str, + workspace_root: Path, + include_website: bool = False, +) -> dict[str, Any]: + """Capture only bounded repository identity, never a credential-bearing URL.""" + + specs = { + item.name: item + for item in load_repository_specs(include_website=include_website) + } + repository = specs.get(repo) + if repository is None: + raise ReleaseExecutionBlocked("Repository is not registered.") + path = resolve_repo_path(repository, workspace_root) + _require_operator_checkout(path) + snapshot = collect_repository_snapshot( + repository, + workspace_root=workspace_root, + target_tag=target_tag or None, + online=False, + ) + if not snapshot.exists or not snapshot.is_git: + raise ReleaseExecutionBlocked("Repository is missing or is not a Git checkout.") + if snapshot.safe_directory_required: + raise ReleaseExecutionBlocked("Git safe.directory approval is required.") + if not snapshot.has_head or not snapshot.branch: + raise ReleaseExecutionBlocked("Repository has no named-branch HEAD.") + head = git_text(path, "rev-parse", "--verify", "HEAD") + if len(head) not in {40, 64}: + raise ReleaseExecutionBlocked("Repository HEAD cannot be bound safely.") + fetch_urls = _bounded_remote_urls(path, push=False) + push_urls = _bounded_remote_urls(path, push=True) + expected_remote = repository.remote.strip() + if fetch_urls != (expected_remote,) or push_urls != (expected_remote,): + raise ReleaseExecutionBlocked( + "Repository origin does not match its registered release remote." + ) + tag_object = ( + git_text(path, "rev-parse", "--verify", f"refs/tags/{target_tag}") + if target_tag + else None + ) or None + if tag_object is not None and len(tag_object) not in {40, 64}: + raise ReleaseExecutionBlocked("Repository tag object cannot be bound safely.") + if tag_object is not None and git_text( + path, "cat-file", "-t", f"refs/tags/{target_tag}" + ) != "tag": + raise ReleaseExecutionBlocked( + "Existing release tag is not an annotated tag object." + ) + return { + "kind": "repository_state", + "repo": repo, + "head": head, + "branch": snapshot.branch, + "remote": DURABLE_REMOTE, + "remote_sha256": hashlib.sha256( + json.dumps( + {"fetch": fetch_urls, "push": push_urls}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + ).hexdigest(), + "worktree_clean": not snapshot.dirty_entries, + "target_tag": target_tag, + "tag_object": tag_object, + } + + +def _require_operator_checkout(path: Path) -> None: + _require_trusted_ancestor_chain(path) + required = ( + (path, "repository", True), + (path / ".git", "Git metadata", True), + (path / ".git" / "config", "Git configuration", False), + (path / ".git" / "HEAD", "Git HEAD", False), + (path / ".git" / "objects", "Git object database", True), + (path / ".git" / "refs", "Git references", True), + ) + optional = ( + (path / ".git" / "index", "Git index", False), + (path / ".git" / "packed-refs", "packed Git references", False), + ) + for candidate, label, directory in (*required, *optional): + try: + metadata = candidate.lstat() + except FileNotFoundError: + if (candidate, label, directory) in optional: + continue + raise ReleaseExecutionBlocked( + f"Release {label} cannot be inspected safely." + ) from None + except OSError as exc: + raise ReleaseExecutionBlocked( + f"Release {label} cannot be inspected safely." + ) from exc + expected_type = stat.S_ISDIR if directory else stat.S_ISREG + if stat.S_ISLNK(metadata.st_mode) or not expected_type(metadata.st_mode): + raise ReleaseExecutionBlocked( + f"Release {label} must be a real operator-controlled path." + ) + if hasattr(os, "geteuid") and metadata.st_uid != os.geteuid(): + raise ReleaseExecutionBlocked( + f"Release {label} is not owned by the console operator." + ) + if metadata.st_mode & 0o022: + raise ReleaseExecutionBlocked( + f"Release {label} is group/world writable." + ) + _require_trusted_git_tree(path / ".git" / "refs") + _require_trusted_git_tree(path / ".git" / "objects") + if any( + candidate.exists() + for candidate in ( + path / ".git" / "objects" / "info" / "alternates", + path / ".git" / "info" / "grafts", + ) + ): + raise ReleaseExecutionBlocked( + "Release Git object alternates and grafts are not permitted." + ) + _require_trusted_tracked_worktree(path) + + +def _require_trusted_git_tree(root: Path) -> None: + """Boundedly inspect nested authority-bearing Git metadata.""" + + observed = 0 + pending = [root] + while pending: + parent = pending.pop() + _require_trusted_directory(parent, label="Git metadata directory") + try: + entries = tuple(os.scandir(parent)) + except OSError as exc: + raise ReleaseExecutionBlocked( + "Nested Git metadata cannot be inspected safely." + ) from exc + for entry in entries: + observed += 1 + if observed > MAX_TRUSTED_GIT_ENTRIES: + raise ReleaseExecutionBlocked( + "Nested Git metadata exceeds its trust-validation limit." + ) + candidate = Path(entry.path) + try: + metadata = candidate.lstat() + except OSError as exc: + raise ReleaseExecutionBlocked( + "Nested Git metadata changed during trust validation." + ) from exc + if stat.S_ISDIR(metadata.st_mode): + pending.append(candidate) + elif stat.S_ISREG(metadata.st_mode): + _require_trusted_regular_file( + candidate, + label="Git metadata file", + ) + else: + raise ReleaseExecutionBlocked( + "Nested Git metadata contains a symlink or special file." + ) + + +def _require_trusted_tracked_worktree(path: Path) -> None: + """Reject tracked metadata inputs that another local user can race.""" + + result = git(path, "ls-files", "-z", timeout=30) + encoded = result.stdout.encode("utf-8", errors="strict") + if result.returncode != 0 or len(encoded) > 16 * 1024 * 1024: + raise ReleaseExecutionBlocked( + "Tracked release worktree paths cannot be inspected safely." + ) + names = [name for name in result.stdout.split("\0") if name] + if len(names) > 100_000: + raise ReleaseExecutionBlocked( + "Tracked release worktree exceeds its trust-validation limit." + ) + checked_directories: set[Path] = {path} + for name in names: + relative = Path(name) + if ( + relative.is_absolute() + or not relative.parts + or any(part in {"", ".", ".."} for part in relative.parts) + ): + raise ReleaseExecutionBlocked( + "Tracked release worktree contains a non-canonical path." + ) + candidate = path / relative + pending_directories: list[Path] = [] + parent = candidate.parent + while parent != path: + if path not in parent.parents: + raise ReleaseExecutionBlocked( + "Tracked release worktree path leaves its repository." + ) + if parent not in checked_directories: + pending_directories.append(parent) + parent = parent.parent + for directory in reversed(pending_directories): + _require_trusted_directory( + directory, + label="tracked repository directory", + ) + checked_directories.add(directory) + try: + metadata = candidate.lstat() + except OSError as exc: + raise ReleaseExecutionBlocked( + "Tracked release worktree changed during trust validation." + ) from exc + if ( + stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o022 + ): + raise ReleaseExecutionBlocked( + "Tracked release files must be operator-owned, non-writable regular files." + ) + + +def _require_trusted_ancestor_chain(path: Path) -> None: + """Reject replaceable path ancestors; root/current owners are authorities.""" + + resolved = path.absolute() + for ancestor in resolved.parents: + try: + metadata = ancestor.lstat() + except OSError as exc: + raise ReleaseExecutionBlocked( + "Release path ancestry cannot be inspected safely." + ) from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): + raise ReleaseExecutionBlocked( + "Release path ancestry must contain only real directories." + ) + if hasattr(os, "geteuid") and metadata.st_uid not in {0, os.geteuid()}: + raise ReleaseExecutionBlocked( + "Release path ancestry is not owned by root or the console operator." + ) + if metadata.st_mode & 0o022: + raise ReleaseExecutionBlocked( + "Release path ancestry is group/world writable." + ) + + +def _require_trusted_directory(path: Path, *, label: str) -> None: + try: + metadata = path.lstat() + except OSError as exc: + raise ReleaseExecutionBlocked(f"{label.capitalize()} is unavailable.") from exc + if ( + stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISDIR(metadata.st_mode) + or (hasattr(os, "geteuid") and metadata.st_uid != os.geteuid()) + or metadata.st_mode & 0o022 + ): + raise ReleaseExecutionBlocked( + f"{label.capitalize()} must be an operator-owned, non-writable real directory." + ) + + +def _require_trusted_regular_file(path: Path, *, label: str) -> None: + try: + metadata = path.lstat() + except OSError as exc: + raise ReleaseExecutionBlocked(f"{label.capitalize()} is unavailable.") from exc + if ( + stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISREG(metadata.st_mode) + or (hasattr(os, "geteuid") and metadata.st_uid != os.geteuid()) + or metadata.st_mode & 0o022 + ): + raise ReleaseExecutionBlocked( + f"{label.capitalize()} must be an operator-owned, non-writable regular file." + ) + + +def _require_trusted_tree(root: Path) -> None: + _require_trusted_directory(root, label="release tooling directory") + observed = 0 + for current, directory_names, file_names in os.walk(root, followlinks=False): + current_path = Path(current) + _require_trusted_directory(current_path, label="release tooling directory") + for name in (*directory_names, *file_names): + observed += 1 + if observed > MAX_TRUSTED_RUNTIME_ENTRIES: + raise ReleaseExecutionBlocked( + "Release tooling tree exceeds its trust-validation limit." + ) + child = current_path / name + metadata = child.lstat() + if stat.S_ISDIR(metadata.st_mode): + _require_trusted_directory(child, label="release tooling directory") + elif stat.S_ISREG(metadata.st_mode): + _require_trusted_regular_file(child, label="release tooling file") + else: + raise ReleaseExecutionBlocked( + "Release tooling tree contains a symlink or non-file member." + ) + + +def _bounded_remote_urls(path: Path, *, push: bool) -> tuple[str, ...]: + arguments = ["remote", "get-url", "--all"] + if push: + arguments.append("--push") + arguments.append(DURABLE_REMOTE) + result = git(path, *arguments) + encoded = result.stdout.encode("utf-8", errors="strict") + lines = tuple(line.strip() for line in result.stdout.splitlines() if line.strip()) + if ( + result.returncode != 0 + or not lines + or len(lines) > 16 + or len(encoded) > MAX_REMOTE_METADATA_BYTES + ): + kind = "push" if push else "fetch" + raise ReleaseExecutionBlocked( + f"Repository origin {kind} URLs cannot be bound safely." + ) + return lines + + +def _remote_branch_commit(path: Path, *, branch: str) -> str | None: + result = git( + path, + "ls-remote", + "--heads", + DURABLE_REMOTE, + f"refs/heads/{branch}", + timeout=15, + ) + rows = [line.split("\t", 1) for line in result.stdout.splitlines()] + expected_ref = f"refs/heads/{branch}" + if ( + result.returncode != 0 + or len(rows) != 1 + or len(rows[0]) != 2 + or rows[0][1] != expected_ref + or len(rows[0][0]) not in {40, 64} + ): + return None + return rows[0][0] + + +def verify_repository_step_precondition( + *, + spec: ExecutorSpec, + plan_step: dict[str, Any], + version: str, + workspace_root: Path, + expected_receipt: dict[str, Any] | None, +) -> dict[str, Any]: + if spec.kind not in {"tag", "push"}: + raise ReleaseExecutionBlocked("Repository precondition is unsupported.") + if not isinstance(expected_receipt, dict): + raise ReleaseExecutionBlocked( + "Repository mutation requires the preceding durable state receipt." + ) + current = repository_state_receipt( + repo=str(plan_step["repo"]), + target_tag=f"v{version.removeprefix('v')}", + workspace_root=workspace_root, + ) + _require_receipt_match(current, expected_receipt, operation=spec.kind) + if not current["worktree_clean"]: + raise ReleaseExecutionBlocked( + "Repository worktree changed after the run was frozen; create a new run." + ) + if spec.kind == "push" and current["tag_object"] is None: + raise ReleaseExecutionBlocked("Tag publication requires an annotated local tag.") + return current + + +def verify_repository_preflight_binding( + *, plan_step: dict[str, Any], version: str, workspace_root: Path +) -> dict[str, Any]: + current = repository_state_receipt( + repo=str(plan_step["repo"]), + target_tag=f"v{version.removeprefix('v')}", + workspace_root=workspace_root, + ) + _require_receipt_match( + current, + plan_step.get("source_binding"), + operation="repository preflight", + ) + return current + + +def verify_catalog_publication_precondition( + *, plan_step: dict[str, Any], workspace_root: Path +) -> dict[str, Any]: + current = repository_state_receipt( + repo="addideas-govoplan-website", + target_tag="", + workspace_root=workspace_root, + include_website=True, + ) + _require_receipt_match( + current, + plan_step.get("source_binding"), + operation="catalog publication", + ) + if not current["worktree_clean"]: + raise ReleaseExecutionBlocked( + "Website worktree changed after the run was frozen; create a new run." + ) + return current + + +def reconciled_repository_receipt( + *, + spec: ExecutorSpec, + plan_step: dict[str, Any], + version: str, + workspace_root: Path, + expected_receipt: dict[str, Any] | None, +) -> dict[str, Any]: + """Independently prove the repository effect before advancing reconciliation.""" + + if spec.kind == "tag": + if not isinstance(expected_receipt, dict): + raise ReleaseExecutionBlocked( + "Tag reconciliation requires the preceding repository receipt." + ) + receipt = repository_state_receipt( + repo=str(plan_step["repo"]), + target_tag=f"v{version.removeprefix('v')}", + workspace_root=workspace_root, + ) + _require_same_repository_base(receipt, expected_receipt) + if receipt["tag_object"] is None: + raise ReleaseExecutionBlocked( + "The annotated local tag effect could not be verified." + ) + return receipt + if spec.kind == "push": + return verified_repository_publication_receipt( + repo=str(plan_step["repo"]), + version=version, + workspace_root=workspace_root, + expected_receipt=expected_receipt, + ) + raise ReleaseExecutionBlocked( + "This repository effect has no automatic success reconciliation proof." + ) + + +def verified_repository_publication_receipt( + *, + repo: str, + version: str, + workspace_root: Path, + expected_receipt: dict[str, Any] | None, +) -> dict[str, Any]: + if not isinstance(expected_receipt, dict): + raise ReleaseExecutionBlocked( + "Tag publication requires the preceding tag receipt." + ) + tag = f"v{version.removeprefix('v')}" + receipt = repository_state_receipt( + repo=repo, + target_tag=tag, + workspace_root=workspace_root, + ) + _require_receipt_match(receipt, expected_receipt, operation="tag publication") + specs = {item.name: item for item in load_repository_specs(include_website=False)} + path = resolve_repo_path(specs[repo], workspace_root) + remote = remote_tag_commit(path, remote=DURABLE_REMOTE, tag=tag) + if remote.error: + raise ReleaseExecutionBlocked(remote.error) + if ( + not remote.annotated + or remote.commit != receipt["head"] + or remote.tag_object != receipt["tag_object"] + ): + raise ReleaseExecutionBlocked( + "Remote annotated tag does not match the receipt-bound local tag." + ) + if _remote_branch_commit(path, branch=str(receipt["branch"])) != receipt["head"]: + raise ReleaseExecutionBlocked( + "Remote branch does not match the receipt-bound atomic publication." + ) + return receipt + + +def _require_receipt_match( + current: dict[str, Any], expected: object, *, operation: str +) -> None: + if current != expected: + raise ReleaseExecutionBlocked( + f"Repository state drifted before {operation}; create a new release run." + ) + + +def _require_same_repository_base( + current: dict[str, Any], expected: dict[str, Any] | None +) -> None: + if not isinstance(expected, dict): + raise ReleaseExecutionBlocked("Preceding repository receipt is unavailable.") + ignored = {"tag_object"} + if any( + current.get(key) != value + for key, value in expected.items() + if key not in ignored + ): + raise ReleaseExecutionBlocked( + "Repository identity changed while creating the release tag." + ) + + +def generate_catalog_candidate( + *, + candidate_root: Path, + candidate_id: str, + channel: str, + repo_versions: dict[str, str], + workspace_root: Path, + signing_keys: tuple[str, ...], + remote: str, + check_public: bool, + source_receipts: dict[str, dict[str, Any]], + base_catalog: Path, + base_keyring: Path, +) -> tuple[dict[str, Any], CandidateArtifactReceipt]: + require_trusted_release_runtime(workspace_root=workspace_root) + if not signing_keys or len(signing_keys) > MAX_SIGNING_KEYS: + raise ReleaseExecutionBlocked( + "Durable catalog generation requires one to eight configured signing keys." + ) + output = candidate_output_path(candidate_root, candidate_id) + if output.exists(): + raise ReleaseExecutionAmbiguous( + "The server-issued candidate handle already exists; reconcile before reuse." + ) + try: + frozen_sources = { + repo: verify_frozen_repository_tag_receipt( + receipt=source_receipts.get(repo), + workspace_root=workspace_root, + ) + for repo in repo_versions + } + with tempfile.TemporaryDirectory( + prefix="govoplan-release-synthesis-", + dir=candidate_root, + ) as synthesis_dir: + synthesis_workspace = Path(synthesis_dir) + authenticated_base, snapshot_catalog, snapshot_keyring = ( + _snapshot_authenticated_catalog_base( + base_catalog=base_catalog, + base_keyring=base_keyring, + channel=channel, + signing_keys=signing_keys, + workspace_root=workspace_root, + snapshot_root=synthesis_workspace / ".authenticated-base", + ) + ) + base_selection = catalog_source_selection(authenticated_base) + source_versions = dict(base_selection.all_versions) + source_versions.update(repo_versions) + if ( + not source_versions + or len(source_versions) > MAX_CATALOG_SOURCE_REPOSITORIES + ): + raise ReleaseExecutionBlocked( + "Authenticated catalog has no bounded source repository set." + ) + cloned_sources = _clone_catalog_sources( + source_versions=source_versions, + repo_versions=repo_versions, + source_receipts=frozen_sources, + workspace_root=workspace_root, + destination=synthesis_workspace, + ) + python_artifacts = build_selected_wheels( + repo_versions=repo_versions, + workspace_root=synthesis_workspace, + candidate_output=output, + source_receipts=frozen_sources, + frozen_sources=cloned_sources, + ) + candidate = build_selective_catalog_candidate( + repo_versions=repo_versions, + channel=channel, + workspace_root=synthesis_workspace, + output_dir=output, + signing_keys=signing_keys, + base_catalog=snapshot_catalog, + base_keyring=snapshot_keyring, + source_remote=remote, + check_public=check_public, + python_artifacts=python_artifacts, + frozen_source_provenance={ + repo: { + "commit_sha": str(receipt["head"]), + "tag_object_sha": str(receipt["tag_object"]), + } + for repo, receipt in frozen_sources.items() + }, + ) + harden_private_candidate_tree(output) + payload = _read_generated_catalog(output, channel=channel) + identity_issues = selected_artifact_identity_issues(payload) + if identity_issues: + raise ReleaseExecutionBlocked( + "Generated catalog lacks selected release artifact identities: " + + "; ".join(identity_issues) + ) + if getattr(candidate, "status", None) != "ready": + raise ReleaseExecutionBlocked( + "Generated catalog candidate did not pass signature validation." + ) + receipt = issue_candidate_receipt( + root=candidate_root, + candidate_id=candidate_id, + channel=channel, + ) + return to_jsonable(candidate), receipt + except ReleaseExecutionAmbiguous: + raise + except ReleaseExecutionError: + _remove_partial_candidate(output) + raise + except (OSError, subprocess.SubprocessError, ValueError) as exc: + _remove_partial_candidate(output) + raise ReleaseExecutionBlocked( + f"Catalog candidate generation failed before a trusted receipt: {exc}" + ) from exc + + +def validated_candidate_receipt( + *, candidate_root: Path, candidate_id: str, channel: str +) -> CandidateArtifactReceipt: + candidate = candidate_output_path(candidate_root, candidate_id) + payload = _read_generated_catalog(candidate, channel=channel) + issues = selected_artifact_identity_issues(payload) + if issues: + raise ReleaseExecutionBlocked( + "Candidate lacks selected release artifact identities: " + + "; ".join(issues) + ) + return issue_candidate_receipt( + root=candidate_root, + candidate_id=candidate_id, + channel=channel, + ) + + +def _snapshot_authenticated_catalog_base( + *, + base_catalog: Path, + base_keyring: Path, + channel: str, + signing_keys: tuple[str, ...], + workspace_root: Path, + snapshot_root: Path, +) -> tuple[dict[str, Any], Path, Path]: + """Authenticate once, then pin the exact JSON pair in private storage.""" + + parsed_keys = tuple(parse_signing_key(value) for value in signing_keys) + authenticated = load_authenticated_catalog_base( + base_catalog=base_catalog, + base_keyring=base_keyring, + web_root=website_root(workspace_root), + channel=channel, + public_base_url=DEFAULT_PUBLIC_BASE_URL, + signer_public_keys=configured_signer_public_keys(parsed_keys), + ) + snapshot_root.mkdir(mode=0o700, parents=False, exist_ok=False) + os.chmod(snapshot_root, 0o700, follow_symlinks=False) + catalog_path = snapshot_root / f"{validate_release_channel(channel)}.json" + keyring_path = snapshot_root / "keyring.json" + _write_private_json_snapshot(catalog_path, authenticated.catalog) + _write_private_json_snapshot(keyring_path, authenticated.keyring) + return authenticated.catalog, catalog_path, keyring_path + + +def _write_private_json_snapshot(path: Path, payload: object) -> None: + encoded = ( + json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=True) + "\n" + ).encode("utf-8") + if len(encoded) > 16 * 1024 * 1024: + raise ReleaseExecutionBlocked( + "Authenticated catalog trust material exceeds its size limit." + ) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=False) as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + finally: + os.close(descriptor) + + +def build_selected_wheels( + *, + repo_versions: dict[str, str], + workspace_root: Path, + candidate_output: Path, + source_receipts: dict[str, dict[str, Any]], + frozen_sources: dict[str, Path] | None = None, +) -> dict[str, Path]: + artifacts = candidate_output / "artifacts" + artifacts.mkdir(parents=True, exist_ok=False) + specs = {item.name: item for item in load_repository_specs(include_website=False)} + if frozen_sources is None: + with tempfile.TemporaryDirectory( + prefix="govoplan-release-build-" + ) as temp_dir: + cloned = _clone_frozen_sources( + repo_versions=repo_versions, + source_receipts=source_receipts, + workspace_root=workspace_root, + destination=Path(temp_dir), + ) + return _build_selected_wheels_from_sources( + repo_versions=repo_versions, + candidate_output=candidate_output, + source_receipts=source_receipts, + frozen_sources=cloned, + specs=specs, + workspace_root=workspace_root, + ) + return _build_selected_wheels_from_sources( + repo_versions=repo_versions, + candidate_output=candidate_output, + source_receipts=source_receipts, + frozen_sources=frozen_sources, + specs=specs, + workspace_root=workspace_root, + ) + + +def _build_selected_wheels_from_sources( + *, + repo_versions: dict[str, str], + candidate_output: Path, + source_receipts: dict[str, dict[str, Any]], + frozen_sources: dict[str, Path], + specs: dict[str, Any], + workspace_root: Path, +) -> dict[str, Path]: + artifacts = candidate_output / "artifacts" + if not artifacts.exists(): + artifacts.mkdir(parents=True, exist_ok=False) + result: dict[str, Path] = {} + for repo in sorted(repo_versions): + repository = specs.get(repo) + receipt = source_receipts.get(repo) + frozen_path = frozen_sources.get(repo) + if ( + repository is None + or not isinstance(receipt, dict) + or frozen_path is None + ): + raise ReleaseExecutionBlocked( + f"Repository {repo} has no verified frozen source receipt." + ) + project_name = python_project_name(frozen_path) + if project_name is None: + continue + staging = Path( + tempfile.mkdtemp(prefix=f".{repo}-build-", dir=artifacts) + ) + os.chmod(staging, 0o700, follow_symlinks=False) + command = isolated_wheel_builder_command( + source=frozen_path, + output=staging, + ) + if _run_isolated_wheel_builder(command) != 0: + raise ReleaseExecutionBlocked( + f"Server-owned wheel staging failed for {repo}: " + "the isolated no-network worker did not complete successfully" + ) + created = _single_isolated_wheel(staging) + destination = artifacts / created.name + if destination.exists() or destination.is_symlink(): + raise ReleaseExecutionBlocked( + f"Isolated wheel output name collides for {repo}." + ) + _copy_isolated_wheel(created, destination) + identity = inspect_python_wheel(destination) + if ( + identity.package_name != normalize_package_name(project_name) + or identity.package_version != repo_versions[repo].removeprefix("v") + ): + destination.unlink(missing_ok=True) + raise ReleaseExecutionBlocked( + f"Isolated wheel identity does not match selected source {repo}." + ) + os.chmod(destination, 0o600, follow_symlinks=False) + created.unlink() + staging.rmdir() + result[repo] = destination + return result + + +def isolated_wheel_builder_command(*, source: Path, output: Path) -> tuple[str, ...]: + """Build one absolute bubblewrap command with no host data or network view.""" + + launcher = isolated_builder_launcher() + return ( + *launcher, + "--unshare-all", + "--die-with-parent", + "--new-session", + "--cap-drop", + "ALL", + "--ro-bind", + "/usr", + "/usr", + "--symlink", + "usr/bin", + "/bin", + "--symlink", + "usr/lib", + "/lib", + "--symlink", + "usr/lib64", + "/lib64", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--dir", + "/tmp/home", + "--ro-bind", + str(source.absolute()), + "/src", + "--bind", + str(output.absolute()), + "/out", + "--chdir", + "/tmp", + "--clearenv", + "--setenv", + "PATH", + "/usr/bin", + "--setenv", + "HOME", + "/tmp/home", + "--setenv", + "TMPDIR", + "/tmp", + "--setenv", + "PYTHONNOUSERSITE", + "1", + "--setenv", + "PYTHONHASHSEED", + "0", + "--setenv", + "SOURCE_DATE_EPOCH", + "315532800", + "--setenv", + "PIP_CONFIG_FILE", + "/dev/null", + "--setenv", + "PIP_DISABLE_PIP_VERSION_CHECK", + "1", + "--setenv", + "PIP_NO_CACHE_DIR", + "1", + "--setenv", + "PIP_NO_INDEX", + "1", + "/usr/bin/prlimit", + f"--fsize={MAX_WHEEL_BYTES}", + "--cpu=600", + "--nproc=256", + "--as=4294967296", + "--", + "/usr/bin/python3", + "-m", + "pip", + "wheel", + "--no-deps", + "--no-build-isolation", + "--disable-pip-version-check", + "--no-index", + "--wheel-dir", + "/out", + "/src", + ) + + +def isolated_builder_launcher() -> tuple[str, ...]: + flatpak_spawn = Path("/usr/bin/flatpak-spawn") + direct_bwrap = Path("/usr/bin/bwrap") + if _trusted_system_executable(flatpak_spawn): + return (str(flatpak_spawn), "--host", "/usr/bin/bwrap") + if _trusted_system_executable(direct_bwrap): + return (str(direct_bwrap),) + raise ReleaseExecutionBlocked( + "Catalog generation requires a trusted bubblewrap worker; no supported " + "isolated builder is available." + ) + + +def _trusted_system_executable(path: Path) -> bool: + try: + metadata = path.lstat() + except OSError: + return False + return ( + stat.S_ISREG(metadata.st_mode) + and metadata.st_uid == 0 + and metadata.st_mode & 0o022 == 0 + and os.access(path, os.X_OK) + ) + + +def _run_isolated_wheel_builder(command: tuple[str, ...]) -> int: + process = subprocess.Popen( # noqa: S603 - absolute trusted launcher only. + command, + cwd=Path("/"), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + start_new_session=True, + ) + try: + return process.wait(timeout=620) + except subprocess.TimeoutExpired as exc: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + raise ReleaseExecutionBlocked( + "Isolated wheel builder exceeded its bounded execution time." + ) from exc + + +def _single_isolated_wheel(output: Path) -> Path: + entries: list[Path] = [] + with os.scandir(output) as iterator: + for entry in iterator: + entries.append(Path(entry.path)) + if len(entries) > 1: + break + if len(entries) != 1: + raise ReleaseExecutionBlocked( + "Isolated builder must return exactly one wheel and no extra output." + ) + wheel = entries[0] + metadata = wheel.lstat() + if ( + stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISREG(metadata.st_mode) + or wheel.suffix != ".whl" + or metadata.st_uid != os.geteuid() + or metadata.st_nlink != 1 + or not 0 < metadata.st_size <= MAX_WHEEL_BYTES + ): + raise ReleaseExecutionBlocked( + "Isolated builder returned an unsafe or oversized wheel." + ) + return wheel + + +def _copy_isolated_wheel(source: Path, destination: Path) -> None: + """Copy worker output through pinned descriptors into a fresh candidate file.""" + + source_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + destination_flags = ( + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_NOFOLLOW", 0) + ) + source_descriptor = os.open(source, source_flags) + destination_descriptor = -1 + try: + metadata = os.fstat(source_descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_nlink != 1 + or not 0 < metadata.st_size <= MAX_WHEEL_BYTES + ): + raise ReleaseExecutionBlocked( + "Isolated builder wheel changed before trusted staging." + ) + destination_descriptor = os.open( + destination, + destination_flags, + 0o600, + ) + remaining = metadata.st_size + while remaining: + chunk = os.read(source_descriptor, min(1024 * 1024, remaining)) + if not chunk: + raise ReleaseExecutionBlocked( + "Isolated builder wheel was truncated during trusted staging." + ) + view = memoryview(chunk) + while view: + written = os.write(destination_descriptor, view) + if written <= 0: + raise ReleaseExecutionBlocked( + "Isolated builder wheel could not be staged safely." + ) + view = view[written:] + remaining -= len(chunk) + if os.read(source_descriptor, 1): + raise ReleaseExecutionBlocked( + "Isolated builder wheel grew beyond its verified size." + ) + os.fchmod(destination_descriptor, 0o600) + os.fsync(destination_descriptor) + except BaseException: + if destination_descriptor >= 0: + os.close(destination_descriptor) + destination_descriptor = -1 + destination.unlink(missing_ok=True) + raise + finally: + os.close(source_descriptor) + if destination_descriptor >= 0: + os.close(destination_descriptor) + directory_descriptor = os.open( + destination.parent, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0), + ) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + + +def _clone_frozen_sources( + *, + repo_versions: dict[str, str], + source_receipts: dict[str, dict[str, Any]], + workspace_root: Path, + destination: Path, +) -> dict[str, Path]: + specs = {item.name: item for item in load_repository_specs(include_website=False)} + result: dict[str, Path] = {} + for repo in sorted(repo_versions): + repository = specs.get(repo) + receipt = source_receipts.get(repo) + if repository is None or not isinstance(receipt, dict): + raise ReleaseExecutionBlocked( + f"Repository {repo} has no verified frozen source receipt." + ) + # Recheck the private local receipt and registered origin immediately + # before fetching the immutable source tag. + verify_frozen_repository_tag_receipt( + receipt=receipt, + workspace_root=workspace_root, + ) + result[repo] = _checkout_frozen_source( + repo=repo, + source_remote=repository.remote, + commit=str(receipt["head"]), + tag=str(receipt["target_tag"]), + tag_object=str(receipt["tag_object"]), + source_root=destination, + ) + return result + + +def _clone_catalog_sources( + *, + source_versions: dict[str, str], + repo_versions: dict[str, str], + source_receipts: dict[str, dict[str, Any]], + workspace_root: Path, + destination: Path, +) -> dict[str, Path]: + """Clone every authenticated catalog source, overriding selected exact receipts.""" + + specs = {item.name: item for item in load_repository_specs(include_website=False)} + result: dict[str, Path] = {} + for repo, version in sorted(source_versions.items()): + repository = specs.get(repo) + if repository is None: + raise ReleaseExecutionBlocked( + f"Authenticated catalog source {repo} is not registered." + ) + receipt = source_receipts.get(repo) if repo in repo_versions else None + if repo in repo_versions: + if not isinstance(receipt, dict): + raise ReleaseExecutionBlocked( + f"Selected repository {repo} has no verified source receipt." + ) + verify_frozen_repository_tag_receipt( + receipt=receipt, + workspace_root=workspace_root, + ) + expected_tag = f"v{version.removeprefix('v')}" + if receipt.get("target_tag") != expected_tag: + raise ReleaseExecutionBlocked( + f"Selected repository {repo} receipt has the wrong release tag." + ) + result[repo] = _checkout_frozen_source( + repo=repo, + source_remote=repository.remote, + commit=str(receipt["head"]), + tag=expected_tag, + tag_object=str(receipt["tag_object"]), + source_root=destination, + ) + else: + result[repo] = _checkout_registered_source_tag( + repo=repo, + source_remote=repository.remote, + tag=f"v{version.removeprefix('v')}", + source_root=destination, + ) + return result + + +def verify_frozen_repository_tag_receipt( + *, receipt: dict[str, Any] | None, workspace_root: Path +) -> dict[str, Any]: + """Verify a stored local/remote annotated tag without trusting live HEAD.""" + + if not isinstance(receipt, dict) or receipt.get("kind") != "repository_state": + raise ReleaseExecutionBlocked( + "Catalog generation requires each source push's durable receipt." + ) + repo = receipt.get("repo") + tag = receipt.get("target_tag") + tag_object = receipt.get("tag_object") + head = receipt.get("head") + if not all(isinstance(value, str) and value for value in (repo, tag, tag_object, head)): + raise ReleaseExecutionBlocked("Frozen source receipt is incomplete.") + specs = {item.name: item for item in load_repository_specs(include_website=False)} + repository = specs.get(repo) + if repository is None: + raise ReleaseExecutionBlocked("Frozen source repository is not registered.") + path = resolve_repo_path(repository, workspace_root) + _require_operator_checkout(path) + fetch_urls = _bounded_remote_urls(path, push=False) + push_urls = _bounded_remote_urls(path, push=True) + if fetch_urls != (repository.remote.strip(),) or push_urls != ( + repository.remote.strip(), + ): + raise ReleaseExecutionBlocked( + "Frozen source origin no longer matches its registered release remote." + ) + remote_digest = hashlib.sha256( + json.dumps( + {"fetch": fetch_urls, "push": push_urls}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + ).hexdigest() + if receipt.get("remote") != DURABLE_REMOTE or receipt.get( + "remote_sha256" + ) != remote_digest: + raise ReleaseExecutionBlocked("Frozen source remote binding changed.") + local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") + local_type = git_text(path, "cat-file", "-t", f"refs/tags/{tag}") + local_commit = ref_commit(path, f"refs/tags/{tag}") + remote = remote_tag_commit(path, remote=DURABLE_REMOTE, tag=tag) + if ( + local_type != "tag" + or local_object != tag_object + or local_commit != head + or remote.error + or not remote.annotated + or remote.tag_object != tag_object + or remote.commit != head + ): + raise ReleaseExecutionBlocked( + "Frozen source tag is not the same annotated object locally and on origin." + ) + return dict(receipt) + + +def _checkout_frozen_source( + *, + repo: str, + source_remote: str | Path, + commit: str, + tag: str, + tag_object: str, + source_root: Path, +) -> Path: + destination = source_root / repo + clone = subprocess.run( + ( + "/usr/bin/git", + "clone", + "--quiet", + "--no-checkout", + "--no-hardlinks", + str(source_remote), + str(destination), + ), + cwd=source_root, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=120, + env=_git_release_environment(), + ) + if clone.returncode != 0: + raise ReleaseExecutionBlocked( + f"Frozen source clone failed for {repo}." + ) + cloned_tag_object = git_text( + destination, "rev-parse", "--verify", f"refs/tags/{tag}" + ) + cloned_tag_commit = ref_commit(destination, f"refs/tags/{tag}") + cloned_tag_type = git_text(destination, "cat-file", "-t", f"refs/tags/{tag}") + if ( + cloned_tag_type != "tag" + or cloned_tag_object != tag_object + or cloned_tag_commit != commit + ): + raise ReleaseExecutionBlocked( + f"Registered origin returned a different release tag for {repo}." + ) + checkout = subprocess.run( + ("/usr/bin/git", "checkout", "--quiet", "--detach", commit), + cwd=destination, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=120, + env=_git_release_environment(), + ) + if checkout.returncode != 0 or git_text( + destination, "rev-parse", "--verify", "HEAD" + ) != commit: + raise ReleaseExecutionBlocked( + f"Frozen source checkout failed for {repo}: " + f"{_compact_output(checkout.stderr) or 'commit identity mismatch'}" + ) + return destination + + +def _checkout_registered_source_tag( + *, + repo: str, + source_remote: str | Path, + tag: str, + source_root: Path, +) -> Path: + """Clone one unchanged authenticated-base source at its annotated origin tag.""" + + destination = source_root / repo + clone = subprocess.run( + ( + "/usr/bin/git", + "clone", + "--quiet", + "--no-checkout", + "--no-hardlinks", + str(source_remote), + str(destination), + ), + cwd=source_root, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=120, + env=_git_release_environment(), + ) + if clone.returncode != 0: + raise ReleaseExecutionBlocked( + f"Authenticated catalog source clone failed for {repo}." + ) + tag_object = git_text(destination, "rev-parse", "--verify", f"refs/tags/{tag}") + tag_commit = ref_commit(destination, f"refs/tags/{tag}") + if ( + git_text(destination, "cat-file", "-t", f"refs/tags/{tag}") != "tag" + or len(tag_object) not in {40, 64} + or not tag_commit + ): + raise ReleaseExecutionBlocked( + f"Authenticated catalog source tag is not annotated for {repo}." + ) + checkout = subprocess.run( + ("/usr/bin/git", "checkout", "--quiet", "--detach", tag_commit), + cwd=destination, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=120, + env=_git_release_environment(), + ) + if checkout.returncode != 0 or git_text( + destination, "rev-parse", "--verify", "HEAD" + ) != tag_commit: + raise ReleaseExecutionBlocked( + f"Authenticated catalog source checkout failed for {repo}." + ) + return destination + + +def _git_release_environment() -> dict[str, str]: + environment = sanitized_git_environment() + environment.setdefault( + "GIT_SSH_COMMAND", + "ssh -o BatchMode=yes -o ConnectTimeout=8", + ) + return environment + + +def python_project_name(repository: Path) -> str | None: + pyproject = repository / "pyproject.toml" + try: + encoded = pyproject.read_bytes() + except FileNotFoundError: + return None + except OSError as exc: + raise ReleaseExecutionBlocked("Python project metadata cannot be read.") from exc + if len(encoded) > 1024 * 1024: + raise ReleaseExecutionBlocked("Python project metadata exceeds its size limit.") + try: + payload = tomllib.loads(encoded.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + raise ReleaseExecutionBlocked("Python project metadata is malformed.") from exc + project = payload.get("project") + name = project.get("name") if isinstance(project, dict) else None + return name.strip() if isinstance(name, str) and name.strip() else None + + +def publish_received_candidate( + *, + candidate_path: Path, + candidate_receipt: dict[str, Any], + channel: str, + workspace_root: Path, + remote: str, + expected_website_receipt: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + require_trusted_release_runtime(workspace_root=workspace_root) + website_binding = _validated_website_binding(expected_website_receipt) + if remote != DURABLE_REMOTE: + raise ReleaseExecutionBlocked("Catalog publication uses only origin.") + try: + result = publish_catalog_candidate( + candidate_dir=candidate_path, + workspace_root=workspace_root, + channel=channel, + apply=True, + commit=True, + tag=True, + push=True, + remote=remote, + source_remote=remote, + expected_website_head=str(website_binding["head"]), + expected_website_branch=str(website_binding["branch"]), + expected_remote_sha256=str(website_binding["remote_sha256"]), + ) + except Exception as exc: # noqa: BLE001 - partial Git effects are ambiguous. + raise ReleaseExecutionAmbiguous( + f"Catalog publication raised {type(exc).__name__}; reconcile website and remote state." + ) from exc + payload = to_jsonable(result) + status = payload.get("status") if isinstance(payload, dict) else None + if status == "published": + try: + receipt = reconciled_catalog_publication_receipt( + candidate_path=candidate_path, + candidate_receipt=candidate_receipt, + channel=channel, + workspace_root=workspace_root, + remote=remote, + expected_website_receipt=website_binding, + ) + _require_publisher_result_matches_receipt(payload, receipt) + except (ReleaseExecutionBlocked, OSError, RuntimeError, ValueError) as exc: + raise ReleaseExecutionAmbiguous( + "Catalog publisher reported success, but its exact commit, derived " + "files, annotated tag, and origin state could not be proved." + ) from exc + return payload, receipt + if status == "blocked": + raise ReleaseExecutionBlocked(_bounded_result_detail(payload, "catalog publication")) + raise ReleaseExecutionAmbiguous(_bounded_result_detail(payload, "catalog publication")) + + +def reconciled_catalog_publication_receipt( + *, + candidate_path: Path, + candidate_receipt: dict[str, Any], + channel: str, + workspace_root: Path, + remote: str, + expected_website_receipt: dict[str, Any], +) -> dict[str, Any]: + """Independently prove an exact candidate publication after interruption.""" + + require_trusted_release_runtime(workspace_root=workspace_root) + if remote != DURABLE_REMOTE: + raise ReleaseExecutionBlocked("Catalog reconciliation uses only origin.") + website_binding = _validated_website_binding(expected_website_receipt) + candidate_id, expected_catalog_hash = _validated_catalog_candidate_receipt( + candidate_receipt + ) + channel = validate_release_channel(channel) + catalog_payload = _read_generated_catalog(candidate_path, channel=channel) + catalog_hash = canonical_hash(catalog_payload) + if catalog_hash != expected_catalog_hash: + raise ReleaseExecutionBlocked( + "Catalog candidate changed after its durable generator receipt." + ) + keyring_payload = _read_candidate_json( + candidate_path / "keyring.json", + label="candidate keyring", + ) + keyring_hash = canonical_hash(keyring_payload) + release = catalog_payload.get("release") + if ( + not isinstance(release, dict) + or release.get("keyring_sha256") != keyring_hash + ): + raise ReleaseExecutionBlocked( + "Signed catalog does not pin the exact candidate keyring." + ) + artifact_issues = selected_artifact_identity_issues(catalog_payload) + if artifact_issues: + raise ReleaseExecutionBlocked( + "Candidate release artifact identity is incomplete: " + + "; ".join(artifact_issues) + ) + + web_root = website_root(workspace_root) + current = repository_state_receipt( + repo="addideas-govoplan-website", + target_tag="", + workspace_root=workspace_root, + include_website=True, + ) + if ( + current.get("branch") != website_binding["branch"] + or current.get("remote") != website_binding["remote"] + or current.get("remote_sha256") != website_binding["remote_sha256"] + or current.get("worktree_clean") is not True + ): + raise ReleaseExecutionBlocked( + "Website checkout does not match the frozen publication boundary." + ) + frozen_remote = bind_publication_remote( + web_root=web_root, + remote=remote, + registered_remote=registered_website_remote(), + ) + if frozen_remote.sha256 != website_binding["remote_sha256"]: + raise ReleaseExecutionBlocked( + "Website origin changed after the publication plan was frozen." + ) + tag_name = default_tag_name(catalog_payload, channel=channel) + if not tag_name: + raise ReleaseExecutionBlocked( + "Catalog publication tag cannot be reconstructed from the candidate." + ) + identity = remote_publication_identity( + web_root=web_root, + remote_url=frozen_remote.url, + branch=str(website_binding["branch"]), + tag_name=tag_name, + ) + commit = identity["publication_commit_sha"] + tag_object = identity["publication_tag_object_sha"] + if ( + identity["publication_tag_commit_sha"] != commit + or current.get("head") != commit + ): + raise ReleaseExecutionBlocked( + "Remote catalog branch and annotated tag do not identify one local commit." + ) + if ( + git_text(web_root, "cat-file", "-t", f"refs/tags/{tag_name}") != "tag" + or git_text( + web_root, + "rev-parse", + "--verify", + f"refs/tags/{tag_name}", + ) + != tag_object + or ref_commit(web_root, f"refs/tags/{tag_name}") != commit + ): + raise ReleaseExecutionBlocked( + "Local catalog publication tag differs from the origin annotation." + ) + parent_line = git_text(web_root, "rev-list", "--parents", "-n", "1", commit) + if parent_line.split() != [commit, str(website_binding["head"])]: + raise ReleaseExecutionBlocked( + "Catalog publication commit does not have the sole frozen parent." + ) + + previous_keyring = _read_git_json_blob( + web_root, + commit=str(website_binding["head"]), + repository_path="public/catalogs/v1/keyring.json", + label="frozen website keyring", + ) + trusted_keys = trusted_keys_from_keyring(previous_keyring) + if not trusted_keys: + raise ReleaseExecutionBlocked( + "Frozen website commit has no catalog trust anchor." + ) + candidate_keys = trusted_keys_from_keyring(keyring_payload) + for key_id in set(candidate_keys) & set(trusted_keys): + if candidate_keys[key_id] != trusted_keys[key_id]: + raise ReleaseExecutionBlocked( + "Candidate keyring replaces an existing trusted public key." + ) + validation = validate_catalog_payload( + catalog_payload, + require_trusted=True, + approved_channels=(channel,), + trusted_keys=trusted_keys, + ) + if validation.get("valid") is not True: + raise ReleaseExecutionBlocked( + "Candidate does not validate against the frozen website trust anchor." + ) + + expected_blobs, module_root = _expected_catalog_publication_blobs( + web_root=web_root, + catalog_payload=catalog_payload, + keyring_payload=keyring_payload, + channel=channel, + ) + _verify_exact_publication_delta( + web_root=web_root, + frozen_parent=str(website_binding["head"]), + commit=commit, + expected_blobs=expected_blobs, + module_root=module_root, + ) + verify_committed_publication( + web_root=web_root, + commit_sha=commit, + expected_blobs=expected_blobs, + module_root=module_root, + ) + return { + "kind": "catalog_publication", + "candidate_id": candidate_id, + "catalog_sha256": catalog_hash, + "keyring_sha256": keyring_hash, + "publication_commit_sha": commit, + "publication_tag_object_sha": tag_object, + "publication_tag_commit_sha": commit, + "branch": str(website_binding["branch"]), + "tag_name": tag_name, + "remote": remote, + "remote_sha256": str(website_binding["remote_sha256"]), + } + + +def _validated_website_binding(value: object) -> dict[str, Any]: + if ( + not isinstance(value, dict) + or value.get("kind") != "repository_state" + or value.get("repo") != "addideas-govoplan-website" + or value.get("remote") != DURABLE_REMOTE + or value.get("target_tag") != "" + or value.get("tag_object") is not None + or value.get("worktree_clean") is not True + or not isinstance(value.get("head"), str) + or len(value["head"]) not in {40, 64} + or not isinstance(value.get("branch"), str) + or not value["branch"] + or not isinstance(value.get("remote_sha256"), str) + or len(value["remote_sha256"]) != 64 + ): + raise ReleaseExecutionBlocked( + "Catalog publication has no valid frozen website receipt." + ) + return dict(value) + + +def _validated_catalog_candidate_receipt(value: object) -> tuple[str, str]: + if ( + not isinstance(value, dict) + or set(value) != {"kind", "candidate_id", "catalog_sha256"} + or value.get("kind") != "catalog_candidate" + or not isinstance(value.get("candidate_id"), str) + or not value["candidate_id"].startswith("candidate-") + or len(value["candidate_id"]) != 42 + or not isinstance(value.get("catalog_sha256"), str) + or len(value["catalog_sha256"]) != 64 + ): + raise ReleaseExecutionBlocked( + "Catalog publication has no valid generator receipt." + ) + return value["candidate_id"], value["catalog_sha256"] + + +def _expected_catalog_publication_blobs( + *, + web_root: Path, + catalog_payload: dict[str, Any], + keyring_payload: dict[str, Any], + channel: str, +) -> tuple[dict[str, bytes], Path]: + catalog_root = web_root / "public" / "catalogs" / "v1" + module_root = catalog_root / "modules" + payloads = module_directory_payloads( + catalog_payload=catalog_payload, + keyring_payload=keyring_payload, + channel=channel, + public_base_url=catalog_public_base_url(catalog_payload), + ) + expected = { + f"public/catalogs/v1/channels/{channel}.json": _public_json_bytes( + catalog_payload + ), + "public/catalogs/v1/keyring.json": _public_json_bytes(keyring_payload), + } + for relative_path, payload in payloads: + repository_path = (Path("public/catalogs/v1") / relative_path).as_posix() + if repository_path in expected: + raise ReleaseExecutionBlocked( + "Derived catalog publication paths are ambiguous." + ) + expected[repository_path] = _public_json_bytes(payload) + return expected, module_root + + +def _verify_exact_publication_delta( + *, + web_root: Path, + frozen_parent: str, + commit: str, + expected_blobs: dict[str, bytes], + module_root: Path, +) -> None: + """Require the publication commit to change only the derived catalog tree.""" + + try: + module_prefix = module_root.absolute().relative_to(web_root.absolute()).as_posix() + except ValueError as exc: + raise ReleaseExecutionBlocked( + "Catalog module root leaves the website checkout." + ) from exc + tree = git( + web_root, + "ls-tree", + "-r", + "-z", + frozen_parent, + "--", + "public/catalogs/v1/channels", + "public/catalogs/v1/keyring.json", + module_prefix, + timeout=30, + ) + tree_bytes = tree.stdout.encode("utf-8", errors="strict") + if tree.returncode != 0 or len(tree_bytes) > 16 * 1024 * 1024: + raise ReleaseExecutionBlocked( + "Frozen catalog tree cannot be inspected within its bounds." + ) + old_blobs: dict[str, str] = {} + for row in (item for item in tree.stdout.split("\0") if item): + metadata, separator, repository_path = row.partition("\t") + fields = metadata.split() + if ( + separator != "\t" + or len(fields) != 3 + or fields[0] != "100644" + or fields[1] != "blob" + or len(fields[2]) not in {40, 64} + or repository_path in old_blobs + ): + raise ReleaseExecutionBlocked( + "Frozen catalog tree contains an ambiguous entry." + ) + old_blobs[repository_path] = fields[2] + object_format = git_text(web_root, "rev-parse", "--show-object-format") + if object_format not in {"sha1", "sha256"}: + raise ReleaseExecutionBlocked( + "Website Git object format cannot be established." + ) + expected_delta = { + path + for path, encoded in expected_blobs.items() + if old_blobs.get(path) != _git_blob_identity(encoded, object_format) + } + expected_delta.update( + path + for path in old_blobs + if path.startswith(f"{module_prefix}/") and path not in expected_blobs + ) + observed = git( + web_root, + "diff-tree", + "--no-commit-id", + "--name-only", + "--no-renames", + "--no-ext-diff", + "-r", + "-z", + frozen_parent, + commit, + timeout=30, + ) + observed_bytes = observed.stdout.encode("utf-8", errors="strict") + if observed.returncode != 0 or len(observed_bytes) > 16 * 1024 * 1024: + raise ReleaseExecutionBlocked( + "Catalog publication delta cannot be inspected within its bounds." + ) + observed_delta = {path for path in observed.stdout.split("\0") if path} + if observed_delta != expected_delta: + raise ReleaseExecutionBlocked( + "Catalog publication commit contains paths outside the exact derived delta." + ) + + +def _git_blob_identity(encoded: bytes, object_format: str) -> str: + digest = hashlib.new(object_format) + digest.update(f"blob {len(encoded)}\0".encode("ascii")) + digest.update(encoded) + return digest.hexdigest() + + +def _public_json_bytes(payload: object) -> bytes: + encoded = ( + json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=True) + "\n" + ).encode("utf-8") + if len(encoded) > 16 * 1024 * 1024: + raise ReleaseExecutionBlocked( + "Derived catalog publication object exceeds its size limit." + ) + return encoded + + +def _read_git_json_blob( + repository: Path, + *, + commit: str, + repository_path: str, + label: str, +) -> dict[str, Any]: + entry = git_text( + repository, + "ls-tree", + commit, + "--", + repository_path, + ) + metadata, separator, observed_path = entry.partition("\t") + fields = metadata.split() + if ( + separator != "\t" + or observed_path != repository_path + or len(fields) != 3 + or fields[0] != "100644" + or fields[1] != "blob" + or len(fields[2]) not in {40, 64} + ): + raise ReleaseExecutionBlocked(f"{label.capitalize()} is not one regular blob.") + result = git(repository, "cat-file", "blob", fields[2], timeout=30) + encoded = result.stdout.encode("utf-8", errors="strict") + if result.returncode != 0 or len(encoded) > 16 * 1024 * 1024: + raise ReleaseExecutionBlocked(f"{label.capitalize()} cannot be read safely.") + try: + payload = json.loads(encoded) + except json.JSONDecodeError as exc: + raise ReleaseExecutionBlocked(f"{label.capitalize()} is not valid JSON.") from exc + if not isinstance(payload, dict): + raise ReleaseExecutionBlocked(f"{label.capitalize()} is not a JSON object.") + return payload + + +def _read_candidate_json(path: Path, *, label: str) -> dict[str, Any]: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + descriptor = -1 + try: + descriptor = os.open(path, flags) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o077 + ): + raise ReleaseExecutionBlocked( + f"{label.capitalize()} is not a private regular file." + ) + chunks: list[bytes] = [] + remaining = 16 * 1024 * 1024 + 1 + while remaining: + chunk = os.read(descriptor, min(1024 * 1024, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + encoded = b"".join(chunks) + except OSError as exc: + raise ReleaseExecutionBlocked( + f"{label.capitalize()} cannot be read safely." + ) from exc + finally: + if descriptor >= 0: + os.close(descriptor) + if len(encoded) > 16 * 1024 * 1024: + raise ReleaseExecutionBlocked(f"{label.capitalize()} exceeds its size limit.") + try: + payload = json.loads(encoded.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ReleaseExecutionBlocked(f"{label.capitalize()} is not valid JSON.") from exc + if not isinstance(payload, dict): + raise ReleaseExecutionBlocked(f"{label.capitalize()} is not a JSON object.") + return payload + + +def _require_publisher_result_matches_receipt( + result: dict[str, Any], receipt: dict[str, Any] +) -> None: + fields = { + "candidate_catalog_hash": "catalog_sha256", + "candidate_keyring_hash": "keyring_sha256", + "publication_commit_sha": "publication_commit_sha", + "publication_tag_object_sha": "publication_tag_object_sha", + "publication_tag_commit_sha": "publication_tag_commit_sha", + "branch": "branch", + "tag_name": "tag_name", + "remote": "remote", + } + if any(result.get(source) != receipt[target] for source, target in fields.items()): + raise ReleaseExecutionBlocked( + "Catalog publisher result differs from independent publication proof." + ) + + +def _known_or_ambiguous_result( + result: dict[str, object], *, success: set[str], operation: str +) -> dict[str, Any]: + payload = to_jsonable(result) + status = payload.get("status") if isinstance(payload, dict) else None + if status in success: + return payload + if status == "blocked": + raise ReleaseExecutionBlocked(_bounded_result_detail(payload, operation)) + raise ReleaseExecutionAmbiguous(_bounded_result_detail(payload, operation)) + + +def _bounded_result_detail(payload: dict[str, Any], operation: str) -> str: + repositories = payload.get("repositories") + details = [ + str(item.get("detail")) + for item in repositories + if isinstance(item, dict) and item.get("detail") + ] if isinstance(repositories, list) else [] + detail = "; ".join(details) or str(payload.get("status") or "unknown result") + return _compact_output(f"{operation} did not establish success: {detail}") + + +def _read_generated_catalog(candidate: Path, *, channel: str) -> dict[str, Any]: + channel = validate_release_channel(channel) + path = candidate / "channels" / f"{channel}.json" + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = -1 + try: + descriptor = os.open(path, flags) + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise ReleaseExecutionBlocked("Generated catalog is not a regular file.") + with os.fdopen(descriptor, "rb") as handle: + descriptor = -1 + encoded = handle.read(16 * 1024 * 1024 + 1) + except OSError as exc: + raise ReleaseExecutionBlocked("Generated catalog cannot be read safely.") from exc + finally: + if descriptor >= 0: + os.close(descriptor) + if len(encoded) > 16 * 1024 * 1024: + raise ReleaseExecutionBlocked("Generated catalog exceeds its size limit.") + payload = json.loads(encoded.decode("utf-8")) + if not isinstance(payload, dict): + raise ReleaseExecutionBlocked("Generated catalog is not a JSON object.") + return payload + + +def _remove_partial_candidate(candidate: Path) -> None: + if not candidate.exists(): + return + if candidate.is_symlink() or not candidate.is_dir(): + raise ReleaseExecutionAmbiguous( + "Partial candidate path is unsafe and requires manual reconciliation." + ) + shutil.rmtree(candidate) + + +def _compact_output(value: str) -> str: + text = value.strip() + return text if len(text) <= MAX_EXECUTOR_OUTPUT else text[:MAX_EXECUTOR_OUTPUT] + "…" diff --git a/tools/release/govoplan_release/repository_tag.py b/tools/release/govoplan_release/repository_tag.py index 9091923..3379987 100644 --- a/tools/release/govoplan_release/repository_tag.py +++ b/tools/release/govoplan_release/repository_tag.py @@ -9,7 +9,11 @@ import re import subprocess import sys -from .git_state import collect_repository_snapshot, git_text +from .git_state import ( + collect_repository_snapshot, + git_text, + sanitized_git_environment, +) from .model import RepositorySnapshot from .repository_push import command_text, compact_output from .version_alignment import repository_version_issues, selected_release_webui_bundle_issues @@ -397,7 +401,16 @@ def manifest_shape_gate_issue(workspace: Path) -> str | None: "--workspace-root", str(workspace), ) - result = run(command, cwd=META_ROOT, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) + result = run( + command, + cwd=META_ROOT, + env={ + key: value + for key, value in os.environ.items() + if not key.startswith("GIT_") + } + | {"PYTHONDONTWRITEBYTECODE": "1"}, + ) if result.returncode == 0: return None detail = compact_output(result.stderr) or compact_output(result.stdout) @@ -463,16 +476,26 @@ def run( timeout: int = 120, env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: + effective_command = command + effective_environment = env + if command and Path(command[0]).name == "git": + effective_command = ( + "/usr/bin/git", + "-c", + "core.hooksPath=/dev/null", + *command[1:], + ) + effective_environment = sanitized_git_environment(env) try: return subprocess.run( - command, + effective_command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, - env=env, + env=effective_environment, ) except subprocess.TimeoutExpired as exc: return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out") diff --git a/tools/release/govoplan_release/selective_catalog.py b/tools/release/govoplan_release/selective_catalog.py index 3dfdd2e..90bfb8b 100644 --- a/tools/release/govoplan_release/selective_catalog.py +++ b/tools/release/govoplan_release/selective_catalog.py @@ -33,10 +33,11 @@ from .catalog_entry_synthesis import ( validate_initial_entry_closure, ) from .contracts import collect_contracts -from .git_state import collect_repository_snapshot +from .git_state import collect_repository_snapshot, git_text from .model import CatalogEntryChange, SelectiveCatalogCandidate from .module_directory import write_module_directory from .source_provenance import ( + SourceTagProvenanceIssue, catalog_source_selection, selected_source_provenance, source_tag_provenance_issues, @@ -45,7 +46,12 @@ from .version_alignment import ( selected_release_webui_bundle_issues, selected_repository_version_issues, ) -from .workspace import load_repository_specs, resolve_workspace_root, website_root +from .workspace import ( + load_repository_specs, + resolve_repo_path, + resolve_workspace_root, + website_root, +) GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas" MAX_BASE_JSON_BYTES = 16 * 1024 * 1024 @@ -77,6 +83,7 @@ def build_selective_catalog_candidate( check_public: bool = True, write_summary: bool = True, python_artifacts: dict[str, Path | str] | None = None, + frozen_source_provenance: dict[str, dict[str, str]] | None = None, ) -> SelectiveCatalogCandidate: channel = validate_release_channel(channel) if not repo_versions: @@ -88,15 +95,23 @@ def build_selective_catalog_candidate( workspace = resolve_workspace_root(workspace_root) enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace) - enforce_selected_source_provenance( - repo_versions=repo_versions, - workspace=workspace, - remote=source_remote, - ) - selected_provenance = selected_source_provenance( - repo_versions=repo_versions, - workspace=workspace, - ) + if frozen_source_provenance is None: + enforce_selected_source_provenance( + repo_versions=repo_versions, + workspace=workspace, + remote=source_remote, + ) + selected_provenance = selected_source_provenance( + repo_versions=repo_versions, + workspace=workspace, + ) + else: + selected_provenance = enforce_frozen_selected_source_provenance( + repo_versions=repo_versions, + expected_provenance=frozen_source_provenance, + workspace=workspace, + remote=source_remote, + ) web_root = website_root(workspace) authenticated_base = load_authenticated_catalog_base( base_catalog=base_catalog, @@ -132,7 +147,10 @@ def build_selective_catalog_candidate( ] candidate["release"] = release - repo_contracts = contracts_by_repo(workspace) + repo_contracts = contracts_by_repo( + workspace, + selected_repositories=set(repo_versions), + ) changes = apply_repo_updates( candidate, repo_versions=repo_versions, @@ -151,6 +169,7 @@ def build_selective_catalog_candidate( payload=candidate, workspace=workspace, remote=source_remote, + require_selected_heads=frozen_source_provenance is None, ) output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at) @@ -283,11 +302,107 @@ def enforce_selected_source_provenance( ) +def enforce_frozen_selected_source_provenance( + *, + repo_versions: dict[str, str], + expected_provenance: dict[str, dict[str, str]], + workspace: Path, + remote: str = "origin", +) -> dict[str, dict[str, str]]: + """Verify detached, clean source checkouts against durable object receipts. + + Normal interactive candidate generation still requires a named, current + source branch. The durable executor instead operates on isolated clones of + exact annotated origin tags, so it supplies the commit and tag object that + must be present at each detached HEAD. + """ + + if set(expected_provenance) != set(repo_versions): + raise ValueError( + "Frozen source provenance must cover exactly the selected repositories." + ) + expected_commits: dict[str, str] = {} + expected_tag_objects: dict[str, str] = {} + for repo in sorted(repo_versions): + identity = expected_provenance.get(repo) + commit = identity.get("commit_sha") if isinstance(identity, dict) else None + tag_object = ( + identity.get("tag_object_sha") if isinstance(identity, dict) else None + ) + if ( + not isinstance(identity, dict) + or set(identity) != {"commit_sha", "tag_object_sha"} + or not isinstance(commit, str) + or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", commit) is None + or not isinstance(tag_object, str) + or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", tag_object) is None + ): + raise ValueError( + f"Frozen source provenance is malformed for {repo}." + ) + expected_commits[repo] = commit + expected_tag_objects[repo] = tag_object + + failures = list( + source_tag_provenance_issues( + repo_versions=repo_versions, + workspace=workspace, + remote=remote, + expected_commits=expected_commits, + expected_tag_objects=expected_tag_objects, + ) + ) + specs = {spec.name: spec for spec in load_repository_specs(include_website=False)} + for repo, version in sorted(repo_versions.items()): + spec = specs.get(repo) + if spec is None: + continue + path = resolve_repo_path(spec, workspace) + snapshot = collect_repository_snapshot( + spec, + workspace_root=workspace, + target_tag=f"v{version.removeprefix('v')}", + online=False, + ) + if snapshot.dirty_entries: + failures.append( + SourceTagProvenanceIssue( + repo, + f"v{version.removeprefix('v')}", + "frozen source worktree is not clean", + ) + ) + head = git_text(path, "rev-parse", "--verify", "HEAD") + if head != expected_commits[repo]: + failures.append( + SourceTagProvenanceIssue( + repo, + f"v{version.removeprefix('v')}", + "frozen source HEAD does not match its durable commit receipt", + ) + ) + if failures: + raise ValueError( + "Frozen source provenance gate failed: " + + "; ".join(issue.describe() for issue in failures) + ) + observed = selected_source_provenance( + repo_versions=repo_versions, + workspace=workspace, + ) + if observed != expected_provenance: + raise ValueError( + "Frozen source object identities changed during candidate synthesis." + ) + return observed + + def enforce_complete_catalog_source_provenance( *, payload: object, workspace: Path, remote: str = "origin", + require_selected_heads: bool = True, ) -> None: selection = catalog_source_selection(payload) failures = [*selection.issues] @@ -296,7 +411,9 @@ def enforce_complete_catalog_source_provenance( repo_versions=selection.all_versions, workspace=workspace, remote=remote, - require_head_repos=selection.selected_versions, + require_head_repos=( + selection.selected_versions if require_selected_heads else () + ), expected_commits=selection.selected_commits, expected_tag_objects=selection.selected_tag_objects, ) @@ -408,7 +525,9 @@ def read_bounded_json_source(source: Path | str, *, label: str) -> object: raise ValueError(f"{label.capitalize()} is not valid UTF-8 JSON.") from exc -def _read_regular_file_without_symlinks(path: Path, *, label: str) -> bytes: +def _read_regular_file_without_symlinks( + path: Path, *, label: str, require_private_owner: bool = False +) -> bytes: absolute = path.absolute() parts = absolute.parts[1:] if not parts: @@ -439,6 +558,13 @@ def _read_regular_file_without_symlinks(path: Path, *, label: str) -> bytes: raise ValueError( f"{label.capitalize()} must be a bounded regular file." ) + if require_private_owner and ( + initial.st_uid != os.geteuid() or stat.S_IMODE(initial.st_mode) & 0o077 + ): + raise ValueError( + f"{label.capitalize()} must be owned by the current operator " + "and inaccessible to other users." + ) chunks: list[bytes] = [] remaining = initial.st_size while remaining: @@ -474,8 +600,14 @@ def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int: return max(current + 1, timestamp_sequence) -def contracts_by_repo(workspace: Path) -> dict[str, Any]: - specs = load_repository_specs(include_website=False) +def contracts_by_repo( + workspace: Path, *, selected_repositories: set[str] | None = None +) -> dict[str, Any]: + specs = tuple( + spec + for spec in load_repository_specs(include_website=False) + if selected_repositories is None or spec.name in selected_repositories + ) snapshots = tuple( collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False) for spec in specs @@ -719,7 +851,11 @@ def parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]: raise ValueError("Catalog signing key ID is not a bounded opaque ID.") path = Path(path_text).expanduser() private_key = serialization.load_pem_private_key( - _read_regular_file_without_symlinks(path, label="catalog signing key"), + _read_regular_file_without_symlinks( + path, + label="catalog signing key", + require_private_owner=True, + ), password=None, ) if not isinstance(private_key, Ed25519PrivateKey): diff --git a/tools/release/govoplan_release/source_provenance.py b/tools/release/govoplan_release/source_provenance.py index e39f6b6..e8854cb 100644 --- a/tools/release/govoplan_release/source_provenance.py +++ b/tools/release/govoplan_release/source_provenance.py @@ -12,7 +12,11 @@ import tomllib from typing import Iterable from urllib.parse import urlsplit -from .git_state import collect_repository_snapshot, git_text +from .git_state import ( + collect_repository_snapshot, + git_text, + sanitized_git_environment, +) from .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit from .version_alignment import repository_version_issues from .workspace import load_repository_specs, resolve_repo_path @@ -415,16 +419,17 @@ def _git_file(path: Path, tag: str, name: str) -> str | None: def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]: try: return subprocess.run( - ("git", *args), + ("/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args), cwd=path, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, + env=sanitized_git_environment(), ) except subprocess.TimeoutExpired as exc: - return subprocess.CompletedProcess(("git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out") + return subprocess.CompletedProcess(("/usr/bin/git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out") def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]: diff --git a/tools/release/govoplan_release/version_alignment.py b/tools/release/govoplan_release/version_alignment.py index bb34168..2cd8af7 100644 --- a/tools/release/govoplan_release/version_alignment.py +++ b/tools/release/govoplan_release/version_alignment.py @@ -9,7 +9,7 @@ import re import subprocess import tomllib -from .git_state import collect_versions +from .git_state import collect_versions, sanitized_git_environment from .workspace import load_repository_specs, resolve_repo_path @@ -684,12 +684,13 @@ def _git_tag_commit(repo_path: Path, tag: str) -> str | None: if not (repo_path / ".git").exists(): return None result = subprocess.run( - ["git", "-C", str(repo_path), "rev-list", "-n", "1", tag], + ["/usr/bin/git", "-C", str(repo_path), "rev-list", "-n", "1", tag], check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, + env=sanitized_git_environment(), ) value = result.stdout.strip() return value if result.returncode == 0 and value else None @@ -699,12 +700,13 @@ def _git_tag_file(repo_path: Path, tag: str, relative_path: str) -> str | None: if not (repo_path / ".git").exists(): return None result = subprocess.run( - ["git", "-C", str(repo_path), "show", f"{tag}:{relative_path}"], + ["/usr/bin/git", "-C", str(repo_path), "show", f"{tag}:{relative_path}"], check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, + env=sanitized_git_environment(), ) return result.stdout if result.returncode == 0 else None