2033 lines
71 KiB
Python
2033 lines
71 KiB
Python
"""Publish reviewed release catalog candidates into the website repository."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import shlex
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
import cryptography
|
|
import govoplan_core
|
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
|
|
|
from .catalog import canonical_hash
|
|
from .artifact_identity import selected_artifact_identity_issues
|
|
from .candidate_artifact import validate_release_channel
|
|
from .model import CatalogPublishResult, CatalogPublishStep
|
|
from .module_directory import module_directory_payloads
|
|
from .selective_catalog import read_bounded_json_source, trusted_keys_from_keyring
|
|
from .source_provenance import catalog_source_selection, source_tag_provenance_issues
|
|
from .version_alignment import candidate_catalog_version_issues
|
|
from .workspace import (
|
|
DEFAULT_WORKSPACE_ROOT,
|
|
META_ROOT,
|
|
load_repository_specs,
|
|
resolve_workspace_root,
|
|
website_root,
|
|
)
|
|
|
|
|
|
_GIT_OBJECT = re.compile(r"^[0-9a-f]{40,64}$")
|
|
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
_REMOTE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
|
_MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
_MAX_REMOTE_METADATA_BYTES = 16 * 1024
|
|
_MAX_REMOVED_TREE_ENTRIES = 100_000
|
|
_MAX_REMOVED_TREE_DEPTH = 64
|
|
|
|
|
|
class CatalogPublicationAmbiguousError(RuntimeError):
|
|
"""A remote publication may have occurred but failed independent proof."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class FrozenPublicationRemote:
|
|
"""One registered fetch/push endpoint frozen before publication."""
|
|
|
|
url: str
|
|
sha256: str
|
|
|
|
|
|
def publish_catalog_candidate(
|
|
*,
|
|
candidate_dir: Path | str,
|
|
workspace_root: Path | str | None = None,
|
|
web_root: Path | str | None = None,
|
|
channel: str = "stable",
|
|
apply: bool = False, # noqa: A002 - CLI uses --apply.
|
|
build_web: bool = False,
|
|
commit: bool = False,
|
|
tag: bool = False,
|
|
push: bool = False,
|
|
remote: str = "origin",
|
|
source_remote: str = "origin",
|
|
branch: str | None = None,
|
|
npm: str = "npm",
|
|
tag_name: str | None = None,
|
|
allow_dirty_website: bool = False,
|
|
expected_website_head: str | None = None,
|
|
expected_website_branch: str | None = None,
|
|
expected_remote_sha256: str | None = None,
|
|
) -> CatalogPublishResult:
|
|
channel = validate_release_channel(channel)
|
|
if _REMOTE_NAME.fullmatch(remote) is None:
|
|
raise ValueError("publication remote must be a bounded configured name")
|
|
workspace = resolve_workspace_root(workspace_root or DEFAULT_WORKSPACE_ROOT)
|
|
candidate_root = Path(candidate_dir).expanduser()
|
|
if not candidate_root.is_absolute():
|
|
candidate_root = Path.cwd() / candidate_root
|
|
resolved_web_root = (
|
|
Path(web_root).expanduser().absolute()
|
|
if web_root is not None
|
|
else website_root(workspace).absolute()
|
|
)
|
|
candidate_catalog = candidate_root / "channels" / f"{channel}.json"
|
|
candidate_keyring = candidate_root / "keyring.json"
|
|
target_catalog = (
|
|
resolved_web_root
|
|
/ "public"
|
|
/ "catalogs"
|
|
/ "v1"
|
|
/ "channels"
|
|
/ f"{channel}.json"
|
|
)
|
|
target_keyring = resolved_web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
|
target_modules = resolved_web_root / "public" / "catalogs" / "v1" / "modules"
|
|
|
|
steps: list[CatalogPublishStep] = []
|
|
notes: list[str] = []
|
|
blockers: list[str] = []
|
|
|
|
if not candidate_catalog.exists():
|
|
blockers.append(f"candidate catalog is missing: {candidate_catalog}")
|
|
if not candidate_keyring.exists():
|
|
blockers.append(f"candidate keyring is missing: {candidate_keyring}")
|
|
if not resolved_web_root.exists():
|
|
blockers.append(f"website root is missing: {resolved_web_root}")
|
|
if push:
|
|
tag = True
|
|
commit = True
|
|
if tag:
|
|
commit = True
|
|
|
|
candidate_payload = (
|
|
read_json(candidate_catalog) if candidate_catalog.exists() else None
|
|
)
|
|
keyring_payload = (
|
|
read_json(candidate_keyring) if candidate_keyring.exists() else None
|
|
)
|
|
target_catalog_payload = (
|
|
read_json(target_catalog) if target_catalog.exists() else None
|
|
)
|
|
target_keyring_payload = (
|
|
read_json(target_keyring) if target_keyring.exists() else None
|
|
)
|
|
candidate_catalog_hash = (
|
|
canonical_hash(candidate_payload) if candidate_payload is not None else None
|
|
)
|
|
candidate_keyring_hash = (
|
|
canonical_hash(keyring_payload) if keyring_payload is not None else None
|
|
)
|
|
target_catalog_hash_before = (
|
|
canonical_hash(target_catalog_payload)
|
|
if target_catalog_payload is not None
|
|
else None
|
|
)
|
|
target_keyring_hash_before = (
|
|
canonical_hash(target_keyring_payload)
|
|
if target_keyring_payload is not None
|
|
else None
|
|
)
|
|
validation_valid = False
|
|
validation_error: str | None = None
|
|
validation_warnings: tuple[str, ...] = ()
|
|
|
|
if candidate_payload is not None and keyring_payload is not None:
|
|
if (
|
|
not isinstance(candidate_payload, dict)
|
|
or candidate_payload.get("channel") != channel
|
|
):
|
|
blockers.append(
|
|
"candidate catalog channel does not match the publication channel"
|
|
)
|
|
version_issues = candidate_catalog_version_issues(candidate_payload)
|
|
blockers.extend(
|
|
f"candidate version alignment: {issue.source}: {issue.actual!r}; "
|
|
f"expected {issue.expected!r} ({issue.message})"
|
|
for issue in version_issues
|
|
)
|
|
source_selection = catalog_source_selection(candidate_payload)
|
|
blockers.extend(
|
|
f"source provenance: {issue.describe()}"
|
|
for issue in source_selection.issues
|
|
)
|
|
artifact_issues = selected_artifact_identity_issues(candidate_payload)
|
|
if apply or commit or tag or push:
|
|
blockers.extend(
|
|
f"release artifact identity: {issue}" for issue in artifact_issues
|
|
)
|
|
else:
|
|
notes.extend(
|
|
f"Source-only preview; publication would block: {issue}"
|
|
for issue in artifact_issues
|
|
)
|
|
source_issues = source_tag_provenance_issues(
|
|
repo_versions=source_selection.all_versions,
|
|
workspace=workspace,
|
|
remote=source_remote,
|
|
require_head_repos=source_selection.selected_versions,
|
|
expected_commits=source_selection.selected_commits,
|
|
expected_tag_objects=source_selection.selected_tag_objects,
|
|
)
|
|
blockers.extend(
|
|
f"source provenance: {issue.describe()}" for issue in source_issues
|
|
)
|
|
candidate_keys = trusted_keys_from_keyring(
|
|
keyring_payload if isinstance(keyring_payload, dict) else {}
|
|
)
|
|
publication_trust = trusted_keys_from_keyring(
|
|
target_keyring_payload if isinstance(target_keyring_payload, dict) else {}
|
|
)
|
|
if not publication_trust:
|
|
blockers.append(
|
|
"publication trust anchor is missing; bootstrap a trusted website keyring through a separate reviewed process"
|
|
)
|
|
for key_id in sorted(set(candidate_keys) & set(publication_trust)):
|
|
if candidate_keys[key_id] != publication_trust[key_id]:
|
|
blockers.append(
|
|
f"candidate keyring changes the public key for trusted key id {key_id!r}"
|
|
)
|
|
if candidate_keyring_hash != target_keyring_hash_before:
|
|
release = (
|
|
candidate_payload.get("release")
|
|
if isinstance(candidate_payload, dict)
|
|
else None
|
|
)
|
|
signed_keyring_hash = (
|
|
release.get("keyring_sha256") if isinstance(release, dict) else None
|
|
)
|
|
if signed_keyring_hash != candidate_keyring_hash:
|
|
blockers.append(
|
|
"candidate keyring differs from the publication trust anchor without a matching signed keyring_sha256"
|
|
)
|
|
validation = validate_catalog_payload(
|
|
candidate_payload,
|
|
require_trusted=True,
|
|
approved_channels=(channel,),
|
|
trusted_keys=publication_trust,
|
|
)
|
|
validation_valid = validation.get("valid") is True
|
|
validation_error = str(validation["error"]) if validation.get("error") else None
|
|
validation_warnings = tuple(
|
|
str(item) for item in validation.get("warnings") or ()
|
|
)
|
|
if not validation_valid:
|
|
blockers.append(validation_error or "candidate catalog validation failed")
|
|
|
|
catalog_changed = candidate_catalog_hash != target_catalog_hash_before
|
|
keyring_changed = candidate_keyring_hash != target_keyring_hash_before
|
|
if not catalog_changed and not keyring_changed:
|
|
notes.append(
|
|
"Candidate catalog and keyring already match the website repository."
|
|
)
|
|
|
|
if apply or commit or tag or push:
|
|
if not (resolved_web_root / ".git").exists():
|
|
blockers.append(
|
|
f"website root is not a Git repository: {resolved_web_root}"
|
|
)
|
|
if not allow_dirty_website and website_dirty(resolved_web_root):
|
|
blockers.append("website repository has uncommitted changes")
|
|
if apply:
|
|
blockers.extend(publication_runtime_trust_issues())
|
|
blockers.extend(
|
|
publication_mutation_trust_issues(
|
|
web_root=resolved_web_root,
|
|
candidate_catalog=candidate_catalog,
|
|
candidate_keyring=candidate_keyring,
|
|
target_catalog=target_catalog,
|
|
target_keyring=target_keyring,
|
|
target_modules=target_modules,
|
|
)
|
|
)
|
|
if not git_success(resolved_web_root, "diff", "--cached", "--quiet"):
|
|
blockers.append(
|
|
"website Git index has staged changes; publication requires a clean index"
|
|
)
|
|
|
|
effective_branch = (
|
|
branch or git_text(resolved_web_root, "branch", "--show-current") or None
|
|
)
|
|
effective_tag_name = tag_name or default_tag_name(
|
|
candidate_payload, channel=channel
|
|
)
|
|
frozen_website_head: str | None = None
|
|
frozen_remote: FrozenPublicationRemote | None = None
|
|
expected_binding = (
|
|
expected_website_head,
|
|
expected_website_branch,
|
|
expected_remote_sha256,
|
|
)
|
|
if any(value is not None for value in expected_binding) and not all(
|
|
isinstance(value, str) and value for value in expected_binding
|
|
):
|
|
blockers.append(
|
|
"immutable website head, branch, and remote binding must be supplied together"
|
|
)
|
|
elif all(value is not None for value in expected_binding) and (
|
|
_GIT_OBJECT.fullmatch(expected_website_head or "") is None
|
|
or _SHA256.fullmatch(expected_remote_sha256 or "") is None
|
|
):
|
|
blockers.append("immutable website release binding is malformed")
|
|
if (tag or push) and not effective_tag_name:
|
|
blockers.append("could not infer catalog tag name; pass --tag-name")
|
|
if (commit or push) and not effective_branch:
|
|
blockers.append("could not infer website branch; pass --branch")
|
|
for value, prefix, label in (
|
|
(effective_branch, "refs/heads", "website branch"),
|
|
(effective_tag_name, "refs/tags", "website publication tag"),
|
|
):
|
|
if value:
|
|
try:
|
|
if value.startswith("-"):
|
|
raise ValueError(f"{label} must not begin with an option prefix")
|
|
_validated_git_ref(
|
|
resolved_web_root,
|
|
f"{prefix}/{value}",
|
|
label=label,
|
|
)
|
|
except (RuntimeError, subprocess.SubprocessError, ValueError) as exc:
|
|
blockers.append(f"{label} is not safe: {exc}")
|
|
if (
|
|
tag
|
|
and effective_tag_name
|
|
and git_success(
|
|
resolved_web_root,
|
|
"rev-parse",
|
|
"--quiet",
|
|
"--verify",
|
|
f"refs/tags/{effective_tag_name}",
|
|
)
|
|
):
|
|
blockers.append(f"website tag already exists: {effective_tag_name}")
|
|
if apply and commit and effective_branch:
|
|
frozen_website_head = git_text(
|
|
resolved_web_root, "rev-parse", "--verify", "HEAD^{commit}"
|
|
)
|
|
frozen_branch_head = git_text(
|
|
resolved_web_root,
|
|
"rev-parse",
|
|
"--verify",
|
|
f"refs/heads/{effective_branch}^{{commit}}",
|
|
)
|
|
if (
|
|
_GIT_OBJECT.fullmatch(frozen_website_head) is None
|
|
or frozen_branch_head != frozen_website_head
|
|
):
|
|
blockers.append(
|
|
"website branch and HEAD do not identify one frozen publication parent"
|
|
)
|
|
if (
|
|
expected_website_head is not None
|
|
and frozen_website_head != expected_website_head
|
|
):
|
|
blockers.append("website HEAD differs from the immutable release binding")
|
|
if (
|
|
expected_website_branch is not None
|
|
and effective_branch != expected_website_branch
|
|
):
|
|
blockers.append("website branch differs from the immutable release binding")
|
|
if apply:
|
|
try:
|
|
frozen_remote = bind_publication_remote(
|
|
web_root=resolved_web_root,
|
|
remote=remote,
|
|
registered_remote=registered_website_remote(),
|
|
)
|
|
if (
|
|
expected_remote_sha256 is not None
|
|
and frozen_remote.sha256 != expected_remote_sha256
|
|
):
|
|
raise RuntimeError(
|
|
"website remote differs from the immutable release binding"
|
|
)
|
|
if commit and effective_branch and frozen_website_head:
|
|
verify_remote_branch_head(
|
|
web_root=resolved_web_root,
|
|
remote_url=frozen_remote.url,
|
|
branch=effective_branch,
|
|
expected_commit=frozen_website_head,
|
|
)
|
|
except (
|
|
OSError,
|
|
RuntimeError,
|
|
subprocess.SubprocessError,
|
|
UnicodeError,
|
|
ValueError,
|
|
) as exc:
|
|
blockers.append(f"website publication remote cannot be frozen: {exc}")
|
|
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="validate",
|
|
title="Validate candidate catalog",
|
|
detail="Validate signature, approved channel, freshness, interface closure, and keyring trust.",
|
|
status="done" if validation_valid else "blocked",
|
|
)
|
|
)
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="copy",
|
|
title="Copy candidate catalog/keyring and derive the module directory",
|
|
detail=f"{candidate_root} -> {resolved_web_root / 'public' / 'catalogs' / 'v1'}",
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "done",
|
|
)
|
|
)
|
|
if build_web:
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="build-web",
|
|
title="Build website",
|
|
detail="Run the website build after copying catalog files.",
|
|
command=shlex.join(
|
|
[npm, "--prefix", str(resolved_web_root), "run", "build"]
|
|
),
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
|
)
|
|
)
|
|
if commit:
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="commit",
|
|
title="Commit website catalog update",
|
|
detail="Commit generated catalog/keyring files in the website repository.",
|
|
command=shlex.join(
|
|
[
|
|
"git",
|
|
"-C",
|
|
str(resolved_web_root),
|
|
"commit",
|
|
"-m",
|
|
commit_message(candidate_payload, channel=channel),
|
|
]
|
|
),
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
|
)
|
|
)
|
|
if tag:
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="tag",
|
|
title=f"Tag website catalog publication {effective_tag_name}",
|
|
detail="Create an annotated website publication tag.",
|
|
command=shlex.join(
|
|
[
|
|
"git",
|
|
"-C",
|
|
str(resolved_web_root),
|
|
"tag",
|
|
"-a",
|
|
effective_tag_name,
|
|
"-m",
|
|
commit_message(candidate_payload, channel=channel),
|
|
]
|
|
),
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
|
)
|
|
)
|
|
if push:
|
|
steps.append(
|
|
CatalogPublishStep(
|
|
id="push",
|
|
title="Push website catalog publication",
|
|
detail="Push website branch and tag if a tag was requested.",
|
|
command=push_command(
|
|
resolved_web_root,
|
|
remote=remote,
|
|
branch=effective_branch,
|
|
tag_name=effective_tag_name if tag else None,
|
|
),
|
|
mutating=True,
|
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
|
)
|
|
)
|
|
|
|
if blockers:
|
|
return result(
|
|
status="blocked",
|
|
applied=False,
|
|
candidate_root=candidate_root,
|
|
candidate_catalog=candidate_catalog,
|
|
candidate_keyring=candidate_keyring,
|
|
resolved_web_root=resolved_web_root,
|
|
target_catalog=target_catalog,
|
|
target_keyring=target_keyring,
|
|
channel=channel,
|
|
branch=effective_branch,
|
|
tag_name=effective_tag_name,
|
|
remote=remote,
|
|
validation_valid=validation_valid,
|
|
validation_error=validation_error,
|
|
validation_warnings=validation_warnings,
|
|
candidate_catalog_hash=candidate_catalog_hash,
|
|
candidate_keyring_hash=candidate_keyring_hash,
|
|
target_catalog_hash_before=target_catalog_hash_before,
|
|
target_keyring_hash_before=target_keyring_hash_before,
|
|
catalog_changed=catalog_changed,
|
|
keyring_changed=keyring_changed,
|
|
steps=tuple(steps),
|
|
notes=tuple([*notes, *blockers]),
|
|
)
|
|
|
|
if not apply:
|
|
notes.append(
|
|
"Dry run only. Pass --apply to copy files; pass --commit/--tag/--push for Git publication steps."
|
|
)
|
|
return result(
|
|
status="ready",
|
|
applied=False,
|
|
candidate_root=candidate_root,
|
|
candidate_catalog=candidate_catalog,
|
|
candidate_keyring=candidate_keyring,
|
|
resolved_web_root=resolved_web_root,
|
|
target_catalog=target_catalog,
|
|
target_keyring=target_keyring,
|
|
channel=channel,
|
|
branch=effective_branch,
|
|
tag_name=effective_tag_name,
|
|
remote=remote,
|
|
validation_valid=validation_valid,
|
|
validation_error=validation_error,
|
|
validation_warnings=validation_warnings,
|
|
candidate_catalog_hash=candidate_catalog_hash,
|
|
candidate_keyring_hash=candidate_keyring_hash,
|
|
target_catalog_hash_before=target_catalog_hash_before,
|
|
target_keyring_hash_before=target_keyring_hash_before,
|
|
catalog_changed=catalog_changed,
|
|
keyring_changed=keyring_changed,
|
|
steps=tuple(steps),
|
|
notes=tuple(notes),
|
|
)
|
|
|
|
directory_payloads = module_directory_payloads(
|
|
catalog_payload=candidate_payload,
|
|
keyring_payload=keyring_payload,
|
|
channel=channel,
|
|
public_base_url=catalog_public_base_url(candidate_payload),
|
|
)
|
|
_atomic_write_public_json(
|
|
target_catalog, candidate_payload, trusted_root=resolved_web_root
|
|
)
|
|
_atomic_write_public_json(
|
|
target_keyring, keyring_payload, trusted_root=resolved_web_root
|
|
)
|
|
if target_modules.exists():
|
|
_remove_public_tree(target_modules, trusted_root=resolved_web_root)
|
|
for relative_path, payload in directory_payloads:
|
|
_atomic_write_public_json(
|
|
target_catalog.parent.parent / relative_path,
|
|
payload,
|
|
trusted_root=resolved_web_root,
|
|
)
|
|
|
|
expected_blobs = _expected_publication_blobs(
|
|
web_root=resolved_web_root,
|
|
target_catalog=target_catalog,
|
|
candidate_payload=candidate_payload,
|
|
target_keyring=target_keyring,
|
|
keyring_payload=keyring_payload,
|
|
module_root=target_catalog.parent.parent,
|
|
directory_payloads=directory_payloads,
|
|
)
|
|
|
|
completed_steps = [
|
|
replace_status(step, "done") if step.id == "copy" else step for step in steps
|
|
]
|
|
if build_web:
|
|
run_checked(
|
|
[npm, "--prefix", str(resolved_web_root), "run", "build"],
|
|
cwd=resolved_web_root,
|
|
)
|
|
completed_steps = [
|
|
replace_status(step, "done") if step.id == "build-web" else step
|
|
for step in completed_steps
|
|
]
|
|
publication_commit: str | None = None
|
|
if commit:
|
|
if frozen_website_head is None or effective_branch is None:
|
|
raise RuntimeError("publication parent was not frozen before mutation")
|
|
publication_commit = commit_publication_tree(
|
|
web_root=resolved_web_root,
|
|
frozen_head=frozen_website_head,
|
|
branch=effective_branch,
|
|
expected_blobs=expected_blobs,
|
|
module_root=target_modules,
|
|
message=commit_message(candidate_payload, channel=channel),
|
|
)
|
|
completed_steps = [
|
|
replace_status(step, "done") if step.id == "commit" else step
|
|
for step in completed_steps
|
|
]
|
|
publication_tag_object: str | None = None
|
|
publication_tag_commit: str | None = None
|
|
if tag and effective_tag_name and publication_commit:
|
|
_git_checked(
|
|
resolved_web_root,
|
|
"tag",
|
|
"--no-sign",
|
|
"-a",
|
|
"-m",
|
|
commit_message(candidate_payload, channel=channel),
|
|
"--",
|
|
effective_tag_name,
|
|
publication_commit,
|
|
)
|
|
publication_tag_object = git_text(
|
|
resolved_web_root,
|
|
"rev-parse",
|
|
"--verify",
|
|
f"refs/tags/{effective_tag_name}",
|
|
)
|
|
publication_tag_commit = git_text(
|
|
resolved_web_root,
|
|
"rev-parse",
|
|
"--verify",
|
|
f"refs/tags/{effective_tag_name}^{{commit}}",
|
|
)
|
|
publication_tag_type = git_text(
|
|
resolved_web_root,
|
|
"cat-file",
|
|
"-t",
|
|
f"refs/tags/{effective_tag_name}",
|
|
)
|
|
if (
|
|
_GIT_OBJECT.fullmatch(publication_tag_object) is None
|
|
or publication_tag_type != "tag"
|
|
or publication_tag_commit != publication_commit
|
|
):
|
|
raise RuntimeError(
|
|
"website publication tag does not bind the verified commit"
|
|
)
|
|
completed_steps = [
|
|
replace_status(step, "done") if step.id == "tag" else step
|
|
for step in completed_steps
|
|
]
|
|
if push and effective_branch and publication_commit:
|
|
if frozen_remote is None:
|
|
raise RuntimeError("publication remote was not frozen before mutation")
|
|
push_args = ["-c", "core.hooksPath=/dev/null", "push"]
|
|
if tag and effective_tag_name and publication_tag_object:
|
|
push_args.extend(
|
|
[
|
|
"--atomic",
|
|
frozen_remote.url,
|
|
f"{publication_commit}:refs/heads/{effective_branch}",
|
|
f"{publication_tag_object}:refs/tags/{effective_tag_name}",
|
|
]
|
|
)
|
|
else:
|
|
push_args.extend(
|
|
[
|
|
frozen_remote.url,
|
|
f"{publication_commit}:refs/heads/{effective_branch}",
|
|
]
|
|
)
|
|
try:
|
|
_git_checked(
|
|
resolved_web_root,
|
|
*push_args,
|
|
isolate_config=True,
|
|
)
|
|
if (
|
|
effective_tag_name is None
|
|
or publication_tag_object is None
|
|
or publication_tag_commit is None
|
|
):
|
|
raise RuntimeError("published release has no complete tag identity")
|
|
verify_remote_publication(
|
|
web_root=resolved_web_root,
|
|
remote_url=frozen_remote.url,
|
|
branch=effective_branch,
|
|
tag_name=effective_tag_name,
|
|
expected_commit=publication_commit,
|
|
expected_tag_object=publication_tag_object,
|
|
)
|
|
except Exception as exc:
|
|
raise CatalogPublicationAmbiguousError(
|
|
"website push may have completed but remote identity verification failed"
|
|
) from exc
|
|
completed_steps = [
|
|
replace_status(step, "done") if step.id == "push" else step
|
|
for step in completed_steps
|
|
]
|
|
|
|
return result(
|
|
status="published" if push else "applied",
|
|
applied=True,
|
|
candidate_root=candidate_root,
|
|
candidate_catalog=candidate_catalog,
|
|
candidate_keyring=candidate_keyring,
|
|
resolved_web_root=resolved_web_root,
|
|
target_catalog=target_catalog,
|
|
target_keyring=target_keyring,
|
|
channel=channel,
|
|
branch=effective_branch,
|
|
tag_name=effective_tag_name,
|
|
remote=remote,
|
|
validation_valid=validation_valid,
|
|
validation_error=validation_error,
|
|
validation_warnings=validation_warnings,
|
|
candidate_catalog_hash=candidate_catalog_hash,
|
|
candidate_keyring_hash=candidate_keyring_hash,
|
|
target_catalog_hash_before=target_catalog_hash_before,
|
|
target_keyring_hash_before=target_keyring_hash_before,
|
|
catalog_changed=catalog_changed,
|
|
keyring_changed=keyring_changed,
|
|
publication_commit_sha=publication_commit,
|
|
publication_tag_object_sha=publication_tag_object,
|
|
publication_tag_commit_sha=publication_tag_commit,
|
|
steps=tuple(completed_steps),
|
|
notes=tuple(notes),
|
|
)
|
|
|
|
|
|
def catalog_public_base_url(payload: dict[str, object]) -> str:
|
|
release = payload.get("release")
|
|
catalog_url = release.get("catalog_url") if isinstance(release, dict) else None
|
|
if isinstance(catalog_url, str) and "/catalogs/" in catalog_url:
|
|
return catalog_url.split("/catalogs/", 1)[0]
|
|
return "https://govoplan.add-ideas.de"
|
|
|
|
|
|
def result(
|
|
*,
|
|
status: str,
|
|
applied: bool,
|
|
candidate_root: Path,
|
|
candidate_catalog: Path,
|
|
candidate_keyring: Path,
|
|
resolved_web_root: Path,
|
|
target_catalog: Path,
|
|
target_keyring: Path,
|
|
channel: str,
|
|
branch: str | None,
|
|
tag_name: str | None,
|
|
remote: str,
|
|
validation_valid: bool,
|
|
validation_error: str | None,
|
|
validation_warnings: tuple[str, ...],
|
|
candidate_catalog_hash: str | None,
|
|
candidate_keyring_hash: str | None,
|
|
target_catalog_hash_before: str | None,
|
|
target_keyring_hash_before: str | None,
|
|
catalog_changed: bool,
|
|
keyring_changed: bool,
|
|
publication_commit_sha: str | None = None,
|
|
publication_tag_object_sha: str | None = None,
|
|
publication_tag_commit_sha: str | None = None,
|
|
steps: tuple[CatalogPublishStep, ...],
|
|
notes: tuple[str, ...],
|
|
) -> CatalogPublishResult:
|
|
if status == "published" and (
|
|
_GIT_OBJECT.fullmatch(publication_commit_sha or "") is None
|
|
or _GIT_OBJECT.fullmatch(publication_tag_object_sha or "") is None
|
|
or publication_tag_commit_sha != publication_commit_sha
|
|
or _SHA256.fullmatch(candidate_catalog_hash or "") is None
|
|
or _SHA256.fullmatch(candidate_keyring_hash or "") is None
|
|
or not branch
|
|
or not tag_name
|
|
or _REMOTE_NAME.fullmatch(remote) is None
|
|
):
|
|
raise RuntimeError("published catalog result has no complete remote identity")
|
|
return CatalogPublishResult(
|
|
generated_at=datetime.now(tz=UTC)
|
|
.replace(microsecond=0)
|
|
.isoformat()
|
|
.replace("+00:00", "Z"),
|
|
channel=channel,
|
|
status=status,
|
|
applied=applied,
|
|
candidate_dir=str(candidate_root),
|
|
candidate_catalog_path=str(candidate_catalog),
|
|
candidate_keyring_path=str(candidate_keyring),
|
|
web_root=str(resolved_web_root),
|
|
target_catalog_path=str(target_catalog),
|
|
target_keyring_path=str(target_keyring),
|
|
branch=branch,
|
|
tag_name=tag_name,
|
|
remote=remote,
|
|
validation_valid=validation_valid,
|
|
validation_error=validation_error,
|
|
validation_warnings=validation_warnings,
|
|
candidate_catalog_hash=candidate_catalog_hash,
|
|
candidate_keyring_hash=candidate_keyring_hash,
|
|
target_catalog_hash_before=target_catalog_hash_before,
|
|
target_keyring_hash_before=target_keyring_hash_before,
|
|
catalog_changed=catalog_changed,
|
|
keyring_changed=keyring_changed,
|
|
publication_commit_sha=publication_commit_sha,
|
|
publication_tag_object_sha=publication_tag_object_sha,
|
|
publication_tag_commit_sha=publication_tag_commit_sha,
|
|
steps=steps,
|
|
notes=notes,
|
|
)
|
|
|
|
|
|
def read_json(path: Path) -> object:
|
|
return read_bounded_json_source(path, label="catalog JSON")
|
|
|
|
|
|
def validate_catalog_payload(
|
|
payload: object,
|
|
*,
|
|
require_trusted: bool,
|
|
approved_channels: tuple[str, ...],
|
|
trusted_keys: dict[str, bytes],
|
|
) -> dict[str, object]:
|
|
"""Validate exactly the in-memory object that publication will consume."""
|
|
|
|
encoded = _public_json_bytes(payload)
|
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
prefix="govoplan-catalog-validation-", suffix=".json"
|
|
)
|
|
temporary = Path(temporary_name)
|
|
try:
|
|
os.fchmod(descriptor, 0o600)
|
|
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
|
handle.write(encoded)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
return validate_module_package_catalog(
|
|
temporary,
|
|
require_trusted=require_trusted,
|
|
approved_channels=approved_channels,
|
|
trusted_keys=trusted_keys,
|
|
)
|
|
finally:
|
|
try:
|
|
os.close(descriptor)
|
|
except OSError:
|
|
pass
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
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 ValueError("published catalog object exceeds its size limit")
|
|
return encoded
|
|
|
|
|
|
def _atomic_write_public_json(
|
|
path: Path, payload: object, *, trusted_root: Path
|
|
) -> None:
|
|
"""Write through pinned directories from a trusted worktree root."""
|
|
|
|
encoded = _public_json_bytes(payload)
|
|
relative = _trusted_relative_path(path, trusted_root=trusted_root)
|
|
if len(relative.parts) < 2:
|
|
raise ValueError("published catalog target requires a parent directory")
|
|
parent_descriptor = _open_relative_directory(
|
|
trusted_root, relative.parts[:-1], create=True
|
|
)
|
|
target_name = relative.parts[-1]
|
|
try:
|
|
try:
|
|
observed = os.stat(
|
|
target_name, dir_fd=parent_descriptor, follow_symlinks=False
|
|
)
|
|
except FileNotFoundError:
|
|
observed = None
|
|
if observed is not None and not stat.S_ISREG(observed.st_mode):
|
|
raise ValueError("published catalog target must be a regular file")
|
|
temporary_name = f".{target_name}.{secrets.token_hex(16)}.tmp"
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
descriptor = os.open(temporary_name, flags, 0o600, dir_fd=parent_descriptor)
|
|
replaced = False
|
|
try:
|
|
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
|
handle.write(encoded)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(
|
|
temporary_name,
|
|
target_name,
|
|
src_dir_fd=parent_descriptor,
|
|
dst_dir_fd=parent_descriptor,
|
|
)
|
|
replaced = True
|
|
published = os.open(
|
|
target_name,
|
|
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
|
|
dir_fd=parent_descriptor,
|
|
)
|
|
try:
|
|
if not stat.S_ISREG(os.fstat(published).st_mode):
|
|
raise ValueError("published catalog target must remain regular")
|
|
os.fchmod(published, 0o644)
|
|
finally:
|
|
os.close(published)
|
|
os.fsync(parent_descriptor)
|
|
finally:
|
|
if not replaced:
|
|
try:
|
|
os.close(descriptor)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.unlink(temporary_name, dir_fd=parent_descriptor)
|
|
except FileNotFoundError:
|
|
pass
|
|
finally:
|
|
os.close(parent_descriptor)
|
|
|
|
|
|
def _trusted_relative_path(path: Path, *, trusted_root: Path) -> Path:
|
|
try:
|
|
relative = path.absolute().relative_to(trusted_root.absolute())
|
|
except ValueError as exc:
|
|
raise ValueError(
|
|
"published catalog target leaves the trusted worktree"
|
|
) from exc
|
|
if not relative.parts or any(part in {"", ".", ".."} for part in relative.parts):
|
|
raise ValueError("published catalog target is not canonical")
|
|
return relative
|
|
|
|
|
|
def _open_relative_directory(
|
|
trusted_root: Path, parts: tuple[str, ...], *, create: bool
|
|
) -> int:
|
|
root_flags = (
|
|
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
)
|
|
descriptor = os.open(trusted_root, root_flags)
|
|
try:
|
|
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
|
raise ValueError("publication worktree root must be a directory")
|
|
for part in parts:
|
|
if part in {"", ".", ".."} or "/" in part:
|
|
raise ValueError("published catalog path is not canonical")
|
|
try:
|
|
child = os.open(part, root_flags, dir_fd=descriptor)
|
|
except FileNotFoundError:
|
|
if not create:
|
|
raise
|
|
os.mkdir(part, mode=0o755, dir_fd=descriptor)
|
|
child = os.open(part, root_flags, dir_fd=descriptor)
|
|
os.close(descriptor)
|
|
descriptor = child
|
|
return descriptor
|
|
except BaseException:
|
|
os.close(descriptor)
|
|
raise
|
|
|
|
|
|
def publication_mutation_trust_issues(
|
|
*,
|
|
web_root: Path,
|
|
candidate_catalog: Path,
|
|
candidate_keyring: Path,
|
|
target_catalog: Path,
|
|
target_keyring: Path,
|
|
target_modules: Path,
|
|
) -> tuple[str, ...]:
|
|
"""Reject mutable trust inputs and unsafe website/Git ownership boundaries."""
|
|
|
|
issues: list[str] = []
|
|
for path, label in (
|
|
(web_root, "website root"),
|
|
(candidate_catalog.parent, "candidate catalog root"),
|
|
(candidate_keyring.parent, "candidate keyring root"),
|
|
):
|
|
issue = _operator_controlled_ancestor_issue(path, allow_sticky=True)
|
|
if issue:
|
|
issues.append(f"{label}: {issue}")
|
|
|
|
checks: list[tuple[Path, bool, bool, str]] = [
|
|
(web_root, True, True, "website root"),
|
|
(web_root / ".git", True, True, "website Git directory"),
|
|
(web_root / ".git" / "config", False, True, "website Git config"),
|
|
(candidate_catalog, False, True, "candidate catalog"),
|
|
(candidate_keyring, False, True, "candidate keyring"),
|
|
(target_keyring, False, True, "published trust keyring"),
|
|
(target_catalog, False, False, "published channel catalog"),
|
|
(web_root / ".git" / "HEAD", False, False, "website Git HEAD"),
|
|
(web_root / ".git" / "index", False, False, "website Git index"),
|
|
(web_root / ".git" / "objects", True, False, "website Git objects"),
|
|
(web_root / ".git" / "refs", True, False, "website Git refs"),
|
|
]
|
|
for target in (target_catalog.parent, target_keyring.parent, target_modules.parent):
|
|
try:
|
|
relative = target.absolute().relative_to(web_root.absolute())
|
|
except ValueError:
|
|
return ("publication target leaves the owned website root",)
|
|
current = web_root
|
|
for part in relative.parts:
|
|
current /= part
|
|
checks.append((current, True, False, "website publication directory"))
|
|
seen: set[Path] = set()
|
|
for path, directory, required, label in checks:
|
|
if path in seen:
|
|
continue
|
|
seen.add(path)
|
|
try:
|
|
observed = path.lstat()
|
|
except FileNotFoundError:
|
|
if required:
|
|
issues.append(f"{label} is missing")
|
|
continue
|
|
except OSError:
|
|
issues.append(f"{label} cannot be inspected safely")
|
|
continue
|
|
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
|
|
if stat.S_ISLNK(observed.st_mode) or not expected_type(observed.st_mode):
|
|
issues.append(
|
|
f"{label} must be a real {'directory' if directory else 'file'}"
|
|
)
|
|
elif observed.st_uid != os.geteuid():
|
|
issues.append(f"{label} is not owned by the current operator")
|
|
elif stat.S_IMODE(observed.st_mode) & 0o022:
|
|
issues.append(f"{label} is writable by another user")
|
|
return tuple(issues)
|
|
|
|
|
|
def publication_runtime_trust_issues() -> tuple[str, ...]:
|
|
"""Require the running meta release code/config to be operator-controlled."""
|
|
|
|
release_root = META_ROOT / "tools" / "release"
|
|
runtime_owners = {0, os.geteuid()}
|
|
package_roots: tuple[tuple[Path, str], ...] = (
|
|
(_loaded_package_root(govoplan_core, label="govoplan_core"), "GovOPlaN Core"),
|
|
(_loaded_package_root(cryptography, label="cryptography"), "cryptography"),
|
|
)
|
|
for path, label in (
|
|
(META_ROOT, "meta repository"),
|
|
(Path(sys.prefix), "Python environment"),
|
|
*package_roots,
|
|
):
|
|
ancestor_issue = _operator_controlled_ancestor_issue(path)
|
|
if ancestor_issue:
|
|
return (f"{label}: {ancestor_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"),
|
|
(release_root, True, "release tooling root"),
|
|
)
|
|
issues: list[str] = []
|
|
for path, directory, label in required:
|
|
issue = _operator_control_issue(
|
|
path,
|
|
directory=directory,
|
|
label=label,
|
|
permitted_owners=runtime_owners,
|
|
)
|
|
if issue:
|
|
issues.append(issue)
|
|
if issues:
|
|
return tuple(issues)
|
|
|
|
pending = [release_root, *(path for path, _label in package_roots)]
|
|
inspected = 0
|
|
while pending:
|
|
parent = pending.pop()
|
|
try:
|
|
entries = tuple(os.scandir(parent))
|
|
except OSError:
|
|
return ("release tooling tree cannot be inspected safely",)
|
|
for entry in entries:
|
|
inspected += 1
|
|
if inspected > 4_096:
|
|
return ("release tooling tree exceeds its trust inspection bound",)
|
|
path = Path(entry.path)
|
|
try:
|
|
observed = path.lstat()
|
|
except OSError:
|
|
return ("release tooling tree changed during trust inspection",)
|
|
if stat.S_ISDIR(observed.st_mode):
|
|
pending.append(path)
|
|
directory = True
|
|
elif stat.S_ISREG(observed.st_mode):
|
|
directory = False
|
|
else:
|
|
return (
|
|
f"release tooling path is not a real file or directory: {path}",
|
|
)
|
|
issue = _operator_control_issue(
|
|
path,
|
|
directory=directory,
|
|
label="release tooling path",
|
|
observed=observed,
|
|
permitted_owners=runtime_owners,
|
|
)
|
|
if issue:
|
|
return (issue,)
|
|
return ()
|
|
|
|
|
|
def _loaded_package_root(module: object, *, label: str) -> Path:
|
|
source = getattr(module, "__file__", None)
|
|
if not isinstance(source, str) or not source:
|
|
raise RuntimeError(f"{label} package origin cannot be identified")
|
|
return Path(source).absolute().parent
|
|
|
|
|
|
def _trusted_runtime_executable_issue(
|
|
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"{label}: {ancestor_issue}"
|
|
try:
|
|
link_metadata = executable.lstat()
|
|
except OSError:
|
|
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"{label} symlink has an untrusted owner: {executable}"
|
|
try:
|
|
target = executable.resolve(strict=True)
|
|
except OSError:
|
|
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"{label} target: {ancestor_issue}"
|
|
return _operator_control_issue(
|
|
target,
|
|
directory=False,
|
|
label=f"{label} target",
|
|
permitted_owners=permitted_owners,
|
|
)
|
|
|
|
|
|
def _operator_controlled_ancestor_issue(
|
|
path: Path, *, allow_sticky: bool = False
|
|
) -> str | None:
|
|
"""Reject a trust root that an ancestor directory can be replaced beneath."""
|
|
|
|
current = path.absolute()
|
|
permitted_owners = {0, os.geteuid()}
|
|
while True:
|
|
try:
|
|
observed = current.lstat()
|
|
except OSError:
|
|
return f"release trust-path ancestor cannot be inspected safely: {current}"
|
|
if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode):
|
|
return f"release trust-path ancestor is not a real directory: {current}"
|
|
if observed.st_uid not in permitted_owners:
|
|
return f"release trust-path ancestor has an untrusted owner: {current}"
|
|
writable_by_others = stat.S_IMODE(observed.st_mode) & 0o022
|
|
sticky_boundary = allow_sticky and bool(observed.st_mode & stat.S_ISVTX)
|
|
if writable_by_others and not sticky_boundary:
|
|
return f"release trust-path ancestor is group/world writable: {current}"
|
|
if current.parent == current:
|
|
break
|
|
current = current.parent
|
|
return None
|
|
|
|
|
|
def _operator_control_issue(
|
|
path: Path,
|
|
*,
|
|
directory: bool,
|
|
label: str,
|
|
observed: os.stat_result | None = None,
|
|
permitted_owners: set[int] | None = None,
|
|
) -> str | None:
|
|
try:
|
|
metadata = observed or path.lstat()
|
|
except OSError:
|
|
return f"{label} cannot be inspected safely: {path}"
|
|
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):
|
|
return f"{label} must be a real {'directory' if directory else 'file'}: {path}"
|
|
owners = permitted_owners or {os.geteuid()}
|
|
if metadata.st_uid not in owners:
|
|
return f"{label} is not owned by the current operator: {path}"
|
|
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
|
return f"{label} is group/world writable: {path}"
|
|
return None
|
|
|
|
|
|
def _remove_public_tree(path: Path, *, trusted_root: Path) -> None:
|
|
relative = _trusted_relative_path(path, trusted_root=trusted_root)
|
|
if len(relative.parts) < 2:
|
|
raise ValueError("published module target requires a pinned parent")
|
|
parent_descriptor = _open_relative_directory(
|
|
trusted_root, relative.parts[:-1], create=False
|
|
)
|
|
target_name = relative.parts[-1]
|
|
trash_name = f".{target_name}.{secrets.token_hex(16)}.trash"
|
|
try:
|
|
try:
|
|
observed = os.stat(
|
|
target_name, dir_fd=parent_descriptor, follow_symlinks=False
|
|
)
|
|
except FileNotFoundError:
|
|
return
|
|
if not stat.S_ISDIR(observed.st_mode):
|
|
raise ValueError("published module target must be a real directory")
|
|
os.rename(
|
|
target_name,
|
|
trash_name,
|
|
src_dir_fd=parent_descriptor,
|
|
dst_dir_fd=parent_descriptor,
|
|
)
|
|
trash_descriptor = os.open(
|
|
trash_name,
|
|
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
dir_fd=parent_descriptor,
|
|
)
|
|
try:
|
|
trash_device = os.fstat(trash_descriptor).st_dev
|
|
os.fchmod(trash_descriptor, 0o700)
|
|
finally:
|
|
os.close(trash_descriptor)
|
|
_remove_tree_at(
|
|
parent_descriptor,
|
|
trash_name,
|
|
expected_device=trash_device,
|
|
remaining=[_MAX_REMOVED_TREE_ENTRIES],
|
|
depth=0,
|
|
)
|
|
os.fsync(parent_descriptor)
|
|
finally:
|
|
os.close(parent_descriptor)
|
|
|
|
|
|
def _remove_tree_at(
|
|
parent_descriptor: int,
|
|
name: str,
|
|
*,
|
|
expected_device: int,
|
|
remaining: list[int],
|
|
depth: int,
|
|
) -> None:
|
|
"""Remove a renamed private tree without resolving a mutable parent path."""
|
|
|
|
directory_flags = (
|
|
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
)
|
|
if depth > _MAX_REMOVED_TREE_DEPTH:
|
|
raise RuntimeError("published module tree exceeds its removal depth bound")
|
|
descriptor = os.open(name, directory_flags, dir_fd=parent_descriptor)
|
|
try:
|
|
if os.fstat(descriptor).st_dev != expected_device:
|
|
raise RuntimeError("published module tree crosses a mounted filesystem")
|
|
with os.scandir(descriptor) as entries:
|
|
for entry in entries:
|
|
remaining[0] -= 1
|
|
if remaining[0] < 0:
|
|
raise RuntimeError(
|
|
"published module tree exceeds its removal entry bound"
|
|
)
|
|
child_name = entry.name
|
|
observed = os.stat(child_name, dir_fd=descriptor, follow_symlinks=False)
|
|
if stat.S_ISDIR(observed.st_mode):
|
|
if observed.st_dev != expected_device:
|
|
raise RuntimeError(
|
|
"published module tree crosses a mounted filesystem"
|
|
)
|
|
_remove_tree_at(
|
|
descriptor,
|
|
child_name,
|
|
expected_device=expected_device,
|
|
remaining=remaining,
|
|
depth=depth + 1,
|
|
)
|
|
else:
|
|
os.unlink(child_name, dir_fd=descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
os.rmdir(name, dir_fd=parent_descriptor)
|
|
|
|
|
|
def _expected_publication_blobs(
|
|
*,
|
|
web_root: Path,
|
|
target_catalog: Path,
|
|
candidate_payload: object,
|
|
target_keyring: Path,
|
|
keyring_payload: object,
|
|
module_root: Path,
|
|
directory_payloads: tuple[tuple[Path, dict[str, object]], ...],
|
|
) -> dict[str, bytes]:
|
|
expected = {
|
|
_trusted_relative_path(
|
|
target_catalog, trusted_root=web_root
|
|
).as_posix(): _public_json_bytes(candidate_payload),
|
|
_trusted_relative_path(
|
|
target_keyring, trusted_root=web_root
|
|
).as_posix(): _public_json_bytes(keyring_payload),
|
|
}
|
|
for relative_path, payload in directory_payloads:
|
|
target = module_root / relative_path
|
|
repository_path = _trusted_relative_path(
|
|
target, trusted_root=web_root
|
|
).as_posix()
|
|
if repository_path in expected:
|
|
raise ValueError("publication output paths are ambiguous")
|
|
expected[repository_path] = _public_json_bytes(payload)
|
|
return expected
|
|
|
|
|
|
def committed_publication_sha(web_root: Path) -> str:
|
|
commit_sha = git_text(web_root, "rev-parse", "--verify", "HEAD^{commit}")
|
|
if _GIT_OBJECT.fullmatch(commit_sha) is None:
|
|
raise RuntimeError("could not resolve the publication commit")
|
|
return commit_sha
|
|
|
|
|
|
def commit_publication_tree(
|
|
*,
|
|
web_root: Path,
|
|
frozen_head: str,
|
|
branch: str,
|
|
expected_blobs: dict[str, bytes],
|
|
module_root: Path,
|
|
message: str,
|
|
) -> str:
|
|
"""Build one exact commit from memory through a private index and no hooks."""
|
|
|
|
if _GIT_OBJECT.fullmatch(frozen_head) is None:
|
|
raise RuntimeError("publication parent identity is malformed")
|
|
module_prefix = _trusted_relative_path(
|
|
module_root, trusted_root=web_root
|
|
).as_posix()
|
|
expected_modules = {
|
|
path for path in expected_blobs if path.startswith(f"{module_prefix}/")
|
|
}
|
|
old_modules = _git_tree_paths(web_root, frozen_head, module_prefix)
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="govoplan-publication-index-"
|
|
) as temporary_directory:
|
|
index_name = str(Path(temporary_directory) / "index")
|
|
environment = _sanitized_git_environment(private_index=index_name)
|
|
_git_checked(
|
|
web_root,
|
|
"read-tree",
|
|
frozen_head,
|
|
environment=environment,
|
|
)
|
|
for path, encoded in sorted(expected_blobs.items()):
|
|
object_id = (
|
|
_git_bytes(
|
|
web_root,
|
|
"hash-object",
|
|
"-w",
|
|
"--stdin",
|
|
input_bytes=encoded,
|
|
)
|
|
.decode("ascii")
|
|
.strip()
|
|
)
|
|
if _GIT_OBJECT.fullmatch(object_id) is None:
|
|
raise RuntimeError("Git returned an invalid publication blob identity")
|
|
_git_checked(
|
|
web_root,
|
|
"update-index",
|
|
"--add",
|
|
"--cacheinfo",
|
|
"100644",
|
|
object_id,
|
|
path,
|
|
environment=environment,
|
|
)
|
|
for path in sorted(old_modules - expected_modules):
|
|
_git_checked(
|
|
web_root,
|
|
"update-index",
|
|
"--force-remove",
|
|
"--",
|
|
path,
|
|
environment=environment,
|
|
)
|
|
tree_sha = _git_text_checked(web_root, "write-tree", environment=environment)
|
|
if _GIT_OBJECT.fullmatch(tree_sha) is None:
|
|
raise RuntimeError("Git returned an invalid publication tree identity")
|
|
_verify_treeish_publication(
|
|
web_root=web_root,
|
|
treeish=tree_sha,
|
|
expected_blobs=expected_blobs,
|
|
module_root=module_root,
|
|
)
|
|
expected_delta = _expected_catalog_delta_paths(
|
|
web_root=web_root,
|
|
frozen_head=frozen_head,
|
|
expected_blobs=expected_blobs,
|
|
old_modules=old_modules,
|
|
expected_modules=expected_modules,
|
|
)
|
|
observed_delta = _git_diff_paths(web_root, frozen_head, tree_sha)
|
|
if observed_delta != expected_delta:
|
|
raise RuntimeError(
|
|
"publication tree contains paths outside the computed catalog delta"
|
|
)
|
|
commit_sha = _git_text_checked(
|
|
web_root,
|
|
"-c",
|
|
"commit.gpgSign=false",
|
|
"commit-tree",
|
|
tree_sha,
|
|
"-p",
|
|
frozen_head,
|
|
input_bytes=(message + "\n").encode("utf-8"),
|
|
)
|
|
if _GIT_OBJECT.fullmatch(commit_sha) is None:
|
|
raise RuntimeError("Git returned an invalid publication commit identity")
|
|
commit_line = _git_text_checked(
|
|
web_root, "rev-list", "--parents", "-n", "1", commit_sha
|
|
)
|
|
if commit_line.split() != [commit_sha, frozen_head]:
|
|
raise RuntimeError("publication commit does not have the sole frozen parent")
|
|
commit_tree = _git_text_checked(web_root, "show", "-s", "--format=%T", commit_sha)
|
|
if commit_tree != tree_sha:
|
|
raise RuntimeError("publication commit tree differs from the verified tree")
|
|
symbolic_head = _git_text_checked(web_root, "symbolic-ref", "--quiet", "HEAD")
|
|
current_head = _git_text_checked(web_root, "rev-parse", "--verify", "HEAD^{commit}")
|
|
if symbolic_head != f"refs/heads/{branch}" or current_head != frozen_head:
|
|
raise RuntimeError("website HEAD changed after publication was frozen")
|
|
_git_checked(
|
|
web_root,
|
|
"-c",
|
|
"core.hooksPath=/dev/null",
|
|
"update-ref",
|
|
f"refs/heads/{branch}",
|
|
commit_sha,
|
|
frozen_head,
|
|
)
|
|
_git_checked(web_root, "read-tree", "--reset", commit_sha)
|
|
return commit_sha
|
|
|
|
|
|
def verify_committed_publication(
|
|
*,
|
|
web_root: Path,
|
|
commit_sha: str,
|
|
expected_blobs: dict[str, bytes],
|
|
module_root: Path,
|
|
) -> None:
|
|
"""Require the exact validated objects in one immutable commit tree."""
|
|
|
|
if _GIT_OBJECT.fullmatch(commit_sha) is None:
|
|
raise RuntimeError("publication commit identity is malformed")
|
|
_verify_treeish_publication(
|
|
web_root=web_root,
|
|
treeish=commit_sha,
|
|
expected_blobs=expected_blobs,
|
|
module_root=module_root,
|
|
)
|
|
|
|
|
|
def _verify_treeish_publication(
|
|
*,
|
|
web_root: Path,
|
|
treeish: str,
|
|
expected_blobs: dict[str, bytes],
|
|
module_root: Path,
|
|
) -> None:
|
|
module_prefix = _trusted_relative_path(
|
|
module_root, trusted_root=web_root
|
|
).as_posix()
|
|
expected_modules = {
|
|
path for path in expected_blobs if path.startswith(f"{module_prefix}/")
|
|
}
|
|
tree = _git_bytes(
|
|
web_root,
|
|
"ls-tree",
|
|
"-r",
|
|
"--name-only",
|
|
"-z",
|
|
treeish,
|
|
"--",
|
|
module_prefix,
|
|
)
|
|
observed_modules = {item.decode("utf-8") for item in tree.split(b"\0") if item}
|
|
if observed_modules != expected_modules:
|
|
raise RuntimeError(
|
|
"committed module directory differs from validated publication outputs"
|
|
)
|
|
for repository_path, expected in expected_blobs.items():
|
|
entry = _git_bytes(
|
|
web_root,
|
|
"ls-tree",
|
|
"-z",
|
|
treeish,
|
|
"--",
|
|
repository_path,
|
|
)
|
|
metadata, separator, encoded_path = entry.rstrip(b"\0").partition(b"\t")
|
|
fields = metadata.split()
|
|
if (
|
|
not separator
|
|
or encoded_path.decode("utf-8") != repository_path
|
|
or len(fields) != 3
|
|
or fields[0] != b"100644"
|
|
or fields[1] != b"blob"
|
|
or re.fullmatch(rb"[0-9a-f]{40,64}", fields[2]) is None
|
|
):
|
|
raise RuntimeError(
|
|
f"committed publication path is not one regular blob: {repository_path}"
|
|
)
|
|
observed = _git_bytes(web_root, "cat-file", "blob", fields[2].decode("ascii"))
|
|
if observed != expected:
|
|
raise RuntimeError(
|
|
f"committed publication blob differs from validated output: {repository_path}"
|
|
)
|
|
|
|
|
|
def _expected_catalog_delta_paths(
|
|
*,
|
|
web_root: Path,
|
|
frozen_head: str,
|
|
expected_blobs: dict[str, bytes],
|
|
old_modules: set[str],
|
|
expected_modules: set[str],
|
|
) -> set[str]:
|
|
changed = set(old_modules - expected_modules)
|
|
for path, expected in expected_blobs.items():
|
|
observed = _git_tree_blob(web_root, frozen_head, path)
|
|
if observed is None or observed != (b"100644", expected):
|
|
changed.add(path)
|
|
return changed
|
|
|
|
|
|
def _git_tree_paths(web_root: Path, treeish: str, prefix: str) -> set[str]:
|
|
encoded = _git_bytes(
|
|
web_root,
|
|
"ls-tree",
|
|
"-r",
|
|
"--name-only",
|
|
"-z",
|
|
treeish,
|
|
"--",
|
|
prefix,
|
|
)
|
|
return {item.decode("utf-8") for item in encoded.split(b"\0") if item}
|
|
|
|
|
|
def _git_tree_blob(
|
|
web_root: Path, treeish: str, repository_path: str
|
|
) -> tuple[bytes, bytes] | None:
|
|
entry = _git_bytes(web_root, "ls-tree", "-z", treeish, "--", repository_path)
|
|
if not entry:
|
|
return None
|
|
metadata, separator, encoded_path = entry.rstrip(b"\0").partition(b"\t")
|
|
fields = metadata.split()
|
|
if (
|
|
not separator
|
|
or encoded_path.decode("utf-8") != repository_path
|
|
or len(fields) != 3
|
|
or fields[1] != b"blob"
|
|
or re.fullmatch(rb"[0-9a-f]{40,64}", fields[2]) is None
|
|
):
|
|
return None
|
|
return fields[0], _git_bytes(
|
|
web_root, "cat-file", "blob", fields[2].decode("ascii")
|
|
)
|
|
|
|
|
|
def _git_diff_paths(web_root: Path, before: str, after: str) -> set[str]:
|
|
encoded = _git_bytes(
|
|
web_root,
|
|
"diff-tree",
|
|
"--no-renames",
|
|
"--no-ext-diff",
|
|
"--no-commit-id",
|
|
"--name-only",
|
|
"-r",
|
|
"-z",
|
|
before,
|
|
after,
|
|
)
|
|
return {item.decode("utf-8") for item in encoded.split(b"\0") if item}
|
|
|
|
|
|
def registered_website_remote() -> str:
|
|
"""Return the sole website endpoint registered for release publication."""
|
|
|
|
websites = tuple(
|
|
spec
|
|
for spec in load_repository_specs(include_website=True)
|
|
if spec.category == "website"
|
|
)
|
|
if len(websites) != 1:
|
|
raise RuntimeError("release configuration must register one website repository")
|
|
return _validated_remote_url(websites[0].remote)
|
|
|
|
|
|
def bind_publication_remote(
|
|
*, web_root: Path, remote: str, registered_remote: str
|
|
) -> FrozenPublicationRemote:
|
|
"""Bind one local remote name to the exact registered fetch/push URL."""
|
|
|
|
registered = _validated_remote_url(registered_remote)
|
|
fetch_urls = _configured_remote_urls(web_root, remote=remote, push=False)
|
|
push_urls = _configured_remote_urls(web_root, remote=remote, push=True)
|
|
if fetch_urls != (registered,) or push_urls != (registered,):
|
|
raise RuntimeError(
|
|
"configured website fetch and push URLs do not match the registered release remote"
|
|
)
|
|
digest = hashlib.sha256(
|
|
json.dumps(
|
|
{"fetch": fetch_urls, "push": push_urls},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=True,
|
|
).encode("utf-8")
|
|
).hexdigest()
|
|
return FrozenPublicationRemote(url=registered, sha256=digest)
|
|
|
|
|
|
def verify_remote_branch_head(
|
|
*, web_root: Path, remote_url: str, branch: str, expected_commit: str
|
|
) -> None:
|
|
"""Prove the remote branch still identifies the frozen publication parent."""
|
|
|
|
if _GIT_OBJECT.fullmatch(expected_commit) is None:
|
|
raise RuntimeError("frozen website commit identity is malformed")
|
|
branch_ref = _validated_git_ref(
|
|
web_root, f"refs/heads/{branch}", label="website branch"
|
|
)
|
|
observed = _remote_ref_identities(
|
|
web_root=web_root,
|
|
remote_url=remote_url,
|
|
refs=(branch_ref,),
|
|
)
|
|
if observed != {branch_ref: expected_commit}:
|
|
raise RuntimeError(
|
|
"website HEAD is not identical to the remote publication branch"
|
|
)
|
|
|
|
|
|
def remote_publication_identity(
|
|
*, web_root: Path, remote_url: str, branch: str, tag_name: str
|
|
) -> dict[str, str]:
|
|
"""Read branch, annotated-tag object, and peeled commit from the remote."""
|
|
|
|
branch_ref = _validated_git_ref(
|
|
web_root, f"refs/heads/{branch}", label="website branch"
|
|
)
|
|
tag_ref = _validated_git_ref(
|
|
web_root, f"refs/tags/{tag_name}", label="website publication tag"
|
|
)
|
|
peeled_ref = f"{tag_ref}^{{}}"
|
|
observed = _remote_ref_identities(
|
|
web_root=web_root,
|
|
remote_url=remote_url,
|
|
refs=(branch_ref, tag_ref, peeled_ref),
|
|
)
|
|
if set(observed) != {branch_ref, tag_ref, peeled_ref}:
|
|
raise RuntimeError(
|
|
"remote publication does not expose one annotated tag identity"
|
|
)
|
|
return {
|
|
"publication_commit_sha": observed[branch_ref],
|
|
"publication_tag_object_sha": observed[tag_ref],
|
|
"publication_tag_commit_sha": observed[peeled_ref],
|
|
}
|
|
|
|
|
|
def verify_remote_publication(
|
|
*,
|
|
web_root: Path,
|
|
remote_url: str,
|
|
branch: str,
|
|
tag_name: str,
|
|
expected_commit: str,
|
|
expected_tag_object: str,
|
|
) -> dict[str, str]:
|
|
"""Independently prove the exact branch and annotated tag after a push."""
|
|
|
|
if (
|
|
_GIT_OBJECT.fullmatch(expected_commit) is None
|
|
or _GIT_OBJECT.fullmatch(expected_tag_object) is None
|
|
):
|
|
raise RuntimeError("expected publication identity is malformed")
|
|
identity = remote_publication_identity(
|
|
web_root=web_root,
|
|
remote_url=remote_url,
|
|
branch=branch,
|
|
tag_name=tag_name,
|
|
)
|
|
if identity != {
|
|
"publication_commit_sha": expected_commit,
|
|
"publication_tag_object_sha": expected_tag_object,
|
|
"publication_tag_commit_sha": expected_commit,
|
|
}:
|
|
raise RuntimeError(
|
|
"remote publication identity differs from the verified objects"
|
|
)
|
|
return identity
|
|
|
|
|
|
def _configured_remote_urls(
|
|
web_root: Path, *, remote: str, push: bool
|
|
) -> tuple[str, ...]:
|
|
arguments = ["remote", "get-url"]
|
|
if push:
|
|
arguments.append("--push")
|
|
arguments.extend(["--all", remote])
|
|
encoded = _git_bytes(
|
|
web_root,
|
|
*arguments,
|
|
max_output_bytes=_MAX_REMOTE_METADATA_BYTES,
|
|
)
|
|
try:
|
|
values = tuple(
|
|
_validated_remote_url(line)
|
|
for line in encoded.decode("utf-8", errors="strict").splitlines()
|
|
if line
|
|
)
|
|
except UnicodeError as exc:
|
|
raise RuntimeError("configured website remote is not valid UTF-8") from exc
|
|
if not values or len(values) > 16:
|
|
raise RuntimeError("configured website remote URL set is not bounded")
|
|
return values
|
|
|
|
|
|
def _validated_remote_url(value: str) -> str:
|
|
if not isinstance(value, str):
|
|
raise RuntimeError("website remote URL must be text")
|
|
try:
|
|
encoded = value.encode("utf-8", errors="strict")
|
|
except UnicodeError as exc:
|
|
raise RuntimeError("website remote URL is not valid UTF-8") from exc
|
|
if (
|
|
not value
|
|
or value.startswith("-")
|
|
or value != value.strip()
|
|
or len(encoded) > _MAX_REMOTE_METADATA_BYTES
|
|
or any(
|
|
character.isspace() or not character.isprintable() for character in value
|
|
)
|
|
):
|
|
raise RuntimeError("website remote URL is not a bounded printable endpoint")
|
|
return value
|
|
|
|
|
|
def _validated_git_ref(web_root: Path, value: str, *, label: str) -> str:
|
|
if len(value.encode("utf-8")) > 512:
|
|
raise RuntimeError(f"{label.capitalize()} is not a bounded Git ref")
|
|
_git_checked(
|
|
web_root,
|
|
"check-ref-format",
|
|
value,
|
|
max_output_bytes=_MAX_REMOTE_METADATA_BYTES,
|
|
)
|
|
return value
|
|
|
|
|
|
def _remote_ref_identities(
|
|
*, web_root: Path, remote_url: str, refs: tuple[str, ...]
|
|
) -> dict[str, str]:
|
|
endpoint = _validated_remote_url(remote_url)
|
|
encoded = _git_bytes(
|
|
web_root,
|
|
"ls-remote",
|
|
endpoint,
|
|
*refs,
|
|
max_output_bytes=_MAX_REMOTE_METADATA_BYTES,
|
|
isolate_config=True,
|
|
)
|
|
observed: dict[str, str] = {}
|
|
try:
|
|
lines = encoded.decode("utf-8", errors="strict").splitlines()
|
|
except UnicodeError as exc:
|
|
raise RuntimeError("remote Git identities are not valid UTF-8") from exc
|
|
if len(lines) > max(16, len(refs) * 2):
|
|
raise RuntimeError("remote Git identity response is not bounded")
|
|
allowed = set(refs)
|
|
for line in lines:
|
|
object_id, separator, ref = line.partition("\t")
|
|
if (
|
|
separator != "\t"
|
|
or ref not in allowed
|
|
or ref in observed
|
|
or _GIT_OBJECT.fullmatch(object_id) is None
|
|
):
|
|
raise RuntimeError("remote Git identity response is ambiguous")
|
|
observed[ref] = object_id
|
|
return observed
|
|
|
|
|
|
def website_dirty(path: Path) -> bool:
|
|
if not (path / ".git").exists():
|
|
return False
|
|
return bool(git_text(path, "status", "--porcelain"))
|
|
|
|
|
|
def default_tag_name(payload: object, *, channel: str) -> str | None:
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
sequence = payload.get("sequence")
|
|
release = payload.get("release")
|
|
selected_units = (
|
|
release.get("selected_units") if isinstance(release, dict) else None
|
|
)
|
|
if (
|
|
isinstance(selected_units, list)
|
|
and len(selected_units) == 1
|
|
and isinstance(selected_units[0], dict)
|
|
):
|
|
repo = str(selected_units[0].get("repo") or "catalog")
|
|
version = str(selected_units[0].get("version") or "").removeprefix("v")
|
|
if version:
|
|
return f"catalog-{channel}-{repo}-v{version}"
|
|
return f"catalog-{channel}-{sequence}" if sequence is not None else None
|
|
|
|
|
|
def commit_message(payload: object, *, channel: str) -> str:
|
|
if isinstance(payload, dict):
|
|
release = payload.get("release")
|
|
selected_units = (
|
|
release.get("selected_units") if isinstance(release, dict) else None
|
|
)
|
|
if isinstance(selected_units, list) and selected_units:
|
|
units = ", ".join(
|
|
f"{item.get('repo')} v{str(item.get('version') or '').removeprefix('v')}"
|
|
for item in selected_units
|
|
if isinstance(item, dict)
|
|
)
|
|
if units:
|
|
return f"Publish {channel} catalog for {units}"
|
|
return f"Publish {channel} catalog"
|
|
|
|
|
|
def push_command(
|
|
web_root: Path, *, remote: str, branch: str | None, tag_name: str | None
|
|
) -> str:
|
|
if tag_name:
|
|
return shlex.join(
|
|
[
|
|
"git",
|
|
"-C",
|
|
str(web_root),
|
|
"push",
|
|
"--atomic",
|
|
remote,
|
|
f"HEAD:refs/heads/{branch or '<branch>'}",
|
|
f"refs/tags/{tag_name}",
|
|
]
|
|
)
|
|
return shlex.join(
|
|
[
|
|
"git",
|
|
"-C",
|
|
str(web_root),
|
|
"push",
|
|
remote,
|
|
f"HEAD:refs/heads/{branch or '<branch>'}",
|
|
]
|
|
)
|
|
|
|
|
|
def replace_status(step: CatalogPublishStep, status: str) -> CatalogPublishStep:
|
|
return CatalogPublishStep(
|
|
id=step.id,
|
|
title=step.title,
|
|
detail=step.detail,
|
|
command=step.command,
|
|
mutating=step.mutating,
|
|
status=status,
|
|
)
|
|
|
|
|
|
def _sanitized_git_environment(
|
|
*,
|
|
private_index: str | None = None,
|
|
isolate_config: bool = False,
|
|
base: dict[str, str] | None = None,
|
|
) -> dict[str, str]:
|
|
"""Remove caller-controlled Git redirection before invoking Git."""
|
|
|
|
source = base if base is not None else os.environ
|
|
environment = {
|
|
key: source[key]
|
|
for key in ("HOME", "LANG", "LC_ALL", "LC_CTYPE", "SSH_AUTH_SOCK")
|
|
if source.get(key)
|
|
}
|
|
environment.update(
|
|
{
|
|
"GIT_CONFIG_COUNT": "0",
|
|
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
"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:
|
|
environment["GIT_CONFIG"] = os.devnull
|
|
if private_index is not None:
|
|
index_path = Path(private_index)
|
|
if not index_path.is_absolute():
|
|
raise RuntimeError("private publication index path must be absolute")
|
|
parent = index_path.parent.lstat()
|
|
if (
|
|
not stat.S_ISDIR(parent.st_mode)
|
|
or parent.st_uid != os.geteuid()
|
|
or stat.S_IMODE(parent.st_mode) & 0o077
|
|
):
|
|
raise RuntimeError("private publication index directory is not private")
|
|
environment["GIT_INDEX_FILE"] = str(index_path)
|
|
return environment
|
|
|
|
|
|
def _git_bytes(
|
|
path: Path,
|
|
*args: str,
|
|
input_bytes: bytes | None = None,
|
|
environment: dict[str, str] | None = None,
|
|
max_output_bytes: int = _MAX_GIT_OUTPUT_BYTES,
|
|
isolate_config: bool = False,
|
|
) -> bytes:
|
|
private_index = environment.get("GIT_INDEX_FILE") if environment else None
|
|
process_environment = _sanitized_git_environment(
|
|
private_index=private_index,
|
|
isolate_config=isolate_config,
|
|
base=environment,
|
|
)
|
|
result = subprocess.run(
|
|
[
|
|
"/usr/bin/git",
|
|
"-C",
|
|
str(path),
|
|
"-c",
|
|
"core.fsmonitor=false",
|
|
"-c",
|
|
"core.hooksPath=/dev/null",
|
|
*args,
|
|
],
|
|
check=False,
|
|
input=input_bytes,
|
|
env=process_environment,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
timeout=120,
|
|
)
|
|
if len(result.stdout) > max_output_bytes or len(result.stderr) > max_output_bytes:
|
|
raise RuntimeError("Git command output exceeded its safety bound")
|
|
if result.returncode != 0:
|
|
raise subprocess.CalledProcessError(
|
|
result.returncode,
|
|
result.args,
|
|
output=result.stdout,
|
|
stderr=result.stderr,
|
|
)
|
|
return result.stdout
|
|
|
|
|
|
def _git_checked(
|
|
path: Path,
|
|
*args: str,
|
|
input_bytes: bytes | None = None,
|
|
environment: dict[str, str] | None = None,
|
|
max_output_bytes: int = _MAX_GIT_OUTPUT_BYTES,
|
|
isolate_config: bool = False,
|
|
) -> None:
|
|
_git_bytes(
|
|
path,
|
|
*args,
|
|
input_bytes=input_bytes,
|
|
environment=environment,
|
|
max_output_bytes=max_output_bytes,
|
|
isolate_config=isolate_config,
|
|
)
|
|
|
|
|
|
def _git_text_checked(
|
|
path: Path,
|
|
*args: str,
|
|
input_bytes: bytes | None = None,
|
|
environment: dict[str, str] | None = None,
|
|
max_output_bytes: int = _MAX_GIT_OUTPUT_BYTES,
|
|
isolate_config: bool = False,
|
|
) -> str:
|
|
try:
|
|
return (
|
|
_git_bytes(
|
|
path,
|
|
*args,
|
|
input_bytes=input_bytes,
|
|
environment=environment,
|
|
max_output_bytes=max_output_bytes,
|
|
isolate_config=isolate_config,
|
|
)
|
|
.decode("utf-8", errors="strict")
|
|
.strip()
|
|
)
|
|
except UnicodeError as exc:
|
|
raise RuntimeError("Git command output is not valid UTF-8") from exc
|
|
|
|
|
|
def git_text(path: Path, *args: str) -> str:
|
|
try:
|
|
return _git_text_checked(path, *args)
|
|
except (OSError, RuntimeError, subprocess.SubprocessError, UnicodeError):
|
|
return ""
|
|
|
|
|
|
def git_success(path: Path, *args: str) -> bool:
|
|
try:
|
|
_git_checked(path, *args)
|
|
except (OSError, RuntimeError, subprocess.SubprocessError, UnicodeError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def run_checked(args: list[str], *, cwd: Path) -> None:
|
|
result = subprocess.run(
|
|
args,
|
|
cwd=cwd,
|
|
env=_sanitized_git_environment(),
|
|
check=False,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
timeout=600,
|
|
)
|
|
if (
|
|
len(result.stdout) > _MAX_GIT_OUTPUT_BYTES
|
|
or len(result.stderr) > _MAX_GIT_OUTPUT_BYTES
|
|
):
|
|
raise RuntimeError("publication command output exceeded its safety bound")
|
|
if result.returncode != 0:
|
|
raise subprocess.CalledProcessError(
|
|
result.returncode,
|
|
result.args,
|
|
output=result.stdout,
|
|
stderr=result.stderr,
|
|
)
|