167 lines
4.8 KiB
Python
167 lines
4.8 KiB
Python
#!/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()
|