|
1 | | -"""Check the files a release is about to publish. |
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Check the files a release is about to publish, and the commit messages it ships. |
2 | 3 |
|
3 | | -Usage: python3 check_names.py <file or directory>... |
| 4 | + check_names.py [--tracked] [--commits] [--unserved-everywhere] [--names-optional] [PATH...] |
4 | 5 |
|
5 | | -Reads every file given, every file under a directory, and every member of an archive |
6 | | -(.whl, .zip, .tar, .tar.gz, .tgz, .gem, nested archives and .gz members included), and |
7 | | -fails when one holds an internal reference or a word that must not ship, or when a |
8 | | -README or the package's description names a booster production does not serve yet. On |
9 | | -a hit it prints the file and line, never the word. |
| 6 | +Reads every file given, every file under a directory, every file git tracks (--tracked), |
| 7 | +and every member of an archive (zip, jar, whl, nupkg, tar, tar.gz, tgz, crate, gem, with |
| 8 | +nested archives and .gz members opened in turn). A file fails when it holds: |
| 9 | +
|
| 10 | +- a word or phrase of the internal-name list, matched case-insensitively where no |
| 11 | + letter touches it on either side (a digit, an underscore or a camelCase hump is a |
| 12 | + boundary, and a phrase's words may be split by spaces, underscores or hyphens), in |
| 13 | + its text, in its UTF-16 strings and inside base64 runs; a file whose path holds one |
| 14 | + fails too; |
| 15 | +- a path into internal documents, a local home directory or an attribution line; |
| 16 | +- a booster production does not serve yet, when the file is a README or carries the |
| 17 | + package's description (in every file with --unserved-everywhere). |
| 18 | +
|
| 19 | +--commits also checks the message of every commit since the last v* tag before HEAD |
| 20 | +(every commit, while there is no such tag) for the internal names. |
| 21 | +
|
| 22 | +The internal-name list is read only from the DN_NAME_CHECK_WORDS environment variable, |
| 23 | +one lowercase word or phrase a line. Without it the check fails, except on a pull |
| 24 | +request from a fork or from Dependabot, which gets no secrets: there the internal |
| 25 | +names are skipped with a notice and every other check still runs. --names-optional, |
| 26 | +for ordinary CI, skips them as well whenever the list is missing; the release |
| 27 | +check never passes it. A hit prints the |
| 28 | +file and line, or the commit, and never the matched text; a path that itself holds an |
| 29 | +internal name is not printed either. |
10 | 30 | """ |
11 | 31 |
|
12 | 32 | from __future__ import annotations |
13 | 33 |
|
| 34 | +import base64 |
| 35 | +import binascii |
14 | 36 | import gzip |
15 | | -import hashlib |
16 | 37 | import io |
| 38 | +import json |
| 39 | +import os |
17 | 40 | import re |
| 41 | +import subprocess |
18 | 42 | import sys |
19 | 43 | import tarfile |
20 | 44 | import zipfile |
21 | | -from pathlib import Path |
22 | | -from typing import Iterator, List, Tuple |
23 | | - |
24 | | -FORBIDDEN = re.compile( |
25 | | - r"docs/[0-9]{2}|backend/[0-9]{2}|products/|/Users/|co-authored-by|generated with", |
26 | | - re.IGNORECASE, |
27 | | -) |
28 | | -# Words that must not ship, matched case-insensitively and kept as SHA-256 digests so |
29 | | -# this repository does not spell them: four found anywhere in a word, one only as a |
30 | | -# whole word. |
31 | | -INSIDE_WORDS = { |
32 | | - "9e9b2efac0b62e4cf6f08cc6b7095e6ad7e18c447458c5849accb698f7149ef3", |
33 | | - "934a11e602682e862b4b4d1f881ae8da1e097e3f6964bd780e5bb1fb798a6fdd", |
34 | | - "b7933ed6180d9e2895a9d2df885e2a8b92e998a3c16297581f5b8698daacf492", |
35 | | - "31d1b3d540ecf85228db7797efbe7d198ec5406abd3f6e64f27f684e9a312d29", |
36 | | -} |
37 | | -INSIDE_LENGTHS = (6, 7) |
38 | | -WHOLE_WORDS = {"2e0b8d61fa2a6959d254b6ff5d0fb512249329097336a35568089933b49abdde"} |
39 | | -ARCHIVE = re.compile(r"\.(whl|zip|tar|tar\.gz|tgz|gem)$") |
40 | | -# Boosters a README may name only once production serves them, and the files that carry |
41 | | -# a README or the package's description: a README itself, a wheel's METADATA or an sdist's |
| 45 | +from typing import Iterator, List, Optional, Pattern, Tuple |
| 46 | + |
| 47 | +ENV = "DN_NAME_CHECK_WORDS" |
| 48 | + |
| 49 | +# Written so that none of them matches its own source text. |
| 50 | +PLAIN = [ |
| 51 | + ("a path into internal documents", re.compile(r"docs/[0-9]{2}|backend/[0-9]{2}|product[s]/", re.I)), |
| 52 | + ("a local home directory", re.compile(r"/user[s]/", re.I)), |
| 53 | + ("an attribution line", re.compile(r"co-authored-b[y]|generated[ ]with", re.I)), |
| 54 | +] |
| 55 | +# Boosters a README or the package's description may name only once production serves |
| 56 | +# them, and the files that carry one: a README, a wheel's METADATA or an sdist's |
42 | 57 | # PKG-INFO, an npm package.json, a gem's metadata. |
43 | | -UNSERVED = re.compile(r"face_detection|wanted_person|face_recognition") |
44 | | -DESCRIBES = re.compile(r"(^|/)(README[^/]*|PKG-INFO|METADATA|package\.json|metadata)$", re.IGNORECASE) |
| 58 | +UNSERVED = re.compile(r"face_detectio[n]|face_recognitio[n]|wanted_perso[n]", re.I) |
| 59 | +DESCRIBES = re.compile(r"(^|/)(README[^/]*|PKG-INFO|METADATA|package\.json|metadata)$", re.I) |
| 60 | + |
| 61 | +TAR = re.compile(r"\.(tar|tar\.gz|tgz|crate|gem)$", re.I) |
| 62 | +B64 = re.compile(r"[A-Za-z0-9+/]{8,}={0,2}") |
| 63 | +HUMP = re.compile(r"([a-z0-9])([A-Z])") |
| 64 | +FOUND = "internal name found" |
| 65 | + |
| 66 | + |
| 67 | +def compile_names(raw: str) -> Optional[Pattern[str]]: |
| 68 | + """One pattern for the whole list; None when the list is empty.""" |
| 69 | + entries = set() |
| 70 | + for line in raw.splitlines(): |
| 71 | + parts = [p for p in re.split(r"[\s_-]+", line.strip().lower()) if p] |
| 72 | + if parts: |
| 73 | + entries.add(r"[\s_-]+".join(re.escape(p) for p in parts)) |
| 74 | + if not entries: |
| 75 | + return None |
| 76 | + alternatives = "|".join(sorted(entries, key=len, reverse=True)) |
| 77 | + return re.compile(r"(?<![a-z])(?:%s)(?![a-z])" % alternatives) |
| 78 | + |
| 79 | + |
| 80 | +def withheld() -> bool: |
| 81 | + """A pull request run that GitHub gives no secrets: from a fork, or from Dependabot.""" |
| 82 | + if os.environ.get("GITHUB_EVENT_NAME") != "pull_request": |
| 83 | + return False |
| 84 | + if os.environ.get("GITHUB_ACTOR") == "dependabot[bot]": |
| 85 | + return True |
| 86 | + try: |
| 87 | + with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as f: |
| 88 | + event = json.load(f) |
| 89 | + except (KeyError, OSError, ValueError): |
| 90 | + return False |
| 91 | + head = ((event.get("pull_request") or {}).get("head") or {}).get("repo") |
| 92 | + base = (event.get("repository") or {}).get("full_name") |
| 93 | + return not head or head.get("full_name") != base |
45 | 94 |
|
46 | 95 |
|
47 | | -def digest(text: str) -> str: |
48 | | - return hashlib.sha256(text.encode("utf-8")).hexdigest() |
| 96 | +def offsets(names: Pattern[str], text: str) -> Iterator[int]: |
| 97 | + """Where a name starts, as is and with camelCase humps split; no newline is added, |
| 98 | + so a line number counted in either view is the line in the text.""" |
| 99 | + for view in (text.lower(), HUMP.sub(r"\1 \2", text).lower()): |
| 100 | + for m in names.finditer(view): |
| 101 | + yield view.count("\n", 0, m.start()) + 1 |
49 | 102 |
|
50 | 103 |
|
51 | 104 | def members(name: str, data: bytes) -> Iterator[Tuple[str, bytes]]: |
52 | 105 | """The files inside an archive, nested archives opened in turn; a plain file as is.""" |
53 | | - if name.endswith((".whl", ".zip")): |
54 | | - with zipfile.ZipFile(io.BytesIO(data)) as z: |
55 | | - for info in z.infolist(): |
56 | | - if not info.is_dir(): |
57 | | - yield from members("%s!%s" % (name, info.filename), z.read(info)) |
58 | | - elif ARCHIVE.search(name): |
59 | | - with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as t: |
60 | | - for m in t.getmembers(): |
61 | | - if m.isfile(): |
62 | | - f = t.extractfile(m) |
| 106 | + try: |
| 107 | + if data[:4] == b"PK\x03\x04": |
| 108 | + with zipfile.ZipFile(io.BytesIO(data)) as z: |
| 109 | + inner = [(i.filename, z.read(i)) for i in z.infolist() if not i.is_dir()] |
| 110 | + elif TAR.search(name): |
| 111 | + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as t: |
| 112 | + inner = [] |
| 113 | + for m in t.getmembers(): |
| 114 | + f = t.extractfile(m) if m.isfile() else None |
63 | 115 | if f is not None: |
64 | | - yield from members("%s!%s" % (name, m.name), f.read()) |
65 | | - elif name.endswith(".gz"): |
66 | | - yield from members(name[: -len(".gz")], gzip.decompress(data)) |
67 | | - else: |
| 116 | + inner.append((m.name, f.read())) |
| 117 | + elif data[:2] == b"\x1f\x8b": |
| 118 | + yield from members(re.sub(r"\.gz$", "", name), gzip.decompress(data)) |
| 119 | + return |
| 120 | + else: |
| 121 | + yield name, data |
| 122 | + return |
| 123 | + except (zipfile.BadZipFile, tarfile.TarError, OSError, EOFError): |
68 | 124 | yield name, data |
| 125 | + return |
| 126 | + for inner_name, inner_data in inner: |
| 127 | + yield from members("%s!%s" % (name, inner_name), inner_data) |
69 | 128 |
|
70 | 129 |
|
71 | | -def scan(name: str, data: bytes) -> List[str]: |
72 | | - text = data.decode("utf-8", errors="replace") |
73 | | - errors: List[str] = [] |
| 130 | +class Check: |
| 131 | + def __init__(self, names: Optional[Pattern[str]], unserved_everywhere: bool) -> None: |
| 132 | + self.names = names |
| 133 | + self.unserved_everywhere = unserved_everywhere |
| 134 | + self.findings: List[str] = [] |
| 135 | + self.files = 0 |
| 136 | + self.commits = 0 |
74 | 137 |
|
75 | | - def found(start: int, what: str) -> None: |
76 | | - errors.append("%s line %d contains %s" % (name, text.count("\n", 0, start) + 1, what)) |
| 138 | + def named(self, text: str) -> bool: |
| 139 | + return self.names is not None and any(True for _ in offsets(self.names, text)) |
77 | 140 |
|
78 | | - for m in FORBIDDEN.finditer(text): |
79 | | - found(m.start(), repr(m.group(0))) |
80 | | - if DESCRIBES.search(name.split("!")[-1]): |
81 | | - for m in UNSERVED.finditer(text): |
82 | | - found(m.start(), "%r, a booster production does not serve yet" % m.group(0)) |
83 | | - lower = text.lower() |
84 | | - for m in re.finditer(r"[a-z]{%d,}" % min(INSIDE_LENGTHS), lower): |
85 | | - run = m.group(0) |
86 | | - for size in INSIDE_LENGTHS: |
87 | | - for i in range(len(run) - size + 1): |
88 | | - if digest(run[i : i + size]) in INSIDE_WORDS: |
89 | | - found(m.start() + i, "a word that must not ship") |
90 | | - for m in re.finditer(r"\w+", lower): |
91 | | - if digest(m.group(0)) in WHOLE_WORDS: |
92 | | - found(m.start(), "a word that must not ship") |
93 | | - return errors |
| 141 | + def label(self, top: int, path: str) -> str: |
| 142 | + """The path to print: as is, unless it holds an internal name itself.""" |
| 143 | + if not self.named(path): |
| 144 | + return path |
| 145 | + parts = path.split("!") |
| 146 | + if self.named(parts[0]): |
| 147 | + return "<argument %d>" % top |
| 148 | + shown = parts[0] |
| 149 | + for depth, part in enumerate(parts[1:], 1): |
| 150 | + shown += "!" + ("<member at depth %d>" % depth if self.named(part) else part) |
| 151 | + return shown |
| 152 | + |
| 153 | + def hit(self, where: str, what: str) -> None: |
| 154 | + self.findings.append("%s: %s" % (where, what)) |
| 155 | + |
| 156 | + def scan(self, top: int, path: str, data: bytes) -> None: |
| 157 | + self.files += 1 |
| 158 | + shown = self.label(top, path) |
| 159 | + if shown != path: |
| 160 | + self.hit(shown, "a file name holds an internal name") |
| 161 | + text = data.decode("utf-8", errors="replace") |
| 162 | + lines = set() |
| 163 | + if self.names is not None: |
| 164 | + lines.update(offsets(self.names, text)) |
| 165 | + for m in B64.finditer(text): |
| 166 | + token = m.group(0) |
| 167 | + try: |
| 168 | + decoded = base64.b64decode(token + "=" * (-len(token) % 4), validate=True) |
| 169 | + except (binascii.Error, ValueError): |
| 170 | + continue |
| 171 | + if self.named(decoded.decode("utf-8", errors="replace")): |
| 172 | + lines.add(text.count("\n", 0, m.start()) + 1) |
| 173 | + if b"\0" in data and not lines: |
| 174 | + # The strings of a compiled assembly, at either byte alignment. |
| 175 | + for view in (data, data[1:]): |
| 176 | + if self.named(view.decode("utf-16-le", errors="ignore")): |
| 177 | + self.hit(shown, FOUND) |
| 178 | + break |
| 179 | + for line in sorted(lines): |
| 180 | + self.hit("%s:%d" % (shown, line), FOUND) |
| 181 | + for kind, pattern in PLAIN: |
| 182 | + for m in pattern.finditer(text): |
| 183 | + self.hit("%s:%d" % (shown, text.count("\n", 0, m.start()) + 1), kind) |
| 184 | + if self.unserved_everywhere or DESCRIBES.search(path.split("!")[-1]): |
| 185 | + for m in UNSERVED.finditer(text): |
| 186 | + where = "%s:%d" % (shown, text.count("\n", 0, m.start()) + 1) |
| 187 | + self.hit(where, "a booster production does not serve yet") |
| 188 | + |
| 189 | + def scan_commits(self) -> bool: |
| 190 | + def git(*args: str) -> subprocess.CompletedProcess: |
| 191 | + return subprocess.run(["git", *args], capture_output=True, text=True) |
| 192 | + |
| 193 | + if git("rev-parse", "--is-shallow-repository").stdout.strip() != "false": |
| 194 | + print("::error::--commits needs the full history (actions/checkout with fetch-depth: 0)") |
| 195 | + return False |
| 196 | + since = git("describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD~1") |
| 197 | + span = "%s..HEAD" % since.stdout.strip() if since.returncode == 0 else "HEAD" |
| 198 | + log = git("log", "--format=%H%x00%B%x1e", span) |
| 199 | + if log.returncode != 0: |
| 200 | + print("::error::git log failed for the commits to check") |
| 201 | + return False |
| 202 | + for record in log.stdout.split("\x1e"): |
| 203 | + if "\x00" not in record: |
| 204 | + continue |
| 205 | + sha, message = record.strip("\n").split("\x00", 1) |
| 206 | + self.commits += 1 |
| 207 | + if self.named(message): |
| 208 | + self.hit("commit %s" % sha[:12], FOUND) |
| 209 | + return True |
| 210 | + |
| 211 | + |
| 212 | +def tracked() -> List[str]: |
| 213 | + out = subprocess.run(["git", "ls-files", "-z"], capture_output=True, check=True).stdout |
| 214 | + return [p for p in out.decode("utf-8", errors="surrogateescape").split("\0") if p] |
94 | 215 |
|
95 | 216 |
|
96 | 217 | def main(argv: List[str]) -> int: |
97 | | - if not argv: |
98 | | - print("usage: check_names.py <file or directory>...", file=sys.stderr) |
| 218 | + flags = {"--tracked", "--commits", "--unserved-everywhere", "--names-optional"} |
| 219 | + given = [a for a in argv if a in flags] |
| 220 | + args = [a for a in argv if a not in flags] |
| 221 | + if any(a.startswith("--") for a in args) or not (args or given): |
| 222 | + print( |
| 223 | + "usage: check_names.py [--tracked] [--commits] [--unserved-everywhere] [--names-optional] [PATH...]", |
| 224 | + file=sys.stderr, |
| 225 | + ) |
99 | 226 | return 2 |
100 | | - paths: List[Path] = [] |
101 | | - for arg in argv: |
102 | | - p = Path(arg) |
103 | | - if not p.exists(): |
104 | | - print("::error::%s does not exist" % arg) |
| 227 | + |
| 228 | + names = compile_names(os.environ.get(ENV, "")) |
| 229 | + if names is None and withheld(): |
| 230 | + print("::notice::%s is withheld from this pull request run: internal names not checked" % ENV) |
| 231 | + elif names is None and "--names-optional" in given: |
| 232 | + print("::notice::%s is not set here: internal names not checked (the release check needs it)" % ENV) |
| 233 | + elif names is None: |
| 234 | + print("::error::%s is empty or not set: the internal-name check cannot run" % ENV) |
| 235 | + return 1 |
| 236 | + |
| 237 | + check = Check(names, "--unserved-everywhere" in given) |
| 238 | + paths: List[str] = tracked() if "--tracked" in given else [] |
| 239 | + for top, arg in enumerate(args, 1): |
| 240 | + if not os.path.exists(arg): |
| 241 | + print("::error::%s does not exist" % check.label(top, arg)) |
105 | 242 | return 1 |
106 | | - paths.extend(sorted(q for q in p.rglob("*") if q.is_file()) if p.is_dir() else [p]) |
107 | | - errors: List[str] = [] |
108 | | - scanned = 0 |
109 | | - for p in paths: |
110 | | - for name, data in members(str(p), p.read_bytes()): |
111 | | - scanned += 1 |
112 | | - errors.extend(scan(name, data)) |
113 | | - for e in errors: |
114 | | - print("::error::%s" % e) |
115 | | - print("%d files scanned, %d findings" % (scanned, len(errors))) |
116 | | - return 1 if errors else 0 |
| 243 | + if os.path.isdir(arg): |
| 244 | + for root, dirs, files in os.walk(arg): |
| 245 | + dirs.sort() |
| 246 | + paths.extend(os.path.join(root, f) for f in sorted(files)) |
| 247 | + else: |
| 248 | + paths.append(arg) |
| 249 | + if (args or "--tracked" in given) and not paths: |
| 250 | + print("::error::no file to check") |
| 251 | + return 1 |
| 252 | + for top, path in enumerate(paths, 1): |
| 253 | + if os.path.isfile(path): |
| 254 | + with open(path, "rb") as f: |
| 255 | + for name, data in members(path, f.read()): |
| 256 | + check.scan(top, name, data) |
| 257 | + if "--commits" in given and names is not None and not check.scan_commits(): |
| 258 | + return 1 |
| 259 | + |
| 260 | + for finding in check.findings: |
| 261 | + print("::error::%s" % finding) |
| 262 | + print("%d files and %d commit messages checked, %d findings" % (check.files, check.commits, len(check.findings))) |
| 263 | + return 1 if check.findings else 0 |
117 | 264 |
|
118 | 265 |
|
119 | 266 | if __name__ == "__main__": |
|
0 commit comments