#!/usr/bin/env python3 """ Generic patch validator scoped to a target working directory. """ from __future__ import annotations import argparse import pathlib import re import sys DEFAULT_FORBIDDEN_BASENAMES = { "Package.resolved", "Podfile.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "Gemfile.lock", } DEFAULT_FORBIDDEN_SUFFIXES = { ".xcconfig", } DEFAULT_FORBIDDEN_PATH_PATTERNS = ( re.compile(r"(^|/)\.git(/|$)"), re.compile(r"(^|/)Info\.plist$"), re.compile(r"(^|/)project\.pbxproj$"), ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Validate unified diff patch by policy.") parser.add_argument("patch_file", type=pathlib.Path) parser.add_argument("--cwd", type=pathlib.Path, default=pathlib.Path.cwd()) parser.add_argument("--max-files", type=int, default=12) parser.add_argument("--max-changed-lines", type=int, default=2000) parser.add_argument("--allow-deletes", action="store_true") parser.add_argument( "--allow-path-prefix", action="append", default=[], help="Allowed path prefix relative to --cwd. Repeatable. Defaults to all paths under --cwd.", ) return parser.parse_args() def fail(message: str) -> None: print(f"FAIL: {message}", file=sys.stderr) raise SystemExit(1) def normalize_patch_path(raw: str) -> str | None: raw = raw.strip() if raw == "/dev/null": return None if raw.startswith("a/") or raw.startswith("b/"): raw = raw[2:] return raw def is_safe_relative_path(path: str) -> bool: p = pathlib.PurePosixPath(path) if p.is_absolute(): return False if ".." in p.parts: return False if path.startswith("./"): return False return True def main() -> None: args = parse_args() if not args.patch_file.exists(): fail(f"patch file not found: {args.patch_file}") cwd = args.cwd.resolve() if not cwd.exists() or not cwd.is_dir(): fail(f"--cwd is not a directory: {cwd}") text = args.patch_file.read_text(encoding="utf-8") lines = text.splitlines() added_lines = 0 removed_lines = 0 touched_files: list[str] = [] deleted_files: list[str] = [] for line in lines: if line.startswith("+++ "): path = normalize_patch_path(line[4:]) if path: touched_files.append(path) continue if line.startswith("+") and not line.startswith("+++"): added_lines += 1 continue if line.startswith("-") and not line.startswith("---"): removed_lines += 1 for match in re.finditer(r"^--- (.+)\n\+\+\+ (.+)$", text, flags=re.MULTILINE): before = normalize_patch_path(match.group(1)) after = normalize_patch_path(match.group(2)) if before and after is None: deleted_files.append(before) unique_files = sorted(set(touched_files + deleted_files)) if not unique_files: fail("no file changes detected") if len(unique_files) > args.max_files: fail(f"too many files changed: {len(unique_files)} > {args.max_files}") changed_line_count = added_lines + removed_lines if changed_line_count > args.max_changed_lines: fail( f"too many changed lines: {changed_line_count} > {args.max_changed_lines}" ) allowed_prefixes = [p.strip("/") for p in args.allow_path_prefix if p.strip("/")] for rel_path in unique_files: if not is_safe_relative_path(rel_path): fail(f"unsafe path in patch: {rel_path}") if allowed_prefixes and not any( rel_path == prefix or rel_path.startswith(prefix + "/") for prefix in allowed_prefixes ): fail(f"path not in allowed prefixes: {rel_path}") basename = pathlib.PurePosixPath(rel_path).name if basename in DEFAULT_FORBIDDEN_BASENAMES: fail(f"forbidden file basename: {rel_path}") if any(rel_path.endswith(s) for s in DEFAULT_FORBIDDEN_SUFFIXES): fail(f"forbidden file suffix: {rel_path}") if any(pattern.search(rel_path) for pattern in DEFAULT_FORBIDDEN_PATH_PATTERNS): fail(f"forbidden file path: {rel_path}") target_path = cwd / rel_path try: target_path.resolve().relative_to(cwd) except ValueError: fail(f"path escapes cwd scope: {rel_path}") if deleted_files and not args.allow_deletes: fail( "deletions are blocked (use --allow-deletes): " + ", ".join(sorted(set(deleted_files))) ) print("OK: patch passed validation") print(f"cwd={cwd}") print(f"files={len(unique_files)} changed_lines={changed_line_count}") if __name__ == "__main__": main()