#!/usr/bin/env python3 """Create fixed exact-quote proofs and verify them without executing request argv.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path import sys from typing import Any import proof_manifest from fs_transaction import replace_many REQUEST_SCHEMA = "proof-request/v1" DEFAULT_ROOT = Path(__file__).resolve().parents[2] RESULT_SCHEMA = "proof-runner-result/v1" class ProofRequestError(ValueError): def __init__(self, code: str, message: str, location: str = "") -> None: self.code = code self.location = location super().__init__(message) def _source_path( repo_root: Path, run_root: Path | None, source: dict[str, Any], ) -> tuple[str, str, Path]: namespace = source.get("namespace", "repo") if namespace not in {"repo", "run"}: raise ProofRequestError("INVALID_SOURCE_NAMESPACE", "source.namespace must be repo or run") value = source.get("path") if not isinstance(value, str) or not value or "\\" in value: raise ProofRequestError("INVALID_SOURCE_PATH", "source.path must be a namespace-relative POSIX path") candidate = Path(value) if candidate.is_absolute(): raise ProofRequestError("SOURCE_OUTSIDE_NAMESPACE", "absolute source path is not allowed", value) base = repo_root if namespace == "repo" else run_root if base is None: raise ProofRequestError("RUN_ROOT_REQUIRED", "run namespace requires --run-root", value) resolved = (base / candidate).resolve() try: resolved.relative_to(base) except ValueError as exc: raise ProofRequestError("SOURCE_OUTSIDE_NAMESPACE", f"source path escapes {namespace} root", value) from exc if not resolved.is_file(): raise ProofRequestError("SOURCE_NOT_FOUND", "source file does not exist", value) return namespace, candidate.as_posix(), resolved def _line_range(text: str, quote: str, source: dict[str, Any], location: str) -> tuple[int, int]: start_value = source.get("line_start") end_value = source.get("line_end") if (start_value is None) != (end_value is None): raise ProofRequestError("INCOMPLETE_LINE_RANGE", "line_start and line_end must be supplied together", location) if start_value is not None: if ( not isinstance(start_value, int) or isinstance(start_value, bool) or not isinstance(end_value, int) or isinstance(end_value, bool) or start_value < 1 or end_value < start_value ): raise ProofRequestError("INVALID_LINE_RANGE", "line range must contain positive ordered integers", location) lines = text.splitlines(keepends=True) if end_value > len(lines) or quote not in "".join(lines[start_value - 1 : end_value]): raise ProofRequestError("QUOTE_MISMATCH", "quote is not inside the requested line range", location) return start_value, end_value positions: list[int] = [] cursor = text.find(quote) while cursor >= 0: positions.append(cursor) cursor = text.find(quote, cursor + 1) if not positions: raise ProofRequestError("QUOTE_MISMATCH", "expected quote was not found", location) if len(positions) != 1: raise ProofRequestError("AMBIGUOUS_QUOTE", "quote occurs more than once; provide a line range", location) start_index = positions[0] end_index = start_index + len(quote) - 1 return text.count("\n", 0, start_index) + 1, text.count("\n", 0, end_index) + 1 def build_manifest(request: Any, root: Path, run_root: Path | None = None) -> dict[str, Any]: if not isinstance(request, dict) or request.get("schema_version") != REQUEST_SCHEMA: raise ProofRequestError("REQUEST_SCHEMA_MISMATCH", f"expected {REQUEST_SCHEMA}") run = request.get("run") proofs = request.get("proofs") if not isinstance(run, dict) or set(run) != {"id", "profile"}: raise ProofRequestError("INVALID_RUN", "run must contain only id and profile") if not isinstance(proofs, list) or not proofs: raise ProofRequestError("INVALID_PROOFS", "proofs must be a non-empty array") generated: list[dict[str, Any]] = [] for index, item in enumerate(proofs): location = f"proofs[{index}]" if not isinstance(item, dict) or set(item) != {"finding", "source"}: raise ProofRequestError("INVALID_PROOF_REQUEST", "proof must contain finding and source", location) finding = item.get("finding") source = item.get("source") if not isinstance(finding, dict) or set(finding) != {"id", "role"} or not isinstance(source, dict): raise ProofRequestError("INVALID_PROOF_REQUEST", "invalid finding/source object", location) allowed_source = {"namespace", "path", "quote_utf8", "line_start", "line_end"} if set(source) - allowed_source or not {"path", "quote_utf8"}.issubset(source): raise ProofRequestError("INVALID_PROOF_REQUEST", "invalid source fields", location) namespace, relative, resolved = _source_path(root, run_root, source) quote = source.get("quote_utf8") if not isinstance(quote, str) or not quote: raise ProofRequestError("INVALID_QUOTE", "quote_utf8 must be non-empty", location) source_bytes = resolved.read_bytes() try: source_text = source_bytes.decode("utf-8", errors="strict") except UnicodeDecodeError as exc: raise ProofRequestError("SOURCE_NOT_UTF8", str(exc), relative) from exc line_start, line_end = _line_range(source_text, quote, source, location) generated.append( { "finding": {"id": finding.get("id"), "role": finding.get("role")}, "source": { "namespace": namespace, "path": relative, "sha256": hashlib.sha256(source_bytes).hexdigest(), "line_start": line_start, "line_end": line_end, "quote_utf8": quote, }, "execution": { "argv": ["proof-runner/exact-utf8-v1", namespace, relative, f"{line_start}:{line_end}"], "exit_code": 0, "stdout_utf8": quote, "stdout_sha256": hashlib.sha256(quote.encode("utf-8")).hexdigest(), "exact_match": True, }, } ) return {"schema_version": proof_manifest.SCHEMA_VERSION, "run": dict(run), "proofs": generated} def _failure(exc: Exception) -> dict[str, Any]: if isinstance(exc, proof_manifest.ManifestValidationError): errors = exc.issues else: errors = [{"code": getattr(exc, "code", "IO_OR_JSON_ERROR"), "location": getattr(exc, "location", ""), "message": str(exc)}] return {"schema_version": proof_manifest.RESULT_SCHEMA_VERSION, "status": "FAIL", "errors": errors} def _display_path(path: Path, root: Path) -> str: resolved = path.resolve() try: return resolved.relative_to(root).as_posix() except ValueError: return resolved.as_posix() def _constrained_output(path: Path, repo_root: Path, run_root: Path | None) -> Path: output = path.resolve() roots = [repo_root, *( [run_root] if run_root is not None else [] )] if not any(output.is_relative_to(root) for root in roots): raise ProofRequestError( "OUTPUT_OUTSIDE_ALLOWED_ROOT", "proof output must be under --repo-root or --run-root", output.as_posix(), ) if not output.parent.is_dir(): raise ProofRequestError("OUTPUT_PARENT_NOT_FOUND", "proof output parent does not exist", output.parent.as_posix()) return output def render_report_summary(manifest_path: str, manifest_sha256: str, proof_count: int) -> str: """Render the compact proof section consumed by report workflows.""" return ( "## 증명 결과\n\n" f"- Manifest: `{manifest_path}`\n" f"- Manifest SHA-256: `{manifest_sha256}`\n" f"- Proof: {proof_count}\n" f"- PASS: {proof_count}\n" "- FAIL: 0\n" ) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("request", type=Path, help="proof-request/v1 JSON") parser.add_argument("--repo-root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--run-root", type=Path, help="isolated root for source.namespace=run and run artifacts") parser.add_argument("--profiles", type=Path, default=proof_manifest.DEFAULT_PROFILES) parser.add_argument("--output", type=Path, required=True) parser.add_argument( "--summary-output", type=Path, help="compact Markdown proof summary (default: proof-summary.md beside --output)", ) args = parser.parse_args(argv) try: root = args.repo_root.resolve(strict=True) run_root = args.run_root.resolve(strict=True) if args.run_root is not None else None if run_root is not None and not run_root.is_dir(): raise ProofRequestError("INVALID_RUN_ROOT", "--run-root must be a directory", str(run_root)) with args.request.open("r", encoding="utf-8") as stream: request = json.load(stream) manifest = build_manifest(request, root, run_root) profiles = proof_manifest.load_allowed_profiles(args.profiles.resolve(strict=True)) verified = proof_manifest.verify_manifest(manifest, root, profiles, run_root=run_root) manifest_content = proof_manifest.manifest_bytes(verified) manifest_hash = hashlib.sha256(manifest_content).hexdigest() output = _constrained_output(args.output, root, run_root) summary_output = _constrained_output(args.summary_output or args.output.with_name("proof-summary.md"), root, run_root) if output == summary_output: raise ProofRequestError("OUTPUT_PATH_COLLISION", "manifest and summary outputs must differ", str(output)) manifest_display = _display_path(output, root) summary_content = render_report_summary( manifest_display, manifest_hash, verified["verification"]["proof_count"], ).encode("utf-8") replace_many({output: manifest_content, summary_output: summary_content}) result = { "schema_version": RESULT_SCHEMA, "status": "PASS", "manifest": {"path": manifest_display, "sha256": manifest_hash}, "report_summary": {"path": _display_path(summary_output, root)}, "proof_count": verified["verification"]["proof_count"], "pass_count": verified["verification"]["pass_count"], "fail_count": verified["verification"]["fail_count"], } json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") return 0 except (ProofRequestError, proof_manifest.ManifestValidationError, OSError, UnicodeError, json.JSONDecodeError) as exc: json.dump(_failure(exc), sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") return 2 if __name__ == "__main__": raise SystemExit(main())