2026-05-13

This commit is contained in:
2026-05-13 20:09:01 -04:00
parent 967565c80b
commit 229ff7dea0
15 changed files with 380 additions and 56 deletions
+166
View File
@@ -0,0 +1,166 @@
#!/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()