😏
This commit is contained in:
Executable
+609
@@ -0,0 +1,609 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify Codex MMO release archives by safe extraction and manifest checks.
|
||||
|
||||
The verifier is deliberately independent from the archive builder. It rejects
|
||||
traversal, links, special files, duplicate members, incomplete package trees,
|
||||
extra files, missing files, hash mismatches, size mismatches, and mode drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "libexec"))
|
||||
|
||||
from mmo_version import MMO_SCHEMA_VERSION # noqa: E402
|
||||
|
||||
REQUIRED_FILES = {
|
||||
"VERSION",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"install.sh",
|
||||
"uninstall.sh",
|
||||
"config/catalog.toml",
|
||||
"config/inventory-snapshots/openai-codex.json",
|
||||
"config/inventory-snapshots/opencode-go.json",
|
||||
"config/inventory-snapshots/opencode-zen.json",
|
||||
"config/inventory-snapshots/openrouter.json",
|
||||
"config/inventory-snapshots/zai-api.json",
|
||||
"config/inventory-snapshots/zai-coding-plan.json",
|
||||
"scripts/build_release.py",
|
||||
"scripts/build_inventory_snapshot.py",
|
||||
"scripts/validate_package.py",
|
||||
"scripts/verify_release.py",
|
||||
"libexec/mmo_app_server.py",
|
||||
"libexec/mmo_catalog_data.py",
|
||||
"libexec/mmo_codex_home.py",
|
||||
"libexec/mmo_diagnostics.py",
|
||||
"libexec/mmo_runtime.py",
|
||||
"libexec/mmo_state.py",
|
||||
"libexec/mmo_workspace.py",
|
||||
"libexec/mmo_inventory_snapshot.py",
|
||||
"libexec/mmo_mcp.py",
|
||||
"libexec/mmo_tool_mcp.py",
|
||||
"libexec/root_runner.py",
|
||||
"libexec/worker_runner.py",
|
||||
"profiles/README.md",
|
||||
}
|
||||
REQUIRED_DIRECTORIES = {"bin", "config", "docs", "evals", "libexec", "profiles", "scripts", "tests"}
|
||||
MAX_ARCHIVE_MEMBERS = 20_000
|
||||
MAX_ARCHIVE_MEMBER_BYTES = 512 * 1024 * 1024
|
||||
MAX_EXPANDED_BYTES = 1_000_000_000
|
||||
MAX_ZIP_COMPRESSION_RATIO = 1_000
|
||||
|
||||
|
||||
def _reject_json_constant(value: str) -> Any:
|
||||
raise ValueError(f"non-standard JSON constant: {value}")
|
||||
|
||||
|
||||
def _finite_json_float(value: str) -> float:
|
||||
parsed = float(value)
|
||||
if not math.isfinite(parsed):
|
||||
raise ValueError(f"JSON number is outside the supported finite range: {value}")
|
||||
return parsed
|
||||
|
||||
|
||||
def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, item in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate JSON object member: {key!r}")
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
|
||||
def _validate_json_unicode(value: Any) -> None:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value.encode("utf-8")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ValueError("JSON strings must contain valid Unicode scalar values") from exc
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_validate_json_unicode(item)
|
||||
elif isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
_validate_json_unicode(key)
|
||||
_validate_json_unicode(item)
|
||||
|
||||
|
||||
def _strict_json_loads(value: str) -> Any:
|
||||
parsed = json.loads(
|
||||
value,
|
||||
parse_constant=_reject_json_constant,
|
||||
parse_float=_finite_json_float,
|
||||
object_pairs_hook=_unique_json_object,
|
||||
)
|
||||
_validate_json_unicode(parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
class VerificationError(RuntimeError):
|
||||
"""Raised when a release archive fails integrity verification."""
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _safe_member_path(name: str) -> PurePosixPath:
|
||||
if not name or "\x00" in name or "\\" in name or name.startswith("/"):
|
||||
raise VerificationError(f"unsafe archive member path: {name!r}")
|
||||
value = name[:-1] if name.endswith("/") else name
|
||||
parts = value.split("/")
|
||||
if not parts or any(part in {"", ".", ".."} for part in parts):
|
||||
raise VerificationError(f"unsafe archive member path: {name!r}")
|
||||
return PurePosixPath(*parts)
|
||||
|
||||
|
||||
def _ensure_member_limits(names: Iterable[str], sizes: Iterable[int]) -> None:
|
||||
names_list = list(names)
|
||||
sizes_list = list(sizes)
|
||||
if len(names_list) != len(sizes_list):
|
||||
raise VerificationError("archive member metadata is inconsistent")
|
||||
if len(names_list) > MAX_ARCHIVE_MEMBERS:
|
||||
raise VerificationError(f"archive contains too many members: {len(names_list)}")
|
||||
if any(size < 0 for size in sizes_list):
|
||||
raise VerificationError("archive contains a negative member size")
|
||||
expanded = sum(sizes_list)
|
||||
if expanded > MAX_EXPANDED_BYTES:
|
||||
raise VerificationError(f"archive expands beyond safety limit: {expanded} bytes")
|
||||
if len(set(names_list)) != len(names_list):
|
||||
raise VerificationError("archive contains duplicate member names")
|
||||
|
||||
|
||||
def _copy_limited(source: Any, output: Any, maximum: int, label: str) -> int:
|
||||
copied = 0
|
||||
while True:
|
||||
block = source.read(min(1024 * 1024, maximum - copied + 1))
|
||||
if not block:
|
||||
return copied
|
||||
copied += len(block)
|
||||
if copied > maximum:
|
||||
raise VerificationError(f"archive member exceeds size limit: {label}")
|
||||
output.write(block)
|
||||
|
||||
|
||||
def _register_member(
|
||||
seen: dict[str, bool], relative: PurePosixPath, *, is_directory: bool, label: str
|
||||
) -> None:
|
||||
key = relative.as_posix()
|
||||
if key in seen:
|
||||
raise VerificationError(f"archive aliases the same extraction path: {label}")
|
||||
parts = relative.parts
|
||||
for index in range(1, len(parts)):
|
||||
parent = "/".join(parts[:index])
|
||||
if seen.get(parent) is False:
|
||||
raise VerificationError(f"archive places a member beneath a file: {label}")
|
||||
if not is_directory and any(existing.startswith(f"{key}/") for existing in seen):
|
||||
raise VerificationError(f"archive replaces a populated directory with a file: {label}")
|
||||
seen[key] = is_directory
|
||||
|
||||
|
||||
def _validate_explicit_directories(seen: dict[str, bool]) -> None:
|
||||
"""Require exactly one explicit entry for every payload parent directory."""
|
||||
|
||||
expected: set[str] = set()
|
||||
for name, is_directory in seen.items():
|
||||
if is_directory:
|
||||
continue
|
||||
parts = name.split("/")
|
||||
expected.update("/".join(parts[:index]) for index in range(1, len(parts)))
|
||||
actual = {name for name, is_directory in seen.items() if is_directory}
|
||||
missing = sorted(expected - actual)
|
||||
extra = sorted(actual - expected)
|
||||
if missing or extra:
|
||||
raise VerificationError(
|
||||
f"archive directory set differs from payload parents; missing={missing}, extra={extra}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_permission_mode(
|
||||
archive_type: str, label: str, mode: int, *, is_directory: bool
|
||||
) -> None:
|
||||
allowed = {0o755} if is_directory else {0o644, 0o755}
|
||||
if mode not in allowed:
|
||||
expected = "0755" if is_directory else "0644 or 0755"
|
||||
raise VerificationError(
|
||||
f"{archive_type} member has invalid permission mode: "
|
||||
f"{label}: {mode:04o}; expected {expected}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_zip(archive_path: Path, destination: Path) -> None:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
infos = archive.infolist()
|
||||
_ensure_member_limits((item.filename for item in infos), (item.file_size for item in infos))
|
||||
relative_paths = [_safe_member_path(item.filename) for item in infos]
|
||||
canonical_names = [path.as_posix() for path in relative_paths]
|
||||
if len(set(canonical_names)) != len(canonical_names):
|
||||
raise VerificationError("archive aliases the same extraction path")
|
||||
seen: dict[str, bool] = {}
|
||||
for item, relative in zip(infos, relative_paths, strict=True):
|
||||
if item.create_system != 3:
|
||||
raise VerificationError(f"ZIP member lacks Unix mode metadata: {item.filename}")
|
||||
unix_mode = (item.external_attr >> 16) & 0xFFFF
|
||||
file_type = stat.S_IFMT(unix_mode)
|
||||
if file_type not in {stat.S_IFREG, stat.S_IFDIR}:
|
||||
raise VerificationError(f"ZIP contains a link or special file: {item.filename}")
|
||||
is_directory = file_type == stat.S_IFDIR
|
||||
if item.is_dir() != is_directory:
|
||||
raise VerificationError(
|
||||
f"ZIP member path and type metadata disagree: {item.filename}"
|
||||
)
|
||||
permission_mode = unix_mode & 0o7777
|
||||
_validate_permission_mode(
|
||||
"ZIP", item.filename, permission_mode, is_directory=is_directory
|
||||
)
|
||||
_register_member(seen, relative, is_directory=is_directory, label=item.filename)
|
||||
if item.flag_bits & 0x1:
|
||||
raise VerificationError(f"ZIP contains an encrypted member: {item.filename}")
|
||||
if item.file_size > MAX_ARCHIVE_MEMBER_BYTES:
|
||||
raise VerificationError(f"ZIP member exceeds size limit: {item.filename}")
|
||||
if not is_directory and item.file_size and item.compress_size == 0:
|
||||
raise VerificationError(f"ZIP member has invalid compressed size: {item.filename}")
|
||||
if (
|
||||
item.compress_size > 0
|
||||
and item.file_size / item.compress_size > MAX_ZIP_COMPRESSION_RATIO
|
||||
):
|
||||
raise VerificationError(
|
||||
f"ZIP member has suspicious compression ratio: {item.filename}"
|
||||
)
|
||||
target = destination.joinpath(*relative.parts)
|
||||
if is_directory:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(target, permission_mode)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(item) as source, target.open("wb") as output:
|
||||
copied = _copy_limited(source, output, MAX_ARCHIVE_MEMBER_BYTES, item.filename)
|
||||
if copied != item.file_size:
|
||||
raise VerificationError(f"ZIP member size mismatch: {item.filename}")
|
||||
os.chmod(target, permission_mode)
|
||||
_validate_explicit_directories(seen)
|
||||
|
||||
|
||||
def _extract_tar(archive_path: Path, destination: Path) -> None:
|
||||
with tarfile.open(archive_path, mode="r|gz") as archive:
|
||||
seen: dict[str, bool] = {}
|
||||
member_count = 0
|
||||
expanded_bytes = 0
|
||||
for item in archive:
|
||||
member_count += 1
|
||||
if member_count > MAX_ARCHIVE_MEMBERS:
|
||||
raise VerificationError(f"archive contains too many members: {member_count}")
|
||||
if item.size < 0:
|
||||
raise VerificationError(f"tar member has a negative size: {item.name}")
|
||||
expanded_bytes += item.size
|
||||
if expanded_bytes > MAX_EXPANDED_BYTES:
|
||||
raise VerificationError(
|
||||
f"archive expands beyond safety limit: {expanded_bytes} bytes"
|
||||
)
|
||||
relative = _safe_member_path(item.name)
|
||||
if not (item.isdir() or item.isfile()):
|
||||
raise VerificationError(f"tar contains a link or special file: {item.name}")
|
||||
permission_mode = item.mode & 0o7777
|
||||
if item.mode != permission_mode:
|
||||
raise VerificationError(f"tar member has invalid mode metadata: {item.name}")
|
||||
_validate_permission_mode("tar", item.name, permission_mode, is_directory=item.isdir())
|
||||
_register_member(seen, relative, is_directory=item.isdir(), label=item.name)
|
||||
if item.size > MAX_ARCHIVE_MEMBER_BYTES:
|
||||
raise VerificationError(f"tar member exceeds size limit: {item.name}")
|
||||
target = destination.joinpath(*relative.parts)
|
||||
if item.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(target, permission_mode)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = archive.extractfile(item)
|
||||
if source is None:
|
||||
raise VerificationError(f"unable to read tar member: {item.name}")
|
||||
with source, target.open("wb") as output:
|
||||
copied = _copy_limited(source, output, MAX_ARCHIVE_MEMBER_BYTES, item.name)
|
||||
if copied != item.size:
|
||||
raise VerificationError(f"tar member size mismatch: {item.name}")
|
||||
os.chmod(target, permission_mode)
|
||||
_validate_explicit_directories(seen)
|
||||
|
||||
|
||||
def _source_inventory(source_tree: Path) -> dict[str, dict[str, Any]]:
|
||||
excluded_parts = {
|
||||
".git",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
"dist",
|
||||
"build",
|
||||
}
|
||||
inventory: dict[str, dict[str, Any]] = {}
|
||||
for path in sorted(source_tree.rglob("*")):
|
||||
relative = path.relative_to(source_tree)
|
||||
if any(part in excluded_parts for part in relative.parts):
|
||||
continue
|
||||
mode_bits = path.lstat().st_mode
|
||||
if stat.S_ISLNK(mode_bits):
|
||||
raise VerificationError(f"source tree contains a symbolic link: {relative}")
|
||||
if not (stat.S_ISREG(mode_bits) or stat.S_ISDIR(mode_bits)):
|
||||
raise VerificationError(f"source tree contains a special file: {relative}")
|
||||
if (
|
||||
relative.as_posix() == "PACKAGE-MANIFEST.json"
|
||||
or not path.is_file()
|
||||
or path.suffix in {".pyc", ".pyo"}
|
||||
):
|
||||
continue
|
||||
mode = 0o755 if path.stat().st_mode & stat.S_IXUSR else 0o644
|
||||
inventory[relative.as_posix()] = {
|
||||
"size": path.stat().st_size,
|
||||
"sha256": sha256(path),
|
||||
"mode": f"{mode:04o}",
|
||||
}
|
||||
return inventory
|
||||
|
||||
|
||||
def _tree_hash(package_root: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
files = sorted(
|
||||
(path for path in package_root.rglob("*") if path.is_file() and not path.is_symlink()),
|
||||
key=lambda item: item.relative_to(package_root).as_posix(),
|
||||
)
|
||||
for path in files:
|
||||
relative = path.relative_to(package_root).as_posix().encode("utf-8")
|
||||
mode = ("0755" if path.stat().st_mode & stat.S_IXUSR else "0644").encode("ascii")
|
||||
size = str(path.stat().st_size).encode("ascii")
|
||||
for value in (relative, mode, size):
|
||||
digest.update(len(value).to_bytes(8, "big"))
|
||||
digest.update(value)
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _verify_extracted(package_root: Path, source_tree: Path | None = None) -> dict[str, Any]:
|
||||
manifest_path = package_root / "PACKAGE-MANIFEST.json"
|
||||
if not manifest_path.is_file():
|
||||
raise VerificationError("PACKAGE-MANIFEST.json is missing")
|
||||
try:
|
||||
manifest = _strict_json_loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise VerificationError(f"invalid package manifest: {exc}") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise VerificationError("package manifest root must be an object")
|
||||
expected_manifest_fields = {
|
||||
"schema_version",
|
||||
"package",
|
||||
"version",
|
||||
"source_date_epoch",
|
||||
"manifest_excludes",
|
||||
"file_count",
|
||||
"total_bytes",
|
||||
"files",
|
||||
}
|
||||
if set(manifest) != expected_manifest_fields:
|
||||
raise VerificationError("package manifest has missing or unexpected fields")
|
||||
schema_version = manifest.get("schema_version")
|
||||
if (
|
||||
not isinstance(schema_version, int)
|
||||
or isinstance(schema_version, bool)
|
||||
or schema_version != MMO_SCHEMA_VERSION
|
||||
or manifest.get("package") != "codex-multimodel-orchestrator"
|
||||
):
|
||||
raise VerificationError("package manifest identity is invalid")
|
||||
version = (
|
||||
(package_root / "VERSION").read_text(encoding="utf-8").strip()
|
||||
if (package_root / "VERSION").is_file()
|
||||
else ""
|
||||
)
|
||||
if manifest.get("version") != version:
|
||||
raise VerificationError("manifest version does not match VERSION")
|
||||
expected_root = f"codex-multimodel-orchestrator-{version}"
|
||||
if package_root.name != expected_root:
|
||||
raise VerificationError(
|
||||
f"archive top-level directory must be {expected_root!r}, got {package_root.name!r}"
|
||||
)
|
||||
entries = manifest.get("files")
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise VerificationError("manifest file list is empty or invalid")
|
||||
if manifest.get("manifest_excludes") != ["PACKAGE-MANIFEST.json"]:
|
||||
raise VerificationError("manifest exclusion declaration is invalid")
|
||||
epoch = manifest.get("source_date_epoch")
|
||||
if not isinstance(epoch, int) or isinstance(epoch, bool) or not 0 <= epoch <= 0xFFFFFFFF:
|
||||
raise VerificationError("manifest source_date_epoch is invalid")
|
||||
file_count = manifest.get("file_count")
|
||||
if (
|
||||
not isinstance(file_count, int)
|
||||
or isinstance(file_count, bool)
|
||||
or file_count != len(entries)
|
||||
):
|
||||
raise VerificationError("manifest file_count does not match files array")
|
||||
total_bytes = manifest.get("total_bytes")
|
||||
if not isinstance(total_bytes, int) or isinstance(total_bytes, bool) or total_bytes < 0:
|
||||
raise VerificationError("manifest total_bytes is invalid")
|
||||
|
||||
manifest_inventory: dict[str, dict[str, Any]] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
||||
raise VerificationError("manifest contains an invalid file entry")
|
||||
relative = _safe_member_path(entry["path"]).as_posix()
|
||||
if relative in manifest_inventory or relative == "PACKAGE-MANIFEST.json":
|
||||
raise VerificationError(f"manifest contains a duplicate or reserved path: {relative}")
|
||||
if set(entry) != {"path", "mode", "size", "sha256"}:
|
||||
raise VerificationError(f"manifest entry has unexpected fields: {relative}")
|
||||
if entry.get("mode") not in {"0644", "0755"}:
|
||||
raise VerificationError(f"manifest entry has invalid mode: {relative}")
|
||||
if (
|
||||
not isinstance(entry.get("size"), int)
|
||||
or isinstance(entry.get("size"), bool)
|
||||
or entry["size"] < 0
|
||||
):
|
||||
raise VerificationError(f"manifest entry has invalid size: {relative}")
|
||||
expected_hash = entry.get("sha256")
|
||||
if (
|
||||
not isinstance(expected_hash, str)
|
||||
or len(expected_hash) != 64
|
||||
or any(char not in "0123456789abcdef" for char in expected_hash)
|
||||
):
|
||||
raise VerificationError(f"manifest entry has invalid hash: {relative}")
|
||||
manifest_inventory[relative] = entry
|
||||
|
||||
actual_files = {
|
||||
path.relative_to(package_root).as_posix()
|
||||
for path in package_root.rglob("*")
|
||||
if path.is_file() and not path.is_symlink()
|
||||
}
|
||||
expected_files = set(manifest_inventory) | {"PACKAGE-MANIFEST.json"}
|
||||
missing = sorted(expected_files - actual_files)
|
||||
extra = sorted(actual_files - expected_files)
|
||||
if missing or extra:
|
||||
raise VerificationError(
|
||||
f"archive file set differs from manifest; missing={missing}, extra={extra}"
|
||||
)
|
||||
|
||||
missing_required = sorted(REQUIRED_FILES - actual_files)
|
||||
missing_directories = sorted(
|
||||
name for name in REQUIRED_DIRECTORIES if not (package_root / name).is_dir()
|
||||
)
|
||||
if missing_required or missing_directories:
|
||||
raise VerificationError(
|
||||
f"archive is not a complete source release; missing_files={missing_required}, "
|
||||
f"missing_directories={missing_directories}"
|
||||
)
|
||||
|
||||
mismatches: list[str] = []
|
||||
for relative, entry in sorted(manifest_inventory.items()):
|
||||
path = package_root / relative
|
||||
actual_mode = stat.S_IMODE(path.stat().st_mode)
|
||||
expected_mode = str(entry.get("mode", ""))
|
||||
expected_size = entry.get("size")
|
||||
expected_hash = entry.get("sha256")
|
||||
if path.stat().st_size != expected_size:
|
||||
mismatches.append(f"{relative}: size")
|
||||
if sha256(path) != expected_hash:
|
||||
mismatches.append(f"{relative}: sha256")
|
||||
if f"{actual_mode:04o}" != expected_mode:
|
||||
mismatches.append(f"{relative}: mode")
|
||||
actual_payload_bytes = sum(
|
||||
(package_root / relative).stat().st_size for relative in manifest_inventory
|
||||
)
|
||||
if manifest.get("total_bytes") != actual_payload_bytes:
|
||||
mismatches.append(
|
||||
f"manifest total_bytes {manifest.get('total_bytes')!r} != {actual_payload_bytes}"
|
||||
)
|
||||
if mismatches:
|
||||
raise VerificationError(f"manifest verification failed: {mismatches[:20]}")
|
||||
|
||||
if source_tree is not None:
|
||||
source_inventory = _source_inventory(source_tree)
|
||||
if set(source_inventory) != set(manifest_inventory):
|
||||
missing_from_archive = sorted(set(source_inventory) - set(manifest_inventory))
|
||||
extra_in_archive = sorted(set(manifest_inventory) - set(source_inventory))
|
||||
raise VerificationError(
|
||||
f"archive differs from source tree; missing={missing_from_archive}, extra={extra_in_archive}"
|
||||
)
|
||||
source_mismatches = [
|
||||
relative
|
||||
for relative in sorted(source_inventory)
|
||||
if source_inventory[relative]
|
||||
!= {
|
||||
"size": manifest_inventory[relative].get("size"),
|
||||
"sha256": manifest_inventory[relative].get("sha256"),
|
||||
"mode": manifest_inventory[relative].get("mode"),
|
||||
}
|
||||
]
|
||||
if source_mismatches:
|
||||
raise VerificationError(
|
||||
f"archive content differs from source tree: {source_mismatches[:20]}"
|
||||
)
|
||||
|
||||
return {
|
||||
"package": manifest["package"],
|
||||
"version": manifest["version"],
|
||||
"manifest_files": len(manifest_inventory),
|
||||
"total_files": len(actual_files),
|
||||
"expanded_bytes": sum((package_root / item).stat().st_size for item in actual_files),
|
||||
"manifest_sha256": sha256(manifest_path),
|
||||
"tree_sha256": _tree_hash(package_root),
|
||||
}
|
||||
|
||||
|
||||
def verify_archive(archive_path: Path, source_tree: Path | None = None) -> dict[str, Any]:
|
||||
archive_path = archive_path.expanduser().resolve()
|
||||
if not archive_path.is_file():
|
||||
raise VerificationError(f"archive does not exist: {archive_path}")
|
||||
with tempfile.TemporaryDirectory(prefix="mmo-verify-release-") as temporary:
|
||||
destination = Path(temporary)
|
||||
if archive_path.name.endswith(".tar.gz"):
|
||||
_extract_tar(archive_path, destination)
|
||||
elif archive_path.suffix == ".zip":
|
||||
_extract_zip(archive_path, destination)
|
||||
else:
|
||||
raise VerificationError(f"unsupported archive type: {archive_path.name}")
|
||||
roots = [path for path in destination.iterdir() if path.is_dir()]
|
||||
stray = [path for path in destination.iterdir() if not path.is_dir()]
|
||||
if len(roots) != 1 or stray:
|
||||
raise VerificationError("archive must contain exactly one top-level package directory")
|
||||
package_root = roots[0]
|
||||
result = _verify_extracted(package_root, source_tree=source_tree)
|
||||
result.update(
|
||||
{
|
||||
"archive": str(archive_path),
|
||||
"archive_bytes": archive_path.stat().st_size,
|
||||
"archive_sha256": sha256(archive_path),
|
||||
"top_level": package_root.name,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Verify Codex MMO release archives")
|
||||
parser.add_argument("archives", nargs="+", type=Path)
|
||||
parser.add_argument("--source-tree", type=Path)
|
||||
parser.add_argument("--report", type=Path)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def _atomic_write_text(path: Path, value: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(value)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, 0o644)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parser().parse_args()
|
||||
source = args.source_tree.expanduser().resolve() if args.source_tree else None
|
||||
results: list[dict[str, Any]] = []
|
||||
errors: list[str] = []
|
||||
for archive in args.archives:
|
||||
try:
|
||||
results.append(verify_archive(archive, source_tree=source))
|
||||
except Exception as exc: # verification boundary
|
||||
errors.append(f"{archive}: {type(exc).__name__}: {exc}")
|
||||
report = {"passed": not errors, "results": results, "errors": errors}
|
||||
if args.report:
|
||||
_atomic_write_text(
|
||||
args.report,
|
||||
json.dumps(report, indent=2, sort_keys=True, allow_nan=False) + "\n",
|
||||
)
|
||||
if args.json or errors:
|
||||
print(json.dumps(report, indent=2, sort_keys=True, allow_nan=False))
|
||||
else:
|
||||
for item in results:
|
||||
print(
|
||||
f"PASS {Path(item['archive']).name}: {item['manifest_files']} manifest files, "
|
||||
f"{item['total_files']} total files, sha256={item['archive_sha256']}"
|
||||
)
|
||||
return 0 if not errors else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user