Harden release artifact publication
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user