|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +check_patches.py - integrity linter for the Fortress patch set. |
| 4 | +
|
| 5 | +Fortress is a set of source patches applied to a pinned Chromium checkout |
| 6 | +(see build/apply-patches.sh + patches/series). Several invariants are load-bearing |
| 7 | +but were only ever enforced by human review. This linter enforces them mechanically |
| 8 | +so CI can gate every PR: |
| 9 | +
|
| 10 | + 1. series-sync - patches/series lists exactly the patches/*.patch files, once each, |
| 11 | + in ascending numeric order. A patch that is not in series is silently |
| 12 | + skipped by apply-patches.sh; an entry with no file breaks the build. |
| 13 | + 2. numbering - files are NNNN-*.patch, contiguous from 0001, no gaps, no duplicates. |
| 14 | + 3. single-surface - each patch touches exactly ONE file (one `diff --git`). The project |
| 15 | + rule is one patch per file so rebases stay legible. |
| 16 | + 4. well-formed - each patch has a `diff --git` header, ---/+++ file headers, and >=1 hunk. |
| 17 | + 5. uxr-only - any command-line switch a patch introduces uses the de-branded `uxr-` |
| 18 | + prefix. A `--fortress-*` / `--tilion-*` switch would bake a brand token |
| 19 | + into the binary and is forbidden. |
| 20 | + 6. no-brand - no added line introduces a quoted string literal containing a brand |
| 21 | + token (tilion/tillion/fortress/phoron/swarm). Such a literal ships in |
| 22 | + the binary's string table and is fingerprintable. (Comments are fine.) |
| 23 | +
|
| 24 | +Exit code 0 if every check passes, 1 otherwise. Pure standard library; no build tree needed. |
| 25 | +
|
| 26 | + python tools/check_patches.py # from the repo root |
| 27 | + python tools/check_patches.py --verbose |
| 28 | +""" |
| 29 | +from __future__ import annotations |
| 30 | +import argparse |
| 31 | +import re |
| 32 | +import sys |
| 33 | +from pathlib import Path |
| 34 | + |
| 35 | +REPO = Path(__file__).resolve().parent.parent |
| 36 | +PATCHES = REPO / "patches" |
| 37 | +SERIES = PATCHES / "series" |
| 38 | + |
| 39 | +# Brand tokens that must never appear as a compiled-in string literal. |
| 40 | +BRAND_RE = re.compile(r'"[^"\n]*(tilion|tillion|fortress|phoron|swarm)[^"\n]*"', re.IGNORECASE) |
| 41 | +# Command-line switch lookups a patch may add. |
| 42 | +SWITCH_RE = re.compile(r'(?:HasSwitch|GetSwitchValueASCII|GetSwitchValueNative)\(\s*"([^"]+)"') |
| 43 | +PATCH_NAME_RE = re.compile(r"^(\d{4})-.*\.patch$") |
| 44 | + |
| 45 | + |
| 46 | +class Report: |
| 47 | + def __init__(self) -> None: |
| 48 | + self.failures: list[str] = [] |
| 49 | + |
| 50 | + def check(self, name: str, ok: bool, detail: str = "") -> None: |
| 51 | + mark = "PASS" if ok else "FAIL" |
| 52 | + line = f" [{mark}] {name}" |
| 53 | + if detail: |
| 54 | + line += f" - {detail}" |
| 55 | + print(line) |
| 56 | + if not ok: |
| 57 | + self.failures.append(name) |
| 58 | + |
| 59 | + |
| 60 | +def _patch_files() -> list[Path]: |
| 61 | + return sorted(p for p in PATCHES.glob("*.patch")) |
| 62 | + |
| 63 | + |
| 64 | +def _strip_comment(added: str) -> str: |
| 65 | + """Drop a trailing // line comment so comments never trip the string checks.""" |
| 66 | + i = added.find("//") |
| 67 | + return added[:i] if i != -1 else added |
| 68 | + |
| 69 | + |
| 70 | +def check_series_sync(rep: Report, verbose: bool) -> None: |
| 71 | + if not SERIES.exists(): |
| 72 | + rep.check("series-sync", False, "patches/series is missing") |
| 73 | + return |
| 74 | + listed = [ln.strip() for ln in SERIES.read_text().splitlines() |
| 75 | + if ln.strip() and not ln.strip().startswith("#")] |
| 76 | + listed_names = [Path(x).name for x in listed] |
| 77 | + actual = [p.name for p in _patch_files()] |
| 78 | + |
| 79 | + dupes = sorted({n for n in listed_names if listed_names.count(n) > 1}) |
| 80 | + missing_file = [n for n in listed_names if n not in actual] # in series, no file |
| 81 | + missing_entry = [n for n in actual if n not in listed_names] # file, not in series |
| 82 | + ordered = listed_names == sorted(listed_names) |
| 83 | + |
| 84 | + ok = not dupes and not missing_file and not missing_entry and ordered |
| 85 | + detail = f"{len(listed_names)} entries / {len(actual)} files" |
| 86 | + if dupes: |
| 87 | + detail = f"duplicate series entries: {dupes}" |
| 88 | + elif missing_file: |
| 89 | + detail = f"series lists patches with no file: {missing_file}" |
| 90 | + elif missing_entry: |
| 91 | + detail = f"patch files not listed in series (would be skipped by apply-patches.sh): {missing_entry}" |
| 92 | + elif not ordered: |
| 93 | + detail = "series is not in ascending order" |
| 94 | + rep.check("series-sync", ok, detail) |
| 95 | + |
| 96 | + |
| 97 | +def check_numbering(rep: Report, verbose: bool) -> None: |
| 98 | + nums: list[int] = [] |
| 99 | + bad_names: list[str] = [] |
| 100 | + for p in _patch_files(): |
| 101 | + m = PATCH_NAME_RE.match(p.name) |
| 102 | + if not m: |
| 103 | + bad_names.append(p.name) |
| 104 | + else: |
| 105 | + nums.append(int(m.group(1))) |
| 106 | + dupes = sorted({n for n in nums if nums.count(n) > 1}) |
| 107 | + expected = list(range(1, len(nums) + 1)) if nums else [] |
| 108 | + gaps = sorted(set(expected) - set(nums)) |
| 109 | + ok = not bad_names and not dupes and not gaps |
| 110 | + if bad_names: |
| 111 | + detail = f"non-conforming names: {bad_names}" |
| 112 | + elif dupes: |
| 113 | + detail = f"duplicate numbers: {[f'{n:04d}' for n in dupes]}" |
| 114 | + elif gaps: |
| 115 | + detail = f"missing numbers: {[f'{n:04d}' for n in gaps]}" |
| 116 | + else: |
| 117 | + detail = f"0001..{max(nums):04d} contiguous" if nums else "no patches" |
| 118 | + rep.check("numbering", ok, detail) |
| 119 | + |
| 120 | + |
| 121 | +def check_bodies(rep: Report, verbose: bool) -> None: |
| 122 | + """single-surface + well-formed + uxr-only + no-brand, per patch.""" |
| 123 | + multi_file: list[str] = [] |
| 124 | + malformed: list[str] = [] |
| 125 | + bad_switch: list[str] = [] |
| 126 | + brand_hits: list[str] = [] |
| 127 | + |
| 128 | + for p in _patch_files(): |
| 129 | + text = p.read_text(encoding="utf-8", errors="replace") |
| 130 | + lines = text.splitlines() |
| 131 | + diff_headers = [ln for ln in lines if ln.startswith("diff --git ")] |
| 132 | + has_minus = any(ln.startswith("--- ") for ln in lines) |
| 133 | + has_plus = any(ln.startswith("+++ ") for ln in lines) |
| 134 | + has_hunk = any(ln.startswith("@@") for ln in lines) |
| 135 | + |
| 136 | + if len(diff_headers) != 1: |
| 137 | + multi_file.append(f"{p.name} ({len(diff_headers)} files)") |
| 138 | + if not (diff_headers and has_minus and has_plus and has_hunk): |
| 139 | + malformed.append(p.name) |
| 140 | + |
| 141 | + for ln in lines: |
| 142 | + if not ln.startswith("+") or ln.startswith("+++"): |
| 143 | + continue |
| 144 | + added = _strip_comment(ln[1:]) |
| 145 | + for sw in SWITCH_RE.findall(added): |
| 146 | + if not sw.startswith("uxr-"): |
| 147 | + bad_switch.append(f"{p.name}: --{sw}") |
| 148 | + if BRAND_RE.search(added): |
| 149 | + brand_hits.append(f"{p.name}: {ln.strip()[:70]}") |
| 150 | + |
| 151 | + rep.check("single-surface", not multi_file, |
| 152 | + f"all patches touch one file" if not multi_file else f"multi-file: {multi_file}") |
| 153 | + rep.check("well-formed", not malformed, |
| 154 | + "all patches parse" if not malformed else f"malformed: {malformed}") |
| 155 | + rep.check("uxr-only-switches", not bad_switch, |
| 156 | + "all switches use the uxr- prefix" if not bad_switch else f"non-uxr: {bad_switch}") |
| 157 | + rep.check("no-brand-literals", not brand_hits, |
| 158 | + "no brand string literals in added code" if not brand_hits |
| 159 | + else f"brand literal would ship in binary: {brand_hits}") |
| 160 | + |
| 161 | + |
| 162 | +def main() -> int: |
| 163 | + ap = argparse.ArgumentParser(description="Integrity linter for the Fortress patch set.") |
| 164 | + ap.add_argument("-v", "--verbose", action="store_true") |
| 165 | + args = ap.parse_args() |
| 166 | + |
| 167 | + if not PATCHES.is_dir(): |
| 168 | + print(f"error: {PATCHES} not found (run from the repo root)", file=sys.stderr) |
| 169 | + return 1 |
| 170 | + |
| 171 | + print(f"Fortress patch-set linter - {len(_patch_files())} patches in {PATCHES.relative_to(REPO)}/") |
| 172 | + rep = Report() |
| 173 | + check_series_sync(rep, args.verbose) |
| 174 | + check_numbering(rep, args.verbose) |
| 175 | + check_bodies(rep, args.verbose) |
| 176 | + |
| 177 | + print("-" * 60) |
| 178 | + if rep.failures: |
| 179 | + print(f"FAILED: {len(rep.failures)} check(s): {', '.join(rep.failures)}") |
| 180 | + return 1 |
| 181 | + print("OK: all patch-set checks passed") |
| 182 | + return 0 |
| 183 | + |
| 184 | + |
| 185 | +if __name__ == "__main__": |
| 186 | + raise SystemExit(main()) |
0 commit comments