@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
ROOT_NAME = "VacatureRadar_Project"
|
||||
MANIFEST_PATH = f"{ROOT_NAME}/PROJECT_MANIFEST.json"
|
||||
REQUIRED_PATHS = {
|
||||
f"{ROOT_NAME}/README.md",
|
||||
f"{ROOT_NAME}/CODEX_START_HERE.md",
|
||||
f"{ROOT_NAME}/AGENTS.md",
|
||||
f"{ROOT_NAME}/docs/ai/BACKLOG.yaml",
|
||||
f"{ROOT_NAME}/docs/ai/PROJECT_STATE.md",
|
||||
f"{ROOT_NAME}/scripts/codex_bootstrap.sh",
|
||||
f"{ROOT_NAME}/scripts/codex_verify.sh",
|
||||
f"{ROOT_NAME}/uv.lock",
|
||||
MANIFEST_PATH,
|
||||
}
|
||||
FORBIDDEN_PARTS = {
|
||||
".git",
|
||||
".venv",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"backups",
|
||||
"logs",
|
||||
"media",
|
||||
"staticfiles",
|
||||
}
|
||||
FORBIDDEN_NAMES = {".env", ".coverage", "db.sqlite3", "celerybeat-schedule"}
|
||||
|
||||
|
||||
class PackageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def load_manifest(archive: zipfile.ZipFile) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(archive.read(MANIFEST_PATH))
|
||||
except KeyError as exc:
|
||||
raise PackageError("PROJECT_MANIFEST.json ontbreekt") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PackageError(f"PROJECT_MANIFEST.json is ongeldig: {exc}") from exc
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("files"), list):
|
||||
raise PackageError("PROJECT_MANIFEST.json heeft een ongeldig schema")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_names(names: list[str]) -> None:
|
||||
if len(names) != len(set(names)):
|
||||
raise PackageError("ZIP bevat dubbele padnamen")
|
||||
missing = sorted(REQUIRED_PATHS - set(names))
|
||||
if missing:
|
||||
raise PackageError(f"Verplichte paden ontbreken: {missing}")
|
||||
for name in names:
|
||||
path = PurePosixPath(name)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise PackageError(f"Onveilig ZIP-pad: {name}")
|
||||
if not path.parts or path.parts[0] != ROOT_NAME:
|
||||
raise PackageError(f"Bestand staat buiten projectroot: {name}")
|
||||
if any(part in FORBIDDEN_PARTS for part in path.parts):
|
||||
raise PackageError(f"Runtime/cachepad hoort niet in ZIP: {name}")
|
||||
if path.name in FORBIDDEN_NAMES:
|
||||
raise PackageError(f"Runtime/secretbestand hoort niet in ZIP: {name}")
|
||||
if path.suffix in {".pyc", ".pyo"}:
|
||||
raise PackageError(f"Bytecode hoort niet in ZIP: {name}")
|
||||
|
||||
|
||||
def validate_manifest(archive: zipfile.ZipFile, payload: dict[str, Any]) -> None:
|
||||
entries = payload["files"]
|
||||
expected_paths: set[str] = set()
|
||||
total_bytes = 0
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise PackageError("Manifestentry is geen mapping")
|
||||
relative = entry.get("path")
|
||||
expected_size = entry.get("bytes")
|
||||
expected_hash = entry.get("sha256")
|
||||
if not isinstance(relative, str) or not relative:
|
||||
raise PackageError("Manifestentry mist path")
|
||||
archive_path = f"{ROOT_NAME}/{relative}"
|
||||
if archive_path in expected_paths:
|
||||
raise PackageError(f"Dubbele manifestentry: {relative}")
|
||||
expected_paths.add(archive_path)
|
||||
try:
|
||||
data = archive.read(archive_path)
|
||||
except KeyError as exc:
|
||||
raise PackageError(f"Manifestbestand ontbreekt in ZIP: {relative}") from exc
|
||||
if len(data) != expected_size:
|
||||
raise PackageError(f"Grootte wijkt af voor {relative}")
|
||||
if digest(data) != expected_hash:
|
||||
raise PackageError(f"SHA-256 wijkt af voor {relative}")
|
||||
total_bytes += len(data)
|
||||
|
||||
actual_paths = {
|
||||
name for name in archive.namelist() if not name.endswith("/") and name != MANIFEST_PATH
|
||||
}
|
||||
if expected_paths != actual_paths:
|
||||
extra = sorted(actual_paths - expected_paths)
|
||||
missing = sorted(expected_paths - actual_paths)
|
||||
raise PackageError(f"Manifest/ZIP-paden verschillen; extra={extra}, missing={missing}")
|
||||
if payload.get("file_count_excluding_manifest") != len(entries):
|
||||
raise PackageError("Manifest file_count klopt niet")
|
||||
if payload.get("total_bytes_excluding_manifest") != total_bytes:
|
||||
raise PackageError("Manifest total_bytes klopt niet")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verifieer een VacatureRadar-project-ZIP")
|
||||
parser.add_argument("zip_path")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
with zipfile.ZipFile(args.zip_path) as archive:
|
||||
corrupt = archive.testzip()
|
||||
if corrupt:
|
||||
raise PackageError(f"CRC-fout in {corrupt}")
|
||||
names = [name for name in archive.namelist() if not name.endswith("/")]
|
||||
validate_names(names)
|
||||
validate_manifest(archive, load_manifest(archive))
|
||||
except (OSError, zipfile.BadZipFile, PackageError) as exc:
|
||||
print(f"Packagevalidatiefout: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Project-ZIP geldig: {args.zip_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user