Harden release source execution

This commit is contained in:
2026-07-22 21:37:14 +02:00
parent 349a099e5a
commit 7ecf1f17b0
11 changed files with 3331 additions and 58 deletions

View File

@@ -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()

View File

@@ -18,6 +18,29 @@ from govoplan_release.module_directory import ( # noqa: E402
class ReleaseModuleDirectoryTests(unittest.TestCase): 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: def test_dot_segments_and_noncanonical_ids_never_become_paths(self) -> None:
for value in (".", "..", "../escape", "/absolute", "module/child"): for value in (".", "..", "../escape", "/absolute", "module/child"):
with self.subTest(value=value), self.assertRaises(ValueError): with self.subTest(value=value), self.assertRaises(ValueError):

View File

@@ -23,6 +23,7 @@ from govoplan_release.catalog import canonical_hash # noqa: E402
from govoplan_release.selective_catalog import ( # noqa: E402 from govoplan_release.selective_catalog import ( # noqa: E402
build_selective_catalog_candidate, build_selective_catalog_candidate,
enforce_selected_source_provenance, enforce_selected_source_provenance,
parse_signing_key,
public_key_base64, public_key_base64,
signature, signature,
) )
@@ -159,6 +160,7 @@ class ReleaseSourceProvenanceTests(unittest.TestCase):
encryption_algorithm=serialization.NoEncryption(), encryption_algorithm=serialization.NoEncryption(),
) )
) )
key_path.chmod(0o600)
output = self.root / "candidate" output = self.root / "candidate"
with patch( 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(git_text(core, "rev-parse", "refs/tags/v1.2.3"), selected["tag_object_sha"])
self.assertEqual("test-key", payload["signatures"][0]["key_id"]) 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: def test_catalog_publication_checks_every_preserved_source_ref(self) -> None:
core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3") core, _ = make_repo(self.workspace, self.root, "govoplan-core", "1.2.3")
git(core, "tag", "-a", "v1.2.3", "-m", "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("Array.isArray(result.notes)", ui)
self.assertIn("Validation and provenance details", ui) self.assertIn("Validation and provenance details", ui)
def test_console_returns_actionable_conflict_for_candidate_provenance_gate(self) -> None: def test_console_disables_unclaimed_legacy_candidate_signing(self) -> None:
with patch( with TestClient(
"server.app.build_selective_catalog_candidate", create_app(workspace_root=self.workspace, token="token")
side_effect=ValueError("Complete catalog source provenance gate failed: govoplan-core v1.2.3: missing"), ) as client:
): response = client.post(
with TestClient(create_app(workspace_root=self.workspace)) as client: "/api/catalog-candidates",
response = client.post( headers={"X-Release-Console-Token": "token"},
"/api/catalog-candidates", json={
json={ "repo_versions": {"govoplan-core": "1.2.3"},
"repo_versions": {"govoplan-core": "1.2.3"}, "signing_keys": ["test=/unused/key.pem"],
"signing_keys": ["test=/unused/key.pem"], },
}, )
)
self.assertEqual(409, response.status_code) 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: def test_read_only_ref_checks_can_reuse_the_same_gitea_https_endpoint(self) -> None:
self.assertEqual( self.assertEqual(

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path from pathlib import Path
import re import re
import subprocess 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]: def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
try: try:
return subprocess.run( return subprocess.run(
["git", *args], ["/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args],
cwd=path, cwd=path,
check=False, check=False,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout, timeout=timeout,
env=sanitized_git_environment(),
) )
except subprocess.TimeoutExpired as exc: 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: def git_error(result: subprocess.CompletedProcess[str]) -> str:

View File

@@ -44,7 +44,15 @@ def module_directory_payloads(
public_base_url: str = DEFAULT_PUBLIC_BASE_URL, public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
) -> tuple[tuple[Path, dict[str, Any]], ...]: ) -> tuple[tuple[Path, dict[str, Any]], ...]:
channel = validate_release_channel(channel) 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) modules = module_rows(catalog_payload)
compatibility = compatibility_rows(modules) compatibility = compatibility_rows(modules)
signatures = signature_rows(catalog_payload, keyring_payload) signatures = signature_rows(catalog_payload, keyring_payload)

View File

@@ -1024,11 +1024,18 @@ def publication_runtime_trust_issues() -> tuple[str, ...]:
ancestor_issue = _operator_controlled_ancestor_issue(path) ancestor_issue = _operator_controlled_ancestor_issue(path)
if ancestor_issue: if ancestor_issue:
return (f"{label}: {ancestor_issue}",) return (f"{label}: {ancestor_issue}",)
executable_issue = _trusted_runtime_executable_issue( for executable, label in (
Path(sys.executable), permitted_owners=runtime_owners (Path(sys.executable), "Python executable"),
) (Path("/usr/bin/git"), "Git executable"),
if executable_issue: (Path("/usr/bin/ssh"), "SSH executable"),
return (executable_issue,) ):
executable_issue = _trusted_runtime_executable_issue(
executable,
permitted_owners=runtime_owners,
label=label,
)
if executable_issue:
return (executable_issue,)
required = ( required = (
(META_ROOT, True, "meta repository root"), (META_ROOT, True, "meta repository root"),
(META_ROOT / "repositories.json", False, "repository registry"), (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( def _trusted_runtime_executable_issue(
path: Path, *, permitted_owners: set[int] path: Path, *, permitted_owners: set[int], label: str = "Python executable"
) -> str | None: ) -> str | None:
executable = path.absolute() executable = path.absolute()
ancestor_issue = _operator_controlled_ancestor_issue(executable.parent) ancestor_issue = _operator_controlled_ancestor_issue(executable.parent)
if ancestor_issue: if ancestor_issue:
return f"Python executable: {ancestor_issue}" return f"{label}: {ancestor_issue}"
try: try:
link_metadata = executable.lstat() link_metadata = executable.lstat()
except OSError: 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 stat.S_ISLNK(link_metadata.st_mode):
if link_metadata.st_uid not in permitted_owners: 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: try:
target = executable.resolve(strict=True) target = executable.resolve(strict=True)
except OSError: except OSError:
return f"Python executable symlink cannot be resolved safely: {executable}" return f"{label} symlink cannot be resolved safely: {executable}"
else: else:
target = executable target = executable
ancestor_issue = _operator_controlled_ancestor_issue(target.parent) ancestor_issue = _operator_controlled_ancestor_issue(target.parent)
if ancestor_issue: if ancestor_issue:
return f"Python executable target: {ancestor_issue}" return f"{label} target: {ancestor_issue}"
return _operator_control_issue( return _operator_control_issue(
target, target,
directory=False, directory=False,
label="Python executable target", label=f"{label} target",
permitted_owners=permitted_owners, permitted_owners=permitted_owners,
) )
@@ -1866,7 +1873,9 @@ def _sanitized_git_environment(
source = base if base is not None else os.environ source = base if base is not None else os.environ
environment = { 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( environment.update(
{ {
@@ -1875,7 +1884,9 @@ def _sanitized_git_environment(
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_NOSYSTEM": "1",
"GIT_NO_REPLACE_OBJECTS": "1", "GIT_NO_REPLACE_OBJECTS": "1",
"GIT_PAGER": "cat", "GIT_PAGER": "cat",
"GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
"GIT_TERMINAL_PROMPT": "0", "GIT_TERMINAL_PROMPT": "0",
"PATH": "/usr/bin:/bin",
} }
) )
if isolate_config: if isolate_config:
@@ -1911,7 +1922,7 @@ def _git_bytes(
) )
result = subprocess.run( result = subprocess.run(
[ [
"git", "/usr/bin/git",
"-C", "-C",
str(path), str(path),
"-c", "-c",

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,11 @@ import re
import subprocess import subprocess
import sys 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 .model import RepositorySnapshot
from .repository_push import command_text, compact_output from .repository_push import command_text, compact_output
from .version_alignment import repository_version_issues, selected_release_webui_bundle_issues 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", "--workspace-root",
str(workspace), 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: if result.returncode == 0:
return None return None
detail = compact_output(result.stderr) or compact_output(result.stdout) detail = compact_output(result.stderr) or compact_output(result.stdout)
@@ -463,16 +476,26 @@ def run(
timeout: int = 120, timeout: int = 120,
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]: ) -> 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: try:
return subprocess.run( return subprocess.run(
command, effective_command,
cwd=cwd, cwd=cwd,
check=False, check=False,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout, timeout=timeout,
env=env, env=effective_environment,
) )
except subprocess.TimeoutExpired as exc: except subprocess.TimeoutExpired as exc:
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out") return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out")

View File

@@ -33,10 +33,11 @@ from .catalog_entry_synthesis import (
validate_initial_entry_closure, validate_initial_entry_closure,
) )
from .contracts import collect_contracts 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 .model import CatalogEntryChange, SelectiveCatalogCandidate
from .module_directory import write_module_directory from .module_directory import write_module_directory
from .source_provenance import ( from .source_provenance import (
SourceTagProvenanceIssue,
catalog_source_selection, catalog_source_selection,
selected_source_provenance, selected_source_provenance,
source_tag_provenance_issues, source_tag_provenance_issues,
@@ -45,7 +46,12 @@ from .version_alignment import (
selected_release_webui_bundle_issues, selected_release_webui_bundle_issues,
selected_repository_version_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" GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
MAX_BASE_JSON_BYTES = 16 * 1024 * 1024 MAX_BASE_JSON_BYTES = 16 * 1024 * 1024
@@ -77,6 +83,7 @@ def build_selective_catalog_candidate(
check_public: bool = True, check_public: bool = True,
write_summary: bool = True, write_summary: bool = True,
python_artifacts: dict[str, Path | str] | None = None, python_artifacts: dict[str, Path | str] | None = None,
frozen_source_provenance: dict[str, dict[str, str]] | None = None,
) -> SelectiveCatalogCandidate: ) -> SelectiveCatalogCandidate:
channel = validate_release_channel(channel) channel = validate_release_channel(channel)
if not repo_versions: if not repo_versions:
@@ -88,15 +95,23 @@ def build_selective_catalog_candidate(
workspace = resolve_workspace_root(workspace_root) workspace = resolve_workspace_root(workspace_root)
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace) enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
enforce_selected_source_provenance( if frozen_source_provenance is None:
repo_versions=repo_versions, enforce_selected_source_provenance(
workspace=workspace, repo_versions=repo_versions,
remote=source_remote, workspace=workspace,
) remote=source_remote,
selected_provenance = selected_source_provenance( )
repo_versions=repo_versions, selected_provenance = selected_source_provenance(
workspace=workspace, 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) web_root = website_root(workspace)
authenticated_base = load_authenticated_catalog_base( authenticated_base = load_authenticated_catalog_base(
base_catalog=base_catalog, base_catalog=base_catalog,
@@ -132,7 +147,10 @@ def build_selective_catalog_candidate(
] ]
candidate["release"] = release candidate["release"] = release
repo_contracts = contracts_by_repo(workspace) repo_contracts = contracts_by_repo(
workspace,
selected_repositories=set(repo_versions),
)
changes = apply_repo_updates( changes = apply_repo_updates(
candidate, candidate,
repo_versions=repo_versions, repo_versions=repo_versions,
@@ -151,6 +169,7 @@ def build_selective_catalog_candidate(
payload=candidate, payload=candidate,
workspace=workspace, workspace=workspace,
remote=source_remote, 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) 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( def enforce_complete_catalog_source_provenance(
*, *,
payload: object, payload: object,
workspace: Path, workspace: Path,
remote: str = "origin", remote: str = "origin",
require_selected_heads: bool = True,
) -> None: ) -> None:
selection = catalog_source_selection(payload) selection = catalog_source_selection(payload)
failures = [*selection.issues] failures = [*selection.issues]
@@ -296,7 +411,9 @@ def enforce_complete_catalog_source_provenance(
repo_versions=selection.all_versions, repo_versions=selection.all_versions,
workspace=workspace, workspace=workspace,
remote=remote, 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_commits=selection.selected_commits,
expected_tag_objects=selection.selected_tag_objects, 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 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() absolute = path.absolute()
parts = absolute.parts[1:] parts = absolute.parts[1:]
if not parts: if not parts:
@@ -439,6 +558,13 @@ def _read_regular_file_without_symlinks(path: Path, *, label: str) -> bytes:
raise ValueError( raise ValueError(
f"{label.capitalize()} must be a bounded regular file." 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] = [] chunks: list[bytes] = []
remaining = initial.st_size remaining = initial.st_size
while remaining: while remaining:
@@ -474,8 +600,14 @@ def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int:
return max(current + 1, timestamp_sequence) return max(current + 1, timestamp_sequence)
def contracts_by_repo(workspace: Path) -> dict[str, Any]: def contracts_by_repo(
specs = load_repository_specs(include_website=False) 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( snapshots = tuple(
collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False) collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
for spec in specs 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.") raise ValueError("Catalog signing key ID is not a bounded opaque ID.")
path = Path(path_text).expanduser() path = Path(path_text).expanduser()
private_key = serialization.load_pem_private_key( 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, password=None,
) )
if not isinstance(private_key, Ed25519PrivateKey): if not isinstance(private_key, Ed25519PrivateKey):

View File

@@ -12,7 +12,11 @@ import tomllib
from typing import Iterable from typing import Iterable
from urllib.parse import urlsplit 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 .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit
from .version_alignment import repository_version_issues from .version_alignment import repository_version_issues
from .workspace import load_repository_specs, resolve_repo_path 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]: def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
try: try:
return subprocess.run( return subprocess.run(
("git", *args), ("/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args),
cwd=path, cwd=path,
check=False, check=False,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=30, timeout=30,
env=sanitized_git_environment(),
) )
except subprocess.TimeoutExpired as exc: 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]: def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]:

View File

@@ -9,7 +9,7 @@ import re
import subprocess import subprocess
import tomllib 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 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(): if not (repo_path / ".git").exists():
return None return None
result = subprocess.run( 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, check=False,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10, timeout=10,
env=sanitized_git_environment(),
) )
value = result.stdout.strip() value = result.stdout.strip()
return value if result.returncode == 0 and value else None 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(): if not (repo_path / ".git").exists():
return None return None
result = subprocess.run( 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, check=False,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10, timeout=10,
env=sanitized_git_environment(),
) )
return result.stdout if result.returncode == 0 else None return result.stdout if result.returncode == 0 else None