Files
govoplan/tools/release/govoplan_release/candidate_artifact.py

255 lines
9.8 KiB
Python

"""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")