aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/scripts/denylist_check.py
blob: 08cf985a6fb0a8796f80eac084d1fb49b54c1931 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
#!/usr/bin/env python3
"""Denylist guard for a public repo: refuse private words in commits (git hooks in .githooks/).

Usage: denylist_check.py staged | msg FILE | tree [REF]
  staged    added lines + paths of the index (pre-commit hook)
  msg FILE  commit message, '#' lines skipped (commit-msg hook)
  tree      every tracked file of REF (default HEAD): manual audit

Lists (merged): publish-denylist.local in the worktree top and in the main tree (git-ignored),
plus every `git config denylist.file <path>` (e.g. a global list). Lines: `word` = case-insensitive
substring; `!token` = exemption (a hit inside an occurrence of token does not count); `#` comments.
Output names the place and `word #<line>` only, never the word. Exit 0 clean, 1 hits.
No list at all → warning, exit 0. Bypass once: `git commit --no-verify`.
Enable in a clone: `git config core.hooksPath .githooks`.
"""
import re
import subprocess
import sys
from pathlib import Path

NAME = "publish-denylist.local"


def git(*args):
    r = subprocess.run(["git", "-c", "core.quotePath=false", *args], capture_output=True)
    if r.returncode:
        raise SystemExit(f"denylist-check: git {args[0]} failed: {r.stderr.decode(errors='replace').strip()}")
    return r.stdout.decode("utf-8", errors="replace")


def parse(text):
    """([(lineno, word lowercased)], [exempt token lowercased])."""
    words, exempt = [], []
    for n, line in enumerate(text.splitlines(), 1):
        w = line.strip()
        if not w or w.startswith("#"):
            continue
        if w.startswith("!"):
            if w[1:]:
                exempt.append(w[1:].lower())
        else:
            words.append((n, w.lower()))
    return words, exempt


def _exempt_spans(low, exempt):
    spans = []
    for t in exempt:
        spans += [(m.start(), m.end()) for m in re.finditer(re.escape(t), low)]
    return spans


def hits(text, words, exempt):
    """Line numbers (in the list) of words found in text outside exempted tokens."""
    low = text.lower()
    spans = None
    out = []
    for n, w in words:
        for m in re.finditer(re.escape(w), low):
            if spans is None:
                spans = _exempt_spans(low, exempt)
            if not any(a <= m.start() and m.end() <= b for a, b in spans):
                out.append(n)
                break
    return out


def load_lists():
    """[(label, words, exempt)] of every existing list; label '' for the repo list."""
    top = Path(git("rev-parse", "--show-toplevel").strip())
    common = Path(git("rev-parse", "--path-format=absolute", "--git-common-dir").strip())
    cands = [("", top / NAME), ("", common.parent / NAME)]
    r = subprocess.run(["git", "config", "--get-all", "denylist.file"], capture_output=True, text=True)
    cands += [(Path(p).name + " ", Path(p).expanduser()) for p in r.stdout.splitlines() if p.strip()]
    seen, lists = set(), []
    for label, p in cands:
        if not p.is_file() or p.resolve() in seen:
            continue
        seen.add(p.resolve())
        lists.append((label, *parse(p.read_text(errors="replace"))))
    return lists


def scan(items, lists):
    """items = [(place, text)] → ['place: <label>word #n']; words in place (a path) masked as ***."""
    allw = sorted({w for _, words, _ in lists for _, w in words}, key=len, reverse=True)
    mask = re.compile("|".join(map(re.escape, allw)), re.I) if allw else None
    out = []
    for place, text in items:
        for label, words, exempt in lists:
            out += [f"{mask.sub('***', place)}: {label}word #{n}" for n in hits(text, words, exempt)]
    return out


def staged_items():
    items, path, line = [], None, 0
    for name in git("diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR").split("\0"):
        if name:
            items.append((f"path {name}", name))
    for raw in git("diff", "--cached", "-U0", "--no-color", "--no-ext-diff", "--diff-filter=ACMR").splitlines():
        if raw.startswith("+++ "):
            path = raw[6:] if raw.startswith("+++ b/") else raw[4:].strip('"')
        elif raw.startswith("@@"):
            m = re.match(r"@@ -\S+ \+(\d+)", raw)
            line = int(m.group(1)) if m else 0
        elif raw.startswith("+") and path:
            items.append((f"{path}:{line}", raw[1:]))
            line += 1
    return items


def msg_items(file):
    text = Path(file).read_text(errors="replace")
    items = []
    for n, l in enumerate(text.splitlines(), 1):
        if l.startswith("# ------------------------ >8"):
            break
        if not l.startswith("#"):
            items.append((f"commit message:{n}", l))
    return items


def tree_items(ref):
    items = []
    for name in git("ls-tree", "-r", "-z", "--name-only", ref).split("\0"):
        if not name:
            continue
        items.append((f"path {name}", name))
        data = subprocess.run(["git", "cat-file", "blob", f"{ref}:{name}"], capture_output=True).stdout
        if b"\0" in data[:8000]:
            continue
        for n, l in enumerate(data.decode("utf-8", errors="replace").splitlines(), 1):
            items.append((f"{name}:{n}", l))
    return items


def main(argv):
    if not argv or argv[0] not in ("staged", "msg", "tree") or (argv[0] == "msg" and len(argv) != 2):
        print(__doc__.split("\n\n")[1], file=sys.stderr)
        return 2
    lists = load_lists()
    if not lists:
        print(f"denylist-check: warning: no {NAME} (nor denylist.file): nothing checked", file=sys.stderr)
        return 0
    mode = argv[0]
    items = staged_items() if mode == "staged" else msg_items(argv[1]) if mode == "msg" \
        else tree_items(argv[1] if len(argv) > 1 else "HEAD")
    found = scan(items, lists)
    if found:
        print("denylist-check: private word found (fix, or `git commit --no-verify` if a false hit):",
              file=sys.stderr)
        print("\n".join(found), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))