Add private release candidate handles
This commit is contained in:
254
tools/release/govoplan_release/candidate_artifact.py
Normal file
254
tools/release/govoplan_release/candidate_artifact.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Opaque, workspace-scoped handles for generated catalog candidates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
from typing import Iterable
|
||||
|
||||
from .catalog import canonical_hash
|
||||
|
||||
|
||||
MAX_CATALOG_BYTES = 16 * 1024 * 1024
|
||||
_CANDIDATE_ID = re.compile(r"^candidate-[0-9a-f]{32}$")
|
||||
_CHANNEL = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
class CandidateArtifactError(ValueError):
|
||||
"""A candidate handle or the artifact behind it is unsafe or inconsistent."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CandidateArtifactReceipt:
|
||||
candidate_id: str
|
||||
catalog_sha256: str
|
||||
|
||||
|
||||
def issue_candidate_id(*seed_parts: str) -> str:
|
||||
"""Derive an opaque, deterministic candidate basename before output exists."""
|
||||
|
||||
if not seed_parts or any(
|
||||
not isinstance(part, str) or not part or len(part) > 512
|
||||
for part in seed_parts
|
||||
):
|
||||
raise CandidateArtifactError("candidate ID seeds must be non-empty bounded strings")
|
||||
encoded = json.dumps(
|
||||
list(seed_parts), sort_keys=False, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("utf-8")
|
||||
return f"candidate-{hashlib.sha256(encoded).hexdigest()[:32]}"
|
||||
|
||||
|
||||
def validate_release_channel(value: str) -> str:
|
||||
"""Return one bounded channel basename or reject path-capable input."""
|
||||
|
||||
if not isinstance(value, str) or _CHANNEL.fullmatch(value) is None:
|
||||
raise CandidateArtifactError(
|
||||
"release channel must be a bounded lowercase identifier"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def candidate_output_path(root: Path | str, candidate_id: str) -> Path:
|
||||
"""Resolve a not-yet-created candidate path below a trusted configured root."""
|
||||
|
||||
checked_id = _checked_candidate_id(candidate_id)
|
||||
root_path = Path(root).expanduser()
|
||||
ensure_private_candidate_root(root_path, create=True)
|
||||
candidate = root_path / checked_id
|
||||
try:
|
||||
mode = candidate.lstat().st_mode
|
||||
except FileNotFoundError:
|
||||
return candidate
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError("candidate output path cannot be inspected") from exc
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||
raise CandidateArtifactError("candidate output path must be a real directory")
|
||||
_require_private_owner_mode(candidate, directory=True, label="candidate output path")
|
||||
return candidate
|
||||
|
||||
|
||||
def ensure_private_candidate_root(
|
||||
root: Path | str, *, create: bool = False
|
||||
) -> Path:
|
||||
"""Admit only an operator-owned candidate root inaccessible to other users."""
|
||||
|
||||
root_path = Path(root).expanduser()
|
||||
_reject_symlink_components(root_path, allow_missing=create)
|
||||
if create:
|
||||
try:
|
||||
root_path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError("candidate root cannot be created") from exc
|
||||
_reject_symlink_components(root_path, allow_missing=False)
|
||||
_require_private_owner_mode(root_path, directory=True, label="candidate root")
|
||||
return root_path
|
||||
|
||||
|
||||
def harden_private_candidate_tree(path: Path | str) -> Path:
|
||||
"""Seal a newly generated, operator-owned candidate tree before a receipt."""
|
||||
|
||||
root = Path(path).expanduser()
|
||||
_reject_symlink_components(root, allow_missing=False)
|
||||
_require_current_owner(root, label="candidate directory")
|
||||
try:
|
||||
os.chmod(root, 0o700, follow_symlinks=False)
|
||||
for current, directory_names, file_names in os.walk(root, followlinks=False):
|
||||
current_path = Path(current)
|
||||
_require_current_owner(current_path, label="candidate directory")
|
||||
os.chmod(current_path, 0o700, follow_symlinks=False)
|
||||
for name in (*directory_names, *file_names):
|
||||
child = current_path / name
|
||||
mode = child.lstat().st_mode
|
||||
if stat.S_ISLNK(mode):
|
||||
raise CandidateArtifactError("candidate tree contains a symlink")
|
||||
_require_current_owner(child, label="candidate tree member")
|
||||
if stat.S_ISDIR(mode):
|
||||
os.chmod(child, 0o700, follow_symlinks=False)
|
||||
elif stat.S_ISREG(mode):
|
||||
os.chmod(child, 0o600, follow_symlinks=False)
|
||||
else:
|
||||
raise CandidateArtifactError(
|
||||
"candidate tree contains a non-file member"
|
||||
)
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError("candidate tree cannot be sealed") from exc
|
||||
return root
|
||||
|
||||
|
||||
def issue_candidate_receipt(
|
||||
*, root: Path | str, candidate_id: str, channel: str
|
||||
) -> CandidateArtifactReceipt:
|
||||
"""Hash the signed catalog at a generated candidate handle."""
|
||||
|
||||
candidate = _existing_candidate_path(root, candidate_id)
|
||||
payload = _read_signed_catalog(candidate, channel=channel)
|
||||
return CandidateArtifactReceipt(
|
||||
candidate_id=candidate_id,
|
||||
catalog_sha256=canonical_hash(payload),
|
||||
)
|
||||
|
||||
|
||||
def verify_candidate_receipt(
|
||||
*,
|
||||
root: Path | str,
|
||||
candidate_id: str,
|
||||
catalog_sha256: str,
|
||||
channel: str,
|
||||
) -> Path:
|
||||
"""Re-resolve and re-hash a persisted receipt before candidate consumption."""
|
||||
|
||||
if re.fullmatch(r"[0-9a-f]{64}", catalog_sha256) is None:
|
||||
raise CandidateArtifactError("candidate catalog digest is malformed")
|
||||
candidate = _existing_candidate_path(root, candidate_id)
|
||||
payload = _read_signed_catalog(candidate, channel=channel)
|
||||
if not hmac.compare_digest(canonical_hash(payload), catalog_sha256):
|
||||
raise CandidateArtifactError("candidate catalog no longer matches its receipt")
|
||||
return candidate
|
||||
|
||||
|
||||
def _checked_candidate_id(candidate_id: str) -> str:
|
||||
if not isinstance(candidate_id, str) or _CANDIDATE_ID.fullmatch(candidate_id) is None:
|
||||
raise CandidateArtifactError("candidate ID is not an issued opaque basename")
|
||||
return candidate_id
|
||||
|
||||
|
||||
def _existing_candidate_path(root: Path | str, candidate_id: str) -> Path:
|
||||
candidate = candidate_output_path(root, candidate_id)
|
||||
try:
|
||||
mode = candidate.lstat().st_mode
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError("candidate directory is unavailable") from exc
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||
raise CandidateArtifactError("candidate directory must be a real directory")
|
||||
_reject_symlink_components(candidate, allow_missing=False)
|
||||
return candidate
|
||||
|
||||
|
||||
def _read_signed_catalog(candidate: Path, *, channel: str) -> dict[str, object]:
|
||||
channel = validate_release_channel(channel)
|
||||
channels = candidate / "channels"
|
||||
catalog_path = channels / f"{channel}.json"
|
||||
_require_real_directory(channels, label="candidate channels directory")
|
||||
_require_regular_file(catalog_path, label="candidate catalog")
|
||||
try:
|
||||
with catalog_path.open("rb") as handle:
|
||||
encoded = handle.read(MAX_CATALOG_BYTES + 1)
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError("candidate catalog cannot be read") from exc
|
||||
if len(encoded) > MAX_CATALOG_BYTES:
|
||||
raise CandidateArtifactError("candidate catalog exceeds its size limit")
|
||||
try:
|
||||
payload = json.loads(encoded.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise CandidateArtifactError("candidate catalog is not valid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise CandidateArtifactError("candidate catalog must be a JSON object")
|
||||
if payload.get("channel") != channel:
|
||||
raise CandidateArtifactError(
|
||||
"candidate catalog channel does not match its requested handle"
|
||||
)
|
||||
signatures = payload.get("signatures")
|
||||
if not isinstance(signatures, list) or not signatures:
|
||||
raise CandidateArtifactError("candidate catalog has no signature envelope")
|
||||
return payload
|
||||
|
||||
|
||||
def _reject_symlink_components(path: Path, *, allow_missing: bool) -> None:
|
||||
absolute = path.absolute()
|
||||
parts: Iterable[Path] = reversed((absolute, *absolute.parents))
|
||||
for component in parts:
|
||||
try:
|
||||
mode = component.lstat().st_mode
|
||||
except FileNotFoundError:
|
||||
if allow_missing:
|
||||
continue
|
||||
raise CandidateArtifactError("candidate path has a missing component")
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError("candidate path cannot be inspected") from exc
|
||||
if stat.S_ISLNK(mode):
|
||||
raise CandidateArtifactError("candidate path must not traverse symlinks")
|
||||
|
||||
|
||||
def _require_real_directory(path: Path, *, label: str) -> None:
|
||||
try:
|
||||
mode = path.lstat().st_mode
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError(f"{label} is unavailable") from exc
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||
raise CandidateArtifactError(f"{label} must be a real directory")
|
||||
_require_private_owner_mode(path, directory=True, label=label)
|
||||
|
||||
|
||||
def _require_regular_file(path: Path, *, label: str) -> None:
|
||||
try:
|
||||
mode = path.lstat().st_mode
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError(f"{label} is unavailable") from exc
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
|
||||
raise CandidateArtifactError(f"{label} must be a regular file")
|
||||
_require_private_owner_mode(path, directory=False, label=label)
|
||||
|
||||
|
||||
def _require_current_owner(path: Path, *, label: str) -> os.stat_result:
|
||||
try:
|
||||
observed = path.lstat()
|
||||
except OSError as exc:
|
||||
raise CandidateArtifactError(f"{label} cannot be inspected") from exc
|
||||
if observed.st_uid != os.geteuid():
|
||||
raise CandidateArtifactError(f"{label} is not owned by the current operator")
|
||||
return observed
|
||||
|
||||
|
||||
def _require_private_owner_mode(
|
||||
path: Path, *, directory: bool, label: str
|
||||
) -> None:
|
||||
observed = _require_current_owner(path, label=label)
|
||||
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
if not expected_type(observed.st_mode) or stat.S_IMODE(observed.st_mode) & 0o077:
|
||||
raise CandidateArtifactError(f"{label} is accessible to another user")
|
||||
Reference in New Issue
Block a user