255 lines
9.8 KiB
Python
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")
|