|
| 1 | +#!/usr/bin/env python3 |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import re |
| 5 | +import sys |
| 6 | +from pathlib import Path |
| 7 | +from urllib.parse import urlparse |
| 8 | + |
| 9 | + |
| 10 | +SHA_RE = re.compile(r"\b[a-fA-F0-9]{64}\b") |
| 11 | + |
| 12 | + |
| 13 | +def read_sha(path: Path) -> str: |
| 14 | + match = SHA_RE.search(path.read_text(encoding="utf-8")) |
| 15 | + if not match: |
| 16 | + raise SystemExit(f"{path}: no SHA-256 found") |
| 17 | + return match.group(0).lower() |
| 18 | + |
| 19 | + |
| 20 | +def homebrew_desc(raw: str, formula: Path) -> str: |
| 21 | + desc = raw.strip().rstrip(".") |
| 22 | + if len(desc) > 80: |
| 23 | + for sep in (" — ", " – ", " - ", ": "): |
| 24 | + if sep in desc: |
| 25 | + desc = desc.split(sep, 1)[0].strip().rstrip(".") |
| 26 | + break |
| 27 | + if len(desc) > 80: |
| 28 | + raise SystemExit(f"{formula}: Homebrew desc remains over 80 chars") |
| 29 | + return desc |
| 30 | + |
| 31 | + |
| 32 | +def sha_for_url(url: str, dist_dir: Path, formula: Path) -> str: |
| 33 | + artifact = Path(urlparse(url).path).name |
| 34 | + sha_path = dist_dir / f"{artifact}.sha256" |
| 35 | + if not sha_path.exists(): |
| 36 | + raise SystemExit(f"{formula}: missing checksum file for {artifact}") |
| 37 | + return read_sha(sha_path) |
| 38 | + |
| 39 | + |
| 40 | +def add_checksums(lines: list[str], dist_dir: Path, formula: Path) -> list[str]: |
| 41 | + output: list[str] = [] |
| 42 | + i = 0 |
| 43 | + while i < len(lines): |
| 44 | + line = lines[i] |
| 45 | + url_match = re.match(r'^(\s*)url\s+"([^"]+)"\s*$', line) |
| 46 | + if not url_match: |
| 47 | + output.append(line) |
| 48 | + i += 1 |
| 49 | + continue |
| 50 | + |
| 51 | + indent, url = url_match.groups() |
| 52 | + output.append(line) |
| 53 | + i += 1 |
| 54 | + |
| 55 | + if i < len(lines) and re.match(r"^\s*sha256\s+", lines[i]): |
| 56 | + i += 1 |
| 57 | + |
| 58 | + output.append(f'{indent}sha256 "{sha_for_url(url, dist_dir, formula)}"') |
| 59 | + |
| 60 | + return output |
| 61 | + |
| 62 | + |
| 63 | +def normalize_desc_and_version(lines: list[str], formula: Path) -> list[str]: |
| 64 | + output: list[str] = [] |
| 65 | + for line in lines: |
| 66 | + if re.match(r'^\s*version\s+"[^"]+"\s*$', line): |
| 67 | + continue |
| 68 | + |
| 69 | + desc_match = re.match(r'^(\s*)desc\s+"([^"]*)"\s*$', line) |
| 70 | + if desc_match: |
| 71 | + indent, desc = desc_match.groups() |
| 72 | + output.append(f'{indent}desc "{homebrew_desc(desc, formula)}"') |
| 73 | + continue |
| 74 | + |
| 75 | + output.append(line) |
| 76 | + |
| 77 | + return output |
| 78 | + |
| 79 | + |
| 80 | +def normalize_aliases(text: str) -> str: |
| 81 | + pattern = re.compile(r" BINARY_ALIASES = \{\n(?P<body>.*?)\n \}(?:\.freeze)?", re.S) |
| 82 | + match = pattern.search(text) |
| 83 | + if not match: |
| 84 | + return text |
| 85 | + |
| 86 | + keys = re.findall(r'^\s*"([^"]+)":\s*\{\},?\s*$', match.group("body"), re.M) |
| 87 | + if not keys: |
| 88 | + return text |
| 89 | + |
| 90 | + tokens = [f'"{key}":' for key in keys] |
| 91 | + width = max(len(token) for token in tokens) |
| 92 | + body = "\n".join(f" {token}{' ' * (width - len(token) + 1)}{{}}," for token in tokens) |
| 93 | + replacement = f" BINARY_ALIASES = {{\n{body}\n }}.freeze" |
| 94 | + return text[: match.start()] + replacement + text[match.end() :] |
| 95 | + |
| 96 | + |
| 97 | +def normalize_install(text: str, binary: str) -> str: |
| 98 | + pattern = re.compile(r"( def install\n).*?( install_binary_aliases!\n)", re.S) |
| 99 | + return pattern.sub(rf'\1 bin.install "{binary}"\n\2', text, count=1) |
| 100 | + |
| 101 | + |
| 102 | +def add_test_block(text: str, binary: str) -> str: |
| 103 | + if re.search(r"^\s*test do\s*$", text, re.M): |
| 104 | + return text |
| 105 | + |
| 106 | + block = f'\n test do\n assert_match version.to_s, shell_output("#{{bin}}/{binary} --version")\n end\n' |
| 107 | + marker = "\nend\n" |
| 108 | + if not text.endswith(marker): |
| 109 | + raise SystemExit("formula does not end with a class-level end") |
| 110 | + return text[: -len(marker)] + block + "end\n" |
| 111 | + |
| 112 | + |
| 113 | +def harden_formula(formula: Path, dist_dir: Path) -> None: |
| 114 | + lines = formula.read_text(encoding="utf-8").splitlines() |
| 115 | + lines = normalize_desc_and_version(lines, formula) |
| 116 | + lines = add_checksums(lines, dist_dir, formula) |
| 117 | + text = "\n".join(lines) + "\n" |
| 118 | + text = normalize_aliases(text) |
| 119 | + text = normalize_install(text, formula.stem) |
| 120 | + text = add_test_block(text, formula.stem) |
| 121 | + formula.write_text(text, encoding="utf-8") |
| 122 | + |
| 123 | + |
| 124 | +def main() -> None: |
| 125 | + if len(sys.argv) != 2: |
| 126 | + raise SystemExit("usage: harden-homebrew-formula.py <dist-dir>") |
| 127 | + |
| 128 | + dist_dir = Path(sys.argv[1]) |
| 129 | + formulas = sorted(dist_dir.glob("*.rb")) |
| 130 | + for formula in formulas: |
| 131 | + harden_formula(formula, dist_dir) |
| 132 | + |
| 133 | + |
| 134 | +if __name__ == "__main__": |
| 135 | + main() |
0 commit comments