95 lines
4.0 KiB
Python
95 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify ZIP safety, embedded checksums and extracted build-pack validation."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
|
|
def parse_manifest(data: bytes) -> dict[str, str]:
|
|
result: dict[str, str] = {}
|
|
for number, line in enumerate(data.decode("utf-8").splitlines(), 1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
digest, relative = line.split(" ", 1)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid checksum line {number}") from exc
|
|
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
|
|
raise ValueError(f"Invalid SHA-256 at line {number}")
|
|
if relative in result:
|
|
raise ValueError(f"Duplicate checksum path: {relative}")
|
|
result[relative] = digest
|
|
return result
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("archive", type=Path)
|
|
args = parser.parse_args()
|
|
archive_path = args.archive.resolve()
|
|
if not archive_path.is_file():
|
|
raise SystemExit(f"Archive not found: {archive_path}")
|
|
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
infos = archive.infolist()
|
|
names = [info.filename for info in infos]
|
|
if len(names) != len(set(names)):
|
|
raise SystemExit("Archive contains duplicate paths")
|
|
for info in infos:
|
|
path = PurePosixPath(info.filename)
|
|
if path.is_absolute() or ".." in path.parts or not path.parts:
|
|
raise SystemExit(f"Unsafe archive path: {info.filename}")
|
|
mode = (info.external_attr >> 16) & 0xFFFF
|
|
if stat.S_ISLNK(mode):
|
|
raise SystemExit(f"Archive contains symlink: {info.filename}")
|
|
bad = archive.testzip()
|
|
if bad:
|
|
raise SystemExit(f"CRC failure: {bad}")
|
|
|
|
manifest_names = [n for n in names if n.endswith("/PACK_MANIFEST.sha256")]
|
|
index_names = [n for n in names if n.endswith("/FILE_INDEX.txt")]
|
|
if len(manifest_names) != 1 or len(index_names) != 1:
|
|
raise SystemExit("Archive must contain one checksum manifest and one file index")
|
|
manifest_name = manifest_names[0]
|
|
prefix = manifest_name[: -len("PACK_MANIFEST.sha256")]
|
|
if index_names[0] != prefix + "FILE_INDEX.txt":
|
|
raise SystemExit("Checksum manifest and file index do not share one root")
|
|
|
|
manifest = parse_manifest(archive.read(manifest_name))
|
|
index = {line for line in archive.read(index_names[0]).decode("utf-8").splitlines() if line}
|
|
archived_relative = {n[len(prefix):] for n in names if n.startswith(prefix) and not n.endswith("/")}
|
|
if index != archived_relative:
|
|
missing = sorted(index - archived_relative)
|
|
extra = sorted(archived_relative - index)
|
|
raise SystemExit(f"FILE_INDEX mismatch; missing={missing}, extra={extra}")
|
|
expected_manifest_paths = archived_relative - {"PACK_MANIFEST.sha256"}
|
|
if set(manifest) != expected_manifest_paths:
|
|
raise SystemExit("Checksum manifest path set does not match archive")
|
|
for relative, expected in manifest.items():
|
|
actual = hashlib.sha256(archive.read(prefix + relative)).hexdigest()
|
|
if actual != expected:
|
|
raise SystemExit(f"Checksum mismatch: {relative}")
|
|
|
|
with tempfile.TemporaryDirectory(prefix="devrunbook-verify-") as temp:
|
|
archive.extractall(temp)
|
|
root = Path(temp) / PurePosixPath(prefix).parts[0]
|
|
subprocess.run([sys.executable, str(root / "scripts/validate_pack.py")], cwd=root, check=True)
|
|
|
|
print("DevRunbook archive verification PASSED")
|
|
print(f"- Entries: {len(names)}")
|
|
print("- Safe paths, no duplicates, no symlinks and valid CRCs")
|
|
print("- Embedded file index and SHA-256 manifest verified")
|
|
print("- Extracted build-pack validator passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|