Harden release artifact publication

This commit is contained in:
2026-07-22 20:41:42 +02:00
parent 35c346a1fa
commit 4dc0c8d013
5 changed files with 1057 additions and 20 deletions

View File

@@ -4,16 +4,23 @@ from __future__ import annotations
from datetime import UTC, datetime
import json
import os
from pathlib import Path
import re
import secrets
import shlex
import shutil
import stat
import subprocess
import tempfile
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 write_module_directory
from .module_directory import module_directory_payloads
from .selective_catalog import trusted_keys_from_keyring
from .source_provenance import catalog_source_selection, source_tag_provenance_issues
from .version_alignment import candidate_catalog_version_issues
@@ -38,6 +45,7 @@ def publish_catalog_candidate(
tag_name: str | None = None,
allow_dirty_website: bool = False,
) -> CatalogPublishResult:
channel = validate_release_channel(channel)
workspace = resolve_workspace_root(workspace_root or DEFAULT_WORKSPACE_ROOT)
candidate_root = Path(candidate_dir).expanduser()
if not candidate_root.is_absolute():
@@ -75,6 +83,8 @@ def publish_catalog_candidate(
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}; "
@@ -86,6 +96,16 @@ def publish_catalog_candidate(
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,
@@ -117,8 +137,8 @@ def publish_catalog_candidate(
blockers.append(
"candidate keyring differs from the publication trust anchor without a matching signed keyring_sha256"
)
validation = validate_module_package_catalog(
candidate_catalog,
validation = validate_catalog_payload(
candidate_payload,
require_trusted=True,
approved_channels=(channel,),
trusted_keys=publication_trust,
@@ -264,23 +284,42 @@ def publish_catalog_candidate(
notes=tuple(notes),
)
target_catalog.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(candidate_catalog, target_catalog)
shutil.copy2(candidate_keyring, target_keyring)
if target_modules.exists():
shutil.rmtree(target_modules)
write_module_directory(
directory_payloads = module_directory_payloads(
catalog_payload=candidate_payload,
keyring_payload=keyring_payload,
output_root=target_catalog.parent.parent,
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:
add_paths = ["git", "-C", str(resolved_web_root), "add", str(target_catalog), str(target_keyring)]
if target_modules.exists():
@@ -292,15 +331,37 @@ def publish_catalog_candidate(
else:
run_checked(["git", "-C", str(resolved_web_root), "commit", "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
completed_steps = [replace_status(step, "done") if step.id == "commit" else step for step in completed_steps]
if tag and effective_tag_name:
run_checked(["git", "-C", str(resolved_web_root), "tag", "-a", effective_tag_name, "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
publication_commit = committed_publication_sha(resolved_web_root)
verify_committed_publication(
web_root=resolved_web_root,
commit_sha=publication_commit,
expected_blobs=expected_blobs,
module_root=target_modules,
)
publication_tag_object: str | None = None
if tag and effective_tag_name and publication_commit:
run_checked(["git", "-C", str(resolved_web_root), "tag", "-a", effective_tag_name, publication_commit, "-m", commit_message(candidate_payload, channel=channel)], cwd=resolved_web_root)
publication_tag_object = git_text(
resolved_web_root, "rev-parse", "--verify", f"refs/tags/{effective_tag_name}"
)
tagged_commit = git_text(
resolved_web_root,
"rev-parse",
"--verify",
f"refs/tags/{effective_tag_name}^{{commit}}",
)
if (
re.fullmatch(r"[0-9a-f]{40,64}", publication_tag_object) is None
or tagged_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:
if push and effective_branch and publication_commit:
push_args = ["git", "-C", str(resolved_web_root), "push"]
if tag and effective_tag_name:
push_args.extend(["--atomic", remote, f"HEAD:refs/heads/{effective_branch}", f"refs/tags/{effective_tag_name}"])
if tag and effective_tag_name and publication_tag_object:
push_args.extend(["--atomic", remote, f"{publication_commit}:refs/heads/{effective_branch}", f"{publication_tag_object}:refs/tags/{effective_tag_name}"])
else:
push_args.extend([remote, f"HEAD:refs/heads/{effective_branch}"])
push_args.extend([remote, f"{publication_commit}:refs/heads/{effective_branch}"])
run_checked(push_args, cwd=resolved_web_root)
completed_steps = [replace_status(step, "done") if step.id == "push" else step for step in completed_steps]
@@ -394,6 +455,300 @@ def read_json(path: Path) -> object:
return json.loads(path.read_text(encoding="utf-8"))
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 _remove_public_tree(path: Path, *, trusted_root: Path) -> None:
_trusted_relative_path(path, trusted_root=trusted_root)
if not shutil.rmtree.avoids_symlink_attacks:
raise RuntimeError("safe directory removal is unavailable on this platform")
try:
mode = path.lstat().st_mode
except FileNotFoundError:
return
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
raise ValueError("published module target must be a real directory")
shutil.rmtree(path)
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 re.fullmatch(r"[0-9a-f]{40,64}", commit_sha) is None:
raise RuntimeError("could not resolve the publication commit")
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 re.fullmatch(r"[0-9a-f]{40,64}", commit_sha) is None:
raise RuntimeError("publication commit 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}/")
}
tree = subprocess.run(
[
"git",
"-C",
str(web_root),
"ls-tree",
"-r",
"--name-only",
"-z",
commit_sha,
"--",
module_prefix,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
).stdout
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 = subprocess.run(
[
"git",
"-C",
str(web_root),
"ls-tree",
"-z",
commit_sha,
"--",
repository_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
).stdout
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 = subprocess.run(
["git", "-C", str(web_root), "cat-file", "blob", fields[2].decode()],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
).stdout
if observed != expected:
raise RuntimeError(
f"committed publication blob differs from validated output: {repository_path}"
)
def _reject_symlink_components(path: Path) -> None:
absolute = path.absolute()
for component in reversed((absolute, *absolute.parents)):
try:
mode = component.lstat().st_mode
except FileNotFoundError:
continue
if stat.S_ISLNK(mode):
raise ValueError("published catalog path must not traverse symlinks")
def file_json_hash(path: Path) -> str | None:
if not path.exists():
return None