phi-gate 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phi_gate/__init__.py +3 -0
- phi_gate/__main__.py +5 -0
- phi_gate/audit.py +330 -0
- phi_gate/cli.py +410 -0
- phi_gate/config.py +600 -0
- phi_gate/detectors.py +1086 -0
- phi_gate/globs.py +227 -0
- phi_gate/hooks.py +644 -0
- phi_gate/policy.py +97 -0
- phi_gate/py.typed +0 -0
- phi_gate/redact.py +236 -0
- phi_gate/scanner.py +776 -0
- phi_gate/shell.py +1582 -0
- phi_gate-0.2.0.dist-info/METADATA +344 -0
- phi_gate-0.2.0.dist-info/RECORD +19 -0
- phi_gate-0.2.0.dist-info/WHEEL +5 -0
- phi_gate-0.2.0.dist-info/entry_points.txt +2 -0
- phi_gate-0.2.0.dist-info/licenses/LICENSE +21 -0
- phi_gate-0.2.0.dist-info/top_level.txt +1 -0
phi_gate/globs.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Which files a Grep's `glob` lets it search.
|
|
2
|
+
|
|
3
|
+
The Grep pre-check scans those files before the search runs. The glob only
|
|
4
|
+
narrows that set, so where its meaning is uncertain the set is made larger,
|
|
5
|
+
never smaller: a file left out here is a file searched unchecked.
|
|
6
|
+
|
|
7
|
+
Read as ripgrep reads a glob:
|
|
8
|
+
|
|
9
|
+
* commas between globs (`*.py,*.csv`), but not inside braces;
|
|
10
|
+
* brace alternatives, nested too (`*.{csv,txt}`, `*.{json,{csv,tsv}}`);
|
|
11
|
+
* `*` and `?` within a path segment, `[...]` classes, and `**` across segments.
|
|
12
|
+
|
|
13
|
+
Matched generously: each alternative is tried against every run of
|
|
14
|
+
consecutive segments of a file's path (`visits.csv`, `nov/visits.csv`,
|
|
15
|
+
`2026/nov`, ...), so a glob relative to the search root or to the working
|
|
16
|
+
directory, or one naming a directory, selects the file either way. Matching
|
|
17
|
+
ignores case and Unicode normalisation (macOS stores names decomposed), and
|
|
18
|
+
its cost is bounded by the glob's and the path's length: no pattern can make
|
|
19
|
+
it backtrack.
|
|
20
|
+
|
|
21
|
+
A glob that cannot be followed (a negation `!`, a backslash escape,
|
|
22
|
+
unbalanced or empty braces, more than `MAX_ALTERNATIVES` alternatives, an
|
|
23
|
+
overlong pattern) gives `None`: every file is searched, so every file is
|
|
24
|
+
scanned.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import fnmatch
|
|
30
|
+
import re
|
|
31
|
+
import unicodedata
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
MAX_ALTERNATIVES = 256
|
|
35
|
+
MAX_PATTERN = 4096
|
|
36
|
+
MAX_DOUBLE_STARS = 8
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Unsupported(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _split_top(text: str) -> list[str]:
|
|
44
|
+
"""Split on commas outside braces."""
|
|
45
|
+
parts, depth, start = [], 0, 0
|
|
46
|
+
for i, ch in enumerate(text):
|
|
47
|
+
if ch == "{":
|
|
48
|
+
depth += 1
|
|
49
|
+
elif ch == "}":
|
|
50
|
+
depth -= 1
|
|
51
|
+
if depth < 0:
|
|
52
|
+
raise _Unsupported("unbalanced braces")
|
|
53
|
+
elif ch == "," and depth == 0:
|
|
54
|
+
parts.append(text[start:i])
|
|
55
|
+
start = i + 1
|
|
56
|
+
if depth != 0:
|
|
57
|
+
raise _Unsupported("unbalanced braces")
|
|
58
|
+
parts.append(text[start:])
|
|
59
|
+
return parts
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _expand(glob: str) -> list[str]:
|
|
63
|
+
"""Every alternative a brace glob stands for."""
|
|
64
|
+
open_at = glob.find("{")
|
|
65
|
+
if open_at < 0:
|
|
66
|
+
if "}" in glob:
|
|
67
|
+
raise _Unsupported("unbalanced braces")
|
|
68
|
+
return [glob]
|
|
69
|
+
depth = 0
|
|
70
|
+
close_at = -1
|
|
71
|
+
for i in range(open_at, len(glob)):
|
|
72
|
+
if glob[i] == "{":
|
|
73
|
+
depth += 1
|
|
74
|
+
elif glob[i] == "}":
|
|
75
|
+
depth -= 1
|
|
76
|
+
if depth == 0:
|
|
77
|
+
close_at = i
|
|
78
|
+
break
|
|
79
|
+
if close_at < 0:
|
|
80
|
+
raise _Unsupported("unbalanced braces")
|
|
81
|
+
head, body, tail = glob[:open_at], glob[open_at + 1:close_at], glob[close_at + 1:]
|
|
82
|
+
if not body:
|
|
83
|
+
raise _Unsupported("empty braces")
|
|
84
|
+
out: list[str] = []
|
|
85
|
+
for choice in _split_top(body):
|
|
86
|
+
for rest in _expand(head + choice + tail):
|
|
87
|
+
out.append(rest)
|
|
88
|
+
if len(out) > MAX_ALTERNATIVES:
|
|
89
|
+
raise _Unsupported("too many alternatives")
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normal(text: str) -> str:
|
|
94
|
+
return unicodedata.normalize("NFC", text)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _alternatives(pattern: str) -> list[str]:
|
|
98
|
+
if len(pattern) > MAX_PATTERN or "\\" in pattern:
|
|
99
|
+
raise _Unsupported("escape or overlong pattern")
|
|
100
|
+
globs = [g.strip() for g in _split_top(pattern)]
|
|
101
|
+
if any(g.startswith("!") for g in globs):
|
|
102
|
+
raise _Unsupported("negation")
|
|
103
|
+
alternatives = []
|
|
104
|
+
for glob in globs:
|
|
105
|
+
for alt in _expand(glob):
|
|
106
|
+
alt = alt.strip().removeprefix("./").strip("/")
|
|
107
|
+
if alt:
|
|
108
|
+
alternatives.append(alt)
|
|
109
|
+
if not alternatives or len(alternatives) > MAX_ALTERNATIVES:
|
|
110
|
+
raise _Unsupported("no usable alternatives")
|
|
111
|
+
if any(alt.count("**") > MAX_DOUBLE_STARS for alt in alternatives):
|
|
112
|
+
raise _Unsupported("too many **")
|
|
113
|
+
if not all(_classes_closed(alt) for alt in alternatives):
|
|
114
|
+
raise _Unsupported("unclosed class")
|
|
115
|
+
return alternatives
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _classes_closed(glob: str) -> bool:
|
|
119
|
+
"""Whether every `[` opens a class that closes. A `]` straight after the
|
|
120
|
+
opening (or after its `!` or `^`) is a member, not the end."""
|
|
121
|
+
i = 0
|
|
122
|
+
while i < len(glob):
|
|
123
|
+
if glob[i] == "[":
|
|
124
|
+
j = i + 1
|
|
125
|
+
if glob[j:j + 1] in ("!", "^"):
|
|
126
|
+
j += 1
|
|
127
|
+
if glob[j:j + 1] == "]":
|
|
128
|
+
j += 1
|
|
129
|
+
close = glob.find("]", j)
|
|
130
|
+
if close < 0:
|
|
131
|
+
return False
|
|
132
|
+
i = close + 1
|
|
133
|
+
else:
|
|
134
|
+
i += 1
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
Compiled = list[tuple[str, ...]]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def compile_glob(pattern: str) -> Compiled | None:
|
|
142
|
+
"""Each alternative of a Grep glob as path segments, lower-cased, with
|
|
143
|
+
`**` standing for any number of segments; None when every file must be
|
|
144
|
+
scanned."""
|
|
145
|
+
try:
|
|
146
|
+
alternatives = _alternatives(_normal(pattern))
|
|
147
|
+
except _Unsupported:
|
|
148
|
+
return None
|
|
149
|
+
compiled = []
|
|
150
|
+
for alt in alternatives:
|
|
151
|
+
segments: list[str] = []
|
|
152
|
+
for seg in alt.lower().split("/"):
|
|
153
|
+
if not seg:
|
|
154
|
+
continue
|
|
155
|
+
if seg == "**":
|
|
156
|
+
if segments[-1:] != ["**"]:
|
|
157
|
+
segments.append("**")
|
|
158
|
+
else:
|
|
159
|
+
segments.append(re.sub(r"\*{2,}", "*", seg).replace("[^", "[!"))
|
|
160
|
+
compiled.append(tuple(segments))
|
|
161
|
+
return compiled
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _segment_match(name: str, pattern: str, cache: dict[tuple[str, str], bool]) -> bool:
|
|
165
|
+
"""One path segment against one glob segment. fnmatch translates to a
|
|
166
|
+
regular expression with atomic groups, so no pattern backtracks badly."""
|
|
167
|
+
key = (name, pattern)
|
|
168
|
+
if key not in cache:
|
|
169
|
+
cache[key] = fnmatch.fnmatchcase(name, pattern)
|
|
170
|
+
return cache[key]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _matches(segments: tuple[str, ...], parts: list[str], cache: dict[tuple[str, str], bool]) -> bool:
|
|
174
|
+
"""Whether the glob matches some run of consecutive path segments: a
|
|
175
|
+
dynamic programme over (glob segment, path segment), so its cost is their
|
|
176
|
+
product however many `**` the glob holds."""
|
|
177
|
+
full = ("**", *segments, "**")
|
|
178
|
+
n, m = len(full), len(parts)
|
|
179
|
+
# reach[j]: the glob's first i segments can match parts[:j].
|
|
180
|
+
reach = [True] + [False] * m
|
|
181
|
+
for i in range(n):
|
|
182
|
+
seg = full[i]
|
|
183
|
+
nxt = [False] * (m + 1)
|
|
184
|
+
if seg == "**":
|
|
185
|
+
seen = False
|
|
186
|
+
for j in range(m + 1):
|
|
187
|
+
seen = seen or reach[j]
|
|
188
|
+
nxt[j] = seen
|
|
189
|
+
else:
|
|
190
|
+
for j in range(m):
|
|
191
|
+
if reach[j] and _segment_match(parts[j], seg, cache):
|
|
192
|
+
nxt[j + 1] = True
|
|
193
|
+
reach = nxt
|
|
194
|
+
return reach[m]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def named_segments(pattern: str | None) -> set[str]:
|
|
198
|
+
"""Path segments a glob names literally, such as `build` in
|
|
199
|
+
`build/**/*.csv`. A directory skipped by default is still walked when a
|
|
200
|
+
glob names it."""
|
|
201
|
+
if not pattern:
|
|
202
|
+
return set()
|
|
203
|
+
try:
|
|
204
|
+
alternatives = _alternatives(_normal(pattern))
|
|
205
|
+
except _Unsupported:
|
|
206
|
+
return set()
|
|
207
|
+
return {seg for alt in alternatives for seg in alt.split("/") if seg and not set(seg) & set("*?[]")}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def select(files: list[Path], pattern: str | None) -> list[Path]:
|
|
211
|
+
"""The files a Grep with `pattern` would search, or more. Every file when
|
|
212
|
+
there is no glob, or one this cannot follow."""
|
|
213
|
+
if not pattern:
|
|
214
|
+
return files
|
|
215
|
+
compiled = compile_glob(pattern)
|
|
216
|
+
if compiled is None:
|
|
217
|
+
return files
|
|
218
|
+
cache: dict[tuple[str, str], bool] = {}
|
|
219
|
+
chosen = []
|
|
220
|
+
for f in files:
|
|
221
|
+
parts = [_normal(p).lower() for p in f.parts if p not in ("/", "")]
|
|
222
|
+
if any(_matches(segments, parts, cache) for segments in compiled):
|
|
223
|
+
chosen.append(f)
|
|
224
|
+
return chosen
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = ["compile_glob", "named_segments", "select", "MAX_ALTERNATIVES"]
|