2026-05-13
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user