474 lines
18 KiB
Python
474 lines
18 KiB
Python
"""Explicit directory bundle inventories, verified before installation."""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
from contextlib import contextmanager
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass, field
|
|
from functools import wraps
|
|
from pathlib import Path
|
|
|
|
from . import file_copy
|
|
from .policy import (
|
|
FORMAT,
|
|
MAX_BUNDLE_BYTES,
|
|
MAX_FILE_BYTES,
|
|
MAX_FILES,
|
|
MAX_METADATA_BYTES,
|
|
PLATFORM,
|
|
TOOLCHAINS,
|
|
DeliveryError,
|
|
canonical,
|
|
digest,
|
|
fields,
|
|
integer,
|
|
read_json,
|
|
relative_name,
|
|
sha256,
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ReadScope:
|
|
pid: int
|
|
entries: dict = field(default_factory=dict)
|
|
nodes: int = 0
|
|
metadata_bytes: int = 0
|
|
|
|
|
|
_READ_SCOPE: ContextVar[_ReadScope | None] = ContextVar("delivery_bundle_reads", default=None)
|
|
_MAX_SCOPED_BUNDLES = 4
|
|
_MAX_SCOPED_NODES = 2 * (MAX_FILES + 3)
|
|
_MAX_SCOPED_METADATA = 2 * MAX_METADATA_BYTES
|
|
|
|
|
|
def _bundle_signature(bundle: Path) -> dict[str, tuple[int, ...]]:
|
|
"""Observe topology and all metadata that can invalidate a prior byte read."""
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
descriptor = os.open(bundle, flags)
|
|
members = {}
|
|
try:
|
|
root = os.fstat(descriptor)
|
|
with os.scandir(descriptor) as entries:
|
|
for entry in entries:
|
|
members[entry.name] = entry.stat(follow_symlinks=False)
|
|
if len(members) > 2:
|
|
raise DeliveryError("unexpected bundle member")
|
|
finally:
|
|
os.close(descriptor)
|
|
if set(members) != {"manifest.json", "payload"}:
|
|
raise DeliveryError("unexpected bundle member")
|
|
result = {}
|
|
|
|
def identity(info: os.stat_result) -> tuple[int, ...]:
|
|
return (
|
|
info.st_dev,
|
|
info.st_ino,
|
|
info.st_mode,
|
|
info.st_nlink,
|
|
info.st_uid,
|
|
info.st_gid,
|
|
info.st_size,
|
|
info.st_mtime_ns,
|
|
info.st_ctime_ns,
|
|
)
|
|
|
|
def add(name: str, info: os.stat_result, *, directory: bool) -> None:
|
|
if not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)):
|
|
raise DeliveryError("bundle proof requires ordinary directories and files")
|
|
if not directory and info.st_nlink != 1:
|
|
raise DeliveryError("bundle proof requires single-link files")
|
|
result[name] = identity(info)
|
|
if len(result) > MAX_FILES + 3:
|
|
raise DeliveryError("bundle proof entry count limit")
|
|
|
|
add(".", root, directory=True)
|
|
add("manifest.json", members["manifest.json"], directory=False)
|
|
payload = bundle / "payload"
|
|
add("payload", members["payload"], directory=True)
|
|
pending = [(str(payload), "", result["payload"])]
|
|
while pending:
|
|
directory, prefix, expected = pending.pop()
|
|
# Close each iterator before visiting another directory. Wide or deep
|
|
# verified inventories never require one open descriptor per directory.
|
|
descriptor = os.open(directory, flags)
|
|
try:
|
|
if identity(os.fstat(descriptor)) != expected:
|
|
raise DeliveryError("verified bundle changed during the operator operation")
|
|
with os.scandir(descriptor) as entries:
|
|
for entry in entries:
|
|
name = prefix + entry.name
|
|
info = entry.stat(follow_symlinks=False)
|
|
is_directory = stat.S_ISDIR(info.st_mode)
|
|
relative_name(name + "/.__directory__" if is_directory else name)
|
|
add("payload/" + name, info, directory=is_directory)
|
|
if is_directory:
|
|
pending.append(
|
|
(
|
|
os.path.join(directory, entry.name),
|
|
name + "/",
|
|
identity(info),
|
|
)
|
|
)
|
|
finally:
|
|
os.close(descriptor)
|
|
return result
|
|
|
|
|
|
def _unchanged_bundle(bundle: Path, signature: dict[str, tuple[int, ...]]) -> None:
|
|
if _bundle_signature(bundle) != signature:
|
|
raise DeliveryError("verified bundle changed during the operator operation")
|
|
|
|
|
|
@contextmanager
|
|
def verification_scope():
|
|
"""Reuse actual byte reads only inside one synchronous same-PID operation."""
|
|
previous = _READ_SCOPE.get()
|
|
if previous is not None and previous.pid == os.getpid():
|
|
yield
|
|
return
|
|
current = _ReadScope(os.getpid())
|
|
token = _READ_SCOPE.set(current)
|
|
try:
|
|
yield
|
|
# This happens before the decorated operation returns its success value.
|
|
for (bundle, _), (_, signature) in current.entries.items():
|
|
_unchanged_bundle(bundle, signature)
|
|
finally:
|
|
_READ_SCOPE.reset(token)
|
|
|
|
|
|
def verified_operation(function):
|
|
@wraps(function)
|
|
def wrapped(*args, **kwargs):
|
|
with verification_scope():
|
|
return function(*args, **kwargs)
|
|
|
|
return wrapped
|
|
|
|
|
|
def _file_stamp(info: os.stat_result) -> tuple[int, ...]:
|
|
return (
|
|
info.st_dev,
|
|
info.st_ino,
|
|
info.st_mode,
|
|
info.st_nlink,
|
|
info.st_uid,
|
|
info.st_gid,
|
|
info.st_size,
|
|
info.st_mtime_ns,
|
|
info.st_ctime_ns,
|
|
)
|
|
|
|
|
|
def _read_payload(row: tuple[Path, tuple[int, ...], dict]) -> dict:
|
|
path, expected, record = row
|
|
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
with os.fdopen(descriptor, "rb") as stream:
|
|
if _file_stamp(os.fstat(stream.fileno())) != expected:
|
|
raise DeliveryError("payload file changed before byte verification")
|
|
checked = hashlib.file_digest(stream, "sha256").hexdigest()
|
|
after = _file_stamp(os.fstat(stream.fileno()))
|
|
if after != expected or _file_stamp(path.lstat()) != expected:
|
|
raise DeliveryError("payload file changed during byte verification")
|
|
return {**record, "sha256": checked}
|
|
|
|
|
|
def inventory(root: Path, *, path_prefix: str = "") -> list[dict]:
|
|
"""No links/devices or inferred inclusion; bounds apply before hashing."""
|
|
if not root.is_dir() or root.is_symlink():
|
|
raise DeliveryError("payload root must be an ordinary directory")
|
|
entries = []
|
|
total = 0
|
|
directory_count = 0
|
|
for directory, directories, files in os.walk(root, followlinks=False):
|
|
directory_count += len(directories)
|
|
if directory_count + len(entries) + len(files) > MAX_FILES:
|
|
raise DeliveryError("payload entry count limit")
|
|
for name in directories:
|
|
path = Path(directory) / name
|
|
if path.is_symlink():
|
|
raise DeliveryError("payload directory link refused")
|
|
for name in sorted(files):
|
|
path = Path(directory) / name
|
|
info = path.lstat()
|
|
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
raise DeliveryError("payload must contain single-link regular files")
|
|
if stat.S_IMODE(info.st_mode) not in (0o444, 0o555):
|
|
raise DeliveryError("payload file must be read-only")
|
|
size = integer(info.st_size, 0, MAX_FILE_BYTES, "payload file size")
|
|
total += size
|
|
if total > MAX_BUNDLE_BYTES or len(entries) >= MAX_FILES:
|
|
raise DeliveryError("payload aggregate limit")
|
|
relative = path.relative_to(root).as_posix()
|
|
relative_name(path_prefix + relative)
|
|
entries.append(
|
|
(
|
|
path,
|
|
_file_stamp(info),
|
|
{
|
|
"path": relative,
|
|
"bytes": size,
|
|
"mode": "0555" if info.st_mode & 0o111 else "0444",
|
|
},
|
|
)
|
|
)
|
|
# The same finite four-call scheduler used for durable file copies also
|
|
# bounds reads. It joins every dispatched reader before returning or raising.
|
|
verified = file_copy.bounded_copies(entries, _read_payload)
|
|
for path, expected, _ in entries:
|
|
if _file_stamp(path.lstat()) != expected:
|
|
raise DeliveryError("payload file changed before inventory completion")
|
|
return sorted(verified, key=lambda x: x["path"])
|
|
|
|
|
|
def directories(root: Path, *, path_prefix: str = "") -> list[str]:
|
|
result = []
|
|
for directory, names, _ in os.walk(root, followlinks=False):
|
|
for name in names:
|
|
path = Path(directory) / name
|
|
value = path.relative_to(root).as_posix()
|
|
relative_name(path_prefix + value + "/.__directory__")
|
|
if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o555:
|
|
raise DeliveryError("payload directories must be read-only ordinary directories")
|
|
result.append(value)
|
|
if len(result) > MAX_FILES:
|
|
raise DeliveryError("payload directory count limit")
|
|
return sorted(result)
|
|
|
|
|
|
def validate(data: dict) -> dict:
|
|
fields(
|
|
data,
|
|
{
|
|
"schema_version",
|
|
"format",
|
|
"release_id",
|
|
"source_sha",
|
|
"platform",
|
|
"toolchains",
|
|
"locks",
|
|
"images",
|
|
"files",
|
|
"directories",
|
|
"payload_bytes",
|
|
},
|
|
"bundle",
|
|
)
|
|
if (
|
|
type(data["schema_version"]) is not int
|
|
or data["schema_version"] != 1
|
|
or data["format"] != FORMAT
|
|
):
|
|
raise DeliveryError("unsupported bundle format")
|
|
if not isinstance(data["source_sha"], str) or not re.fullmatch(
|
|
r"[0-9a-f]{40}", data["source_sha"]
|
|
):
|
|
raise DeliveryError("invalid source SHA")
|
|
if (
|
|
not isinstance(data["release_id"], str)
|
|
or data["release_id"] != "sd-" + data["source_sha"][:12]
|
|
):
|
|
raise DeliveryError("release identity mismatch")
|
|
if data["platform"] != PLATFORM or data["toolchains"] != TOOLCHAINS:
|
|
raise DeliveryError("bundle platform/toolchain mismatch")
|
|
fields(
|
|
data["locks"],
|
|
{
|
|
"backend/uv.lock",
|
|
"judge/Cargo.lock",
|
|
"frontend/pnpm-lock.yaml",
|
|
"deploy/release/contract.v1.json",
|
|
},
|
|
"lock fingerprints",
|
|
)
|
|
for value in data["locks"].values():
|
|
sha256(value)
|
|
fields(data["images"], {"gnu", "python313", "java21"}, "runtime images")
|
|
for image in data["images"].values():
|
|
fields(
|
|
image,
|
|
{
|
|
"toolchain_id",
|
|
"image_kind",
|
|
"image_revision",
|
|
"runtime_version",
|
|
"image_digest",
|
|
},
|
|
"runtime identity",
|
|
)
|
|
sha256(image["image_digest"])
|
|
integer(image["image_revision"], 1, 2**32 - 1, "image revision")
|
|
for name in ("toolchain_id", "image_kind", "runtime_version"):
|
|
if not isinstance(image[name], str) or not re.fullmatch(
|
|
r"[A-Za-z0-9_.+-]{1,128}", image[name]
|
|
):
|
|
raise DeliveryError("invalid runtime identity")
|
|
runtimes = {
|
|
"gnu": ("gnu-cxx-v1", "linux_gnu_cxx_x86_64_v1", None),
|
|
"python313": ("cpython313-v1", "linux_cpython313_x86_64_v1", "3.13.16"),
|
|
"java21": ("openjdk21-v1", "linux_openjdk21_x86_64_v1", "21.0.12.1+1-LTS"),
|
|
}
|
|
for name, (toolchain, kind, version) in runtimes.items():
|
|
image = data["images"][name]
|
|
if (
|
|
image["toolchain_id"] != toolchain
|
|
or image["image_kind"] != kind
|
|
or (version is not None and image["runtime_version"] != version)
|
|
):
|
|
raise DeliveryError("unsupported runtime image")
|
|
files = data["files"]
|
|
if not isinstance(files, list) or not 1 <= len(files) <= MAX_FILES:
|
|
raise DeliveryError("bundle file count")
|
|
seen = set()
|
|
total = 0
|
|
for entry in files:
|
|
fields(entry, {"path", "bytes", "sha256", "mode"}, "bundle file")
|
|
path = relative_name(entry["path"])
|
|
if path in seen or entry["mode"] not in {"0444", "0555"}:
|
|
raise DeliveryError("duplicate file or unsafe mode")
|
|
seen.add(path)
|
|
total += integer(entry["bytes"], 0, MAX_FILE_BYTES, "bundle file size")
|
|
sha256(entry["sha256"])
|
|
if (
|
|
total > MAX_BUNDLE_BYTES
|
|
or type(data["payload_bytes"]) is not int
|
|
or data["payload_bytes"] != total
|
|
):
|
|
raise DeliveryError("bundle byte total")
|
|
required = {
|
|
"bin/kf-sandbox",
|
|
"bin/kf-worker",
|
|
"bin/kf-coordinator",
|
|
"python/bin/python3.13",
|
|
"backend/alembic.ini",
|
|
"frontend/index.html",
|
|
"frontend/admin/index.html",
|
|
"deploy/release/contract.v1.json",
|
|
}
|
|
# Older deliveries remain valid rollback candidates. New native restore
|
|
# support must be complete in both directions when either member is present.
|
|
restore_pair = {"bin/kf-restore-io", "delivery/scripts/release/restore_native.py"}
|
|
if seen & restore_pair:
|
|
required.update(restore_pair)
|
|
required.update(f"images/{name}/manifest.json" for name in data["images"])
|
|
if not required <= seen:
|
|
raise DeliveryError("incomplete application bundle")
|
|
directory_names = data["directories"]
|
|
if not isinstance(directory_names, list) or len(directory_names) + len(files) > MAX_FILES:
|
|
raise DeliveryError("bundle directory count")
|
|
directory_set = set()
|
|
for name in directory_names:
|
|
if not isinstance(name, str):
|
|
raise DeliveryError("invalid bundle directory")
|
|
relative_name(name + "/.__directory__")
|
|
if name in directory_set or name in seen:
|
|
raise DeliveryError("duplicate bundle directory")
|
|
directory_set.add(name)
|
|
for name in seen | directory_set:
|
|
# Names are already canonical; walk their lexical parent components.
|
|
parent, separator, _ = name.rpartition("/")
|
|
while separator:
|
|
if parent not in directory_set:
|
|
raise DeliveryError("missing payload parent directory")
|
|
parent, separator, _ = parent.rpartition("/")
|
|
return data
|
|
|
|
|
|
def verify(bundle: Path, expected_sha256: str) -> dict:
|
|
"""A bounded operation may reuse a complete byte proof only while unchanged."""
|
|
from .operation_proof import reuse
|
|
|
|
expected_sha256 = sha256(expected_sha256)
|
|
# Inherited proofs are revalidated at every call and never remembered in a
|
|
# same-PID scope. They cannot outlive or be exported beyond their owner.
|
|
inherited = reuse(bundle, expected_sha256)
|
|
if inherited is not None:
|
|
return inherited
|
|
scope = _READ_SCOPE.get()
|
|
if scope is None or scope.pid != os.getpid():
|
|
return _verify_contents(bundle, expected_sha256)
|
|
key = (bundle, expected_sha256)
|
|
if key in scope.entries:
|
|
data, signature = scope.entries[key]
|
|
_unchanged_bundle(bundle, signature)
|
|
# Never expose the remembered metadata object to a mutating caller.
|
|
return json.loads(data)
|
|
if len(scope.entries) >= _MAX_SCOPED_BUNDLES:
|
|
return _verify_contents(bundle, expected_sha256)
|
|
before = _bundle_signature(bundle)
|
|
data = _verify_contents(bundle, expected_sha256)
|
|
_unchanged_bundle(bundle, before)
|
|
encoded = canonical(data)
|
|
if (
|
|
scope.nodes + len(before) <= _MAX_SCOPED_NODES
|
|
and scope.metadata_bytes + len(encoded) <= _MAX_SCOPED_METADATA
|
|
):
|
|
scope.entries[key] = encoded, before
|
|
scope.nodes += len(before)
|
|
scope.metadata_bytes += len(encoded)
|
|
return data
|
|
|
|
|
|
def verify_staged(bundle: Path, expected_sha256: str) -> dict:
|
|
"""Read every byte of a staging tree without remembering its temporary name."""
|
|
return _verify_contents(bundle, expected_sha256)
|
|
|
|
|
|
def _verify_contents(bundle: Path, expected_sha256: str) -> dict:
|
|
"""The manifest digest comes from a separate trusted operator receipt."""
|
|
expected_sha256 = sha256(expected_sha256)
|
|
if not bundle.is_dir() or bundle.is_symlink():
|
|
raise DeliveryError("bundle directory required")
|
|
metadata = bundle / "manifest.json"
|
|
info = metadata.lstat()
|
|
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size > MAX_METADATA_BYTES:
|
|
raise DeliveryError("unsafe manifest file")
|
|
if digest(metadata) != expected_sha256:
|
|
raise DeliveryError("bundle manifest fingerprint mismatch")
|
|
if set(p.name for p in bundle.iterdir()) != {"manifest.json", "payload"}:
|
|
raise DeliveryError("unexpected bundle member")
|
|
data = validate(read_json(metadata))
|
|
if inventory(bundle / "payload") != sorted(data["files"], key=lambda x: x["path"]):
|
|
raise DeliveryError("bundle contents differ from manifest")
|
|
if directories(bundle / "payload") != sorted(data["directories"]):
|
|
raise DeliveryError("bundle directories differ from manifest")
|
|
for name, identity in data["images"].items():
|
|
if digest(bundle / "payload/images" / name / "manifest.json") != identity["image_digest"]:
|
|
raise DeliveryError("image catalog differs from runtime identity")
|
|
if (
|
|
digest(bundle / "payload/deploy/release/contract.v1.json")
|
|
!= data["locks"]["deploy/release/contract.v1.json"]
|
|
):
|
|
raise DeliveryError("delivery contract fingerprint mismatch")
|
|
return data
|
|
|
|
|
|
def write(bundle: Path, source_sha: str, locks: dict, images: dict) -> dict:
|
|
files = inventory(bundle / "payload")
|
|
data = validate(
|
|
{
|
|
"schema_version": 1,
|
|
"format": FORMAT,
|
|
"release_id": "sd-" + source_sha[:12],
|
|
"source_sha": source_sha,
|
|
"platform": PLATFORM,
|
|
"toolchains": TOOLCHAINS,
|
|
"locks": locks,
|
|
"images": images,
|
|
"files": files,
|
|
"directories": directories(bundle / "payload"),
|
|
"payload_bytes": sum(x["bytes"] for x in files),
|
|
}
|
|
)
|
|
raw = canonical(data)
|
|
if len(raw) > MAX_METADATA_BYTES:
|
|
raise DeliveryError("bundle manifest exceeds metadata limit")
|
|
with (bundle / "manifest.json").open("xb") as stream:
|
|
stream.write(raw)
|
|
(bundle / "manifest.json").chmod(0o444)
|
|
return data
|