#!/usr/bin/env python3 """Validate a 1C patch review bundle directory and optional zip archive.""" from __future__ import annotations import argparse import hashlib import json import zipfile from pathlib import Path from typing import Any REQUIRED_FILES = {"manifest.json", "preflight.json", "preflight.md", "diff.json", "README.md"} def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8-sig")) def write_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def issue(severity: str, code: str, message: str, *, path: Path | str | None = None, detail: dict[str, Any] | None = None) -> dict[str, Any]: result: dict[str, Any] = {"severity": severity, "code": code, "message": message} if path is not None: result["path"] = str(path) if detail: result["detail"] = detail return result def safe_relative(relative_path: str) -> Path: path = Path(relative_path.replace("\\", "/")) if path.is_absolute() or ".." in path.parts or not str(path): raise ValueError(relative_path) return path def all_files(root: Path) -> set[str]: if not root.exists(): return set() return {str(path.relative_to(root)).replace("\\", "/") for path in root.rglob("*") if path.is_file()} def expected_working_hash(record: dict[str, Any]) -> str | None: sha = record.get("sha256") or {} if isinstance(sha, dict): return sha.get("working") return None def check_bundle(bundle_dir: Path, zip_path: Path | None = None) -> dict[str, Any]: findings: list[dict[str, Any]] = [] file_checks: list[dict[str, Any]] = [] if not bundle_dir.exists() or not bundle_dir.is_dir(): findings.append(issue("error", "missing_bundle_dir", "Bundle directory is missing.", path=bundle_dir)) return build_result(bundle_dir, zip_path, findings, file_checks, None) present = all_files(bundle_dir) for required in sorted(REQUIRED_FILES): if required not in present: findings.append(issue("error", "missing_bundle_file", "Required bundle file is missing.", path=bundle_dir / required)) manifest_path = bundle_dir / "manifest.json" if not manifest_path.exists(): return build_result(bundle_dir, zip_path, findings, file_checks, None) manifest = load_json(manifest_path) if manifest.get("schema") != "onec_patch_bundle.v1": findings.append(issue("error", "invalid_bundle_schema", "Bundle manifest schema is not onec_patch_bundle.v1.", path=manifest_path, detail={"schema": manifest.get("schema")})) preflight = load_json(bundle_dir / "preflight.json") if (bundle_dir / "preflight.json").exists() else {} if preflight.get("status") != "ready_for_review": findings.append(issue("error", "invalid_preflight_status", "Bundle preflight must be ready_for_review.", path=bundle_dir / "preflight.json", detail={"status": preflight.get("status")})) if not preflight.get("passed"): findings.append(issue("error", "preflight_not_passed", "Bundle preflight is not passed.", path=bundle_dir / "preflight.json")) diff = load_json(bundle_dir / "diff.json") if (bundle_dir / "diff.json").exists() else {} modified = [ str(item.get("relative_path") or "").replace("\\", "/") for item in diff.get("files") or [] if item.get("status") == "modified" ] manifest_relatives = [str(item.get("relative_path") or "").replace("\\", "/") for item in manifest.get("files") or []] if sorted(modified) != sorted(manifest_relatives): findings.append(issue("error", "bundle_diff_manifest_mismatch", "Modified diff files do not match bundle manifest files.", detail={"diff_modified": modified, "manifest_files": manifest_relatives})) expected_bundle_files = set(REQUIRED_FILES) for record in manifest.get("files") or []: bundle_path_raw = str(record.get("bundle_path") or "") try: bundle_path = safe_relative(bundle_path_raw) except ValueError: findings.append(issue("error", "unsafe_bundle_path", "Unsafe bundle_path in manifest.", detail={"bundle_path": bundle_path_raw})) continue expected_bundle_files.add(str(bundle_path).replace("\\", "/")) path = bundle_dir / bundle_path check: dict[str, Any] = { "relative_path": record.get("relative_path"), "bundle_path": str(bundle_path).replace("\\", "/"), "exists": path.exists(), "expected_sha256": expected_working_hash(record), } if not path.exists(): findings.append(issue("error", "missing_modified_file", "Modified bundle file is missing.", path=path)) else: actual = sha256_file(path) check["sha256"] = actual expected = expected_working_hash(record) if expected and actual != expected: findings.append(issue("error", "modified_file_hash_mismatch", "Modified bundle file hash does not match working hash.", path=path, detail={"expected": expected, "actual": actual})) file_checks.append(check) extra_files = sorted(present - expected_bundle_files) for relative in extra_files: findings.append(issue("warning", "extra_bundle_file", "Unexpected file in bundle directory.", path=bundle_dir / relative)) if zip_path is None: candidate = bundle_dir.with_suffix(".zip") zip_path = candidate if candidate.exists() else None if zip_path is None: findings.append(issue("warning", "missing_bundle_zip", "Bundle zip archive was not found.")) elif not zip_path.exists(): findings.append(issue("error", "missing_bundle_zip", "Bundle zip archive path does not exist.", path=zip_path)) else: try: with zipfile.ZipFile(zip_path, "r") as archive: bad_member = archive.testzip() if bad_member: findings.append(issue("error", "invalid_bundle_zip_member", "Zip archive contains a corrupt member.", path=zip_path, detail={"member": bad_member})) zip_files = {name.replace("\\", "/") for name in archive.namelist() if not name.endswith("/")} if zip_files != present: findings.append(issue("error", "bundle_zip_mismatch", "Zip contents differ from bundle directory files.", path=zip_path, detail={"missing_in_zip": sorted(present - zip_files), "extra_in_zip": sorted(zip_files - present)})) except zipfile.BadZipFile as exc: findings.append(issue("error", "invalid_bundle_zip", f"Invalid zip archive: {exc}", path=zip_path)) return build_result(bundle_dir, zip_path, findings, file_checks, manifest) def build_result(bundle_dir: Path, zip_path: Path | None, findings: list[dict[str, Any]], file_checks: list[dict[str, Any]], manifest: dict[str, Any] | None) -> dict[str, Any]: errors = [row for row in findings if row.get("severity") == "error"] warnings = [row for row in findings if row.get("severity") == "warning"] return { "schema": "onec_patch_bundle_check.v1", "bundle_dir": str(bundle_dir), "zip_path": str(zip_path) if zip_path else None, "bundle_schema": (manifest or {}).get("schema"), "passed": not errors, "findings": findings, "file_checks": file_checks, "counts": { "files": len(file_checks), "errors": len(errors), "warnings": len(warnings), }, } def main() -> int: parser = argparse.ArgumentParser(description="Validate a 1C patch review bundle.") parser.add_argument("--bundle-dir", type=Path, required=True) parser.add_argument("--zip", type=Path) parser.add_argument("--output", type=Path) args = parser.parse_args() result = check_bundle(args.bundle_dir, args.zip) if args.output: write_json(args.output, result) print(json.dumps({"output": str(args.output) if args.output else None, "passed": result["passed"], "counts": result["counts"]}, ensure_ascii=False)) return 0 if result["passed"] else 2 if __name__ == "__main__": raise SystemExit(main())