#!/usr/bin/env python3 """Check integrity of a 1C patch workspace before diff/package/apply steps.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path from typing import Any def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8-sig")) 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: str | None = None, record: dict[str, Any] | None = None) -> dict[str, Any]: result = {"severity": severity, "code": code, "message": message} if path: result["path"] = path if record: result["record"] = record return result def is_relative_safe(relative_path: str) -> bool: path = Path(relative_path) return not path.is_absolute() and ".." not in path.parts def expected_paths(workspace: Path, relative_path: str) -> tuple[Path, Path]: rel = Path(relative_path) return workspace / "original" / rel, workspace / "working" / rel def all_files(root: Path) -> list[Path]: if not root.exists(): return [] return sorted(path for path in root.rglob("*") if path.is_file()) def rel_set(root: Path) -> set[str]: result = set() for path in all_files(root): result.add(str(path.relative_to(root)).replace("\\", "/")) return result def check_workspace(workspace: Path) -> dict[str, Any]: manifest_path = workspace / "manifest.json" findings = [] if not manifest_path.exists(): findings.append(issue("error", "missing_manifest", "Workspace manifest.json is missing.", path=str(manifest_path))) return result(workspace, findings, []) manifest = load_json(manifest_path) records = manifest.get("files") or [] manifest_relatives = set() file_checks = [] for record in records: relative = str(record.get("relative_path") or "") manifest_relatives.add(relative) if not relative or not is_relative_safe(relative): findings.append(issue("error", "unsafe_relative_path", f"Unsafe relative path in manifest: {relative}", record=record)) continue original, working = expected_paths(workspace, relative) check = { "relative_path": relative, "original_path": str(original), "working_path": str(working), "expected_sha256": record.get("sha256"), "original_exists": original.exists(), "working_exists": working.exists(), } if not original.exists(): findings.append(issue("error", "missing_original_file", "Original file is missing.", path=str(original), record=record)) else: actual = sha256_file(original) check["original_sha256"] = actual if record.get("sha256") and actual != record.get("sha256"): findings.append(issue("error", "original_hash_mismatch", "Original file hash differs from manifest; original/ must stay immutable.", path=str(original), record=record)) if not working.exists(): findings.append(issue("error", "missing_working_file", "Working file is missing.", path=str(working), record=record)) else: check["working_sha256"] = sha256_file(working) file_checks.append(check) original_extra = sorted(rel_set(workspace / "original") - manifest_relatives) working_extra = sorted(rel_set(workspace / "working") - manifest_relatives) for relative in original_extra: findings.append(issue("error", "extra_original_file", "Unexpected file under original/.", path=str(workspace / "original" / Path(relative)))) for relative in working_extra: findings.append(issue("warning", "extra_working_file", "Unexpected file under working/; future packaging must explicitly include or reject it.", path=str(workspace / "working" / Path(relative)))) if not (workspace / "proposal.json").exists(): findings.append(issue("warning", "missing_proposal_copy", "proposal.json is missing from workspace.", path=str(workspace / "proposal.json"))) if not (workspace / "safety.json").exists(): findings.append(issue("warning", "missing_safety_copy", "safety.json is missing from workspace.", path=str(workspace / "safety.json"))) return result(workspace, findings, file_checks, manifest=manifest) def result(workspace: Path, findings: list[dict[str, Any]], file_checks: list[dict[str, Any]], manifest: dict[str, Any] | None = 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_workspace_integrity.v1", "workspace": str(workspace), "manifest_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="Check 1C patch workspace integrity.") parser.add_argument("--workspace", type=Path, required=True) parser.add_argument("--output", type=Path) args = parser.parse_args() check = check_workspace(args.workspace) output = json.dumps(check, ensure_ascii=False, indent=2) + "\n" if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(output, encoding="utf-8") print(json.dumps({"output": str(args.output) if args.output else None, "passed": check["passed"], "counts": check["counts"]}, ensure_ascii=False)) return 0 if check["passed"] else 2 if __name__ == "__main__": raise SystemExit(main())