attackmap-analyzer-go 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.
- attackmap_analyzer_go/__init__.py +3 -0
- attackmap_analyzer_go/analyzer.py +727 -0
- attackmap_analyzer_go/route_auth.py +911 -0
- attackmap_analyzer_go-0.2.0.dist-info/METADATA +103 -0
- attackmap_analyzer_go-0.2.0.dist-info/RECORD +9 -0
- attackmap_analyzer_go-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_go-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_go-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_go-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
"""Route-level auth resolution for the AttackMap#256 contract.
|
|
2
|
+
|
|
3
|
+
Each emitted ``Route`` carries ``auth`` (``required`` / ``anonymous`` /
|
|
4
|
+
``unknown``), the ``guards`` that apply and the ``guard_evidence`` source text
|
|
5
|
+
that established the state. Core trusts a declared state over its own
|
|
6
|
+
resolution (which can't read Go), so this only declares what the source
|
|
7
|
+
verifiably says.
|
|
8
|
+
|
|
9
|
+
Routers are tracked by variable within a file:
|
|
10
|
+
|
|
11
|
+
- roots: ``r := chi.NewRouter()``, ``gin.Default()``, ``echo.New()``,
|
|
12
|
+
``fiber.New()``; any other receiver is bound to its enclosing top-level
|
|
13
|
+
function (a router passed in as a parameter);
|
|
14
|
+
- derived routers: ``g := r.Group("/api", mw...)`` (gin / echo / fiber),
|
|
15
|
+
``g := r.With(mw...)`` (chi), gorilla ``g := r.PathPrefix("/api").Subrouter()``,
|
|
16
|
+
and closure routers ``r.Group(func(r chi.Router) {...})`` /
|
|
17
|
+
``r.Route("/x", func(r chi.Router) {...})`` (and gitea-style parameterless
|
|
18
|
+
``m.Group("/x", func() {...})``), which inherit the parent's middleware;
|
|
19
|
+
- mounted routers: ``r.Mount("/x", sub)``, ``r.Mount("/x", build())`` and
|
|
20
|
+
``r.Route("/x", register)`` hang a router built in this file (or a
|
|
21
|
+
function's router parameter) under ``r``;
|
|
22
|
+
- each derivation carries its path prefix, so a route's path is joined with
|
|
23
|
+
every literal prefix above it (``Resolution.path``);
|
|
24
|
+
- ``X.Use(mw...)`` applies to routes registered on ``X`` (or routers derived
|
|
25
|
+
from it) after the call, which is how gin, echo groups and fiber behave and
|
|
26
|
+
the only order chi allows (gorilla applies it to every route of ``X``);
|
|
27
|
+
fiber ``X.Use("/prefix", mw)`` only to paths under the prefix;
|
|
28
|
+
- route-local middleware: gin / fiber ``r.POST("/x", mw..., h)``, echo
|
|
29
|
+
``e.POST("/x", h, mw...)``, chi ``r.With(mw...).Post("/x", h)``, and a
|
|
30
|
+
handler wrapped in middleware (``mux.Handle("/x", requireAuth(h))``).
|
|
31
|
+
|
|
32
|
+
A middleware counts as a guard when its name says it authenticates
|
|
33
|
+
(``AuthRequired()``, ``jwtauth.Authenticator``, ``middleware.JWT(...)``,
|
|
34
|
+
``echojwt.WithConfig(...)``, ``jwtware.New(...)``, ``keyauth.New(...)``,
|
|
35
|
+
``BasicAuth``). ``jwtauth.Verifier`` only parses a token, and ``Optional*``
|
|
36
|
+
middleware admits anonymous callers, so neither counts.
|
|
37
|
+
|
|
38
|
+
A guard's ``Skipper`` / ``Next`` / ``Filter`` function that only compares the
|
|
39
|
+
request path with literals (``c.Path() == "/signup"``,
|
|
40
|
+
``strings.HasPrefix(c.Path(), "/public")``) is an explicit opt-out: the
|
|
41
|
+
routes it names are ``anonymous`` when no other guard applies. Any other
|
|
42
|
+
skipper, or a config passed by variable, makes the guard uncertain, and the
|
|
43
|
+
route stays ``unknown``.
|
|
44
|
+
|
|
45
|
+
Linear: brackets are matched in one cached pass per file, argument lists are
|
|
46
|
+
split by jumping over nested brackets, a lookup climbs enclosing blocks (not
|
|
47
|
+
sibling ones), and lookups, chains and derivation depth are bounded.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import bisect
|
|
53
|
+
import functools
|
|
54
|
+
import re
|
|
55
|
+
from dataclasses import dataclass, field
|
|
56
|
+
|
|
57
|
+
REQUIRED = "required"
|
|
58
|
+
ANONYMOUS = "anonymous"
|
|
59
|
+
UNKNOWN = "unknown"
|
|
60
|
+
|
|
61
|
+
_EVIDENCE_MAX = 200
|
|
62
|
+
# Enclosing blocks climbed to find a router variable's binding before giving up
|
|
63
|
+
# (the route stays unknown).
|
|
64
|
+
_LOOKUP_LIMIT = 64
|
|
65
|
+
# Router derivation depth followed before giving up.
|
|
66
|
+
_MAX_DEPTH = 32
|
|
67
|
+
_ARG_SCAN = 4000
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def clip(text: str) -> str:
|
|
71
|
+
text = " ".join(text.split())
|
|
72
|
+
return text if len(text) <= _EVIDENCE_MAX else text[: _EVIDENCE_MAX - 1] + "…"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------- Brackets ----------
|
|
76
|
+
|
|
77
|
+
_SCAN_TOKEN = re.compile(r"[\"'`]|//|/\*|[()\[\]{}]")
|
|
78
|
+
_STRING_BODY = {
|
|
79
|
+
'"': re.compile(r'[^"\\\n]*(?:\\.[^"\\\n]*)*"'),
|
|
80
|
+
"'": re.compile(r"[^'\\\n]*(?:\\.[^'\\\n]*)*'"),
|
|
81
|
+
"`": re.compile(r"[^`]*`"),
|
|
82
|
+
}
|
|
83
|
+
_OPEN = "([{"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@functools.lru_cache(maxsize=4)
|
|
87
|
+
def bracket_pairs(content: str) -> dict[int, int]:
|
|
88
|
+
"""Opening-bracket offset -> closing-bracket offset, in one forward pass
|
|
89
|
+
(Go strings, raw strings, runes and comments skipped)."""
|
|
90
|
+
pairs: dict[int, int] = {}
|
|
91
|
+
stack: list[int] = []
|
|
92
|
+
pos = 0
|
|
93
|
+
n = len(content)
|
|
94
|
+
while pos < n:
|
|
95
|
+
match = _SCAN_TOKEN.search(content, pos)
|
|
96
|
+
if not match:
|
|
97
|
+
break
|
|
98
|
+
token = match.group()
|
|
99
|
+
pos = match.end()
|
|
100
|
+
if token in _STRING_BODY:
|
|
101
|
+
body = _STRING_BODY[token].match(content, pos)
|
|
102
|
+
pos = body.end() if body else pos
|
|
103
|
+
elif token == "//":
|
|
104
|
+
nl = content.find("\n", pos)
|
|
105
|
+
pos = n if nl < 0 else nl
|
|
106
|
+
elif token == "/*":
|
|
107
|
+
end = content.find("*/", pos)
|
|
108
|
+
pos = n if end < 0 else end + 2
|
|
109
|
+
elif token in _OPEN:
|
|
110
|
+
stack.append(match.start())
|
|
111
|
+
elif stack:
|
|
112
|
+
pairs[stack.pop()] = match.start()
|
|
113
|
+
return pairs
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@functools.lru_cache(maxsize=4)
|
|
117
|
+
def literal_spans(content: str) -> tuple[list[int], list[int]]:
|
|
118
|
+
"""Starts and ends of Go strings, raw strings, runes and comments, in one
|
|
119
|
+
forward pass (the same tokens `bracket_pairs` skips)."""
|
|
120
|
+
starts: list[int] = []
|
|
121
|
+
ends: list[int] = []
|
|
122
|
+
pos = 0
|
|
123
|
+
n = len(content)
|
|
124
|
+
while pos < n:
|
|
125
|
+
match = _SCAN_TOKEN.search(content, pos)
|
|
126
|
+
if not match:
|
|
127
|
+
break
|
|
128
|
+
token = match.group()
|
|
129
|
+
pos = match.end()
|
|
130
|
+
if token in _STRING_BODY:
|
|
131
|
+
body = _STRING_BODY[token].match(content, pos)
|
|
132
|
+
pos = body.end() if body else pos
|
|
133
|
+
elif token == "//":
|
|
134
|
+
nl = content.find("\n", pos)
|
|
135
|
+
pos = n if nl < 0 else nl
|
|
136
|
+
elif token == "/*":
|
|
137
|
+
end = content.find("*/", pos)
|
|
138
|
+
pos = n if end < 0 else end + 2
|
|
139
|
+
else:
|
|
140
|
+
continue
|
|
141
|
+
starts.append(match.start())
|
|
142
|
+
ends.append(pos)
|
|
143
|
+
return starts, ends
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def in_literal(content: str, offset: int) -> bool:
|
|
147
|
+
starts, ends = literal_spans(content)
|
|
148
|
+
index = bisect.bisect_right(starts, offset) - 1
|
|
149
|
+
return index >= 0 and offset < ends[index]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def matching_close(content: str, open_idx: int) -> int:
|
|
153
|
+
if open_idx < 0:
|
|
154
|
+
return -1
|
|
155
|
+
return bracket_pairs(content).get(open_idx, -1)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_ARG_TOKEN = re.compile(r"[\"'`,]|//|/\*|[(\[{]")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def split_args(content: str, open_idx: int, close: int) -> list[tuple[int, int]]:
|
|
162
|
+
"""``(start, end)`` offsets of each top-level argument of the call whose
|
|
163
|
+
``(`` is at ``open_idx``.
|
|
164
|
+
|
|
165
|
+
Nested brackets and strings are jumped over, so the cost is the number of
|
|
166
|
+
top-level tokens, not the size of nested closures.
|
|
167
|
+
"""
|
|
168
|
+
pairs = bracket_pairs(content)
|
|
169
|
+
args: list[tuple[int, int]] = []
|
|
170
|
+
start = pos = open_idx + 1
|
|
171
|
+
while pos < close:
|
|
172
|
+
match = _ARG_TOKEN.search(content, pos, close)
|
|
173
|
+
if not match:
|
|
174
|
+
break
|
|
175
|
+
token = match.group()
|
|
176
|
+
pos = match.end()
|
|
177
|
+
if token == ",":
|
|
178
|
+
args.append((start, match.start()))
|
|
179
|
+
start = pos
|
|
180
|
+
elif token in _STRING_BODY:
|
|
181
|
+
body = _STRING_BODY[token].match(content, pos, close)
|
|
182
|
+
pos = body.end() if body else pos
|
|
183
|
+
elif token == "//":
|
|
184
|
+
nl = content.find("\n", pos, close)
|
|
185
|
+
pos = close if nl < 0 else nl
|
|
186
|
+
elif token == "/*":
|
|
187
|
+
end = content.find("*/", pos, close)
|
|
188
|
+
pos = close if end < 0 else end + 2
|
|
189
|
+
else: # an opening bracket: jump to its partner
|
|
190
|
+
pos = max(pos, pairs.get(match.start(), close - 1) + 1)
|
|
191
|
+
if content[start:close].strip():
|
|
192
|
+
args.append((start, close))
|
|
193
|
+
return args
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_STRING_LITERAL = re.compile(r'\s*(?:"([^"\\]*)"|`([^`]*)`)\s*$')
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def string_literal(text: str) -> str | None:
|
|
200
|
+
"""The value of an interpreted (``"..."``, no escapes) or raw (`` `...` ``)
|
|
201
|
+
Go string literal, or None when ``text`` is anything else."""
|
|
202
|
+
match = _STRING_LITERAL.match(text)
|
|
203
|
+
if not match:
|
|
204
|
+
return None
|
|
205
|
+
return match.group(1) if match.group(1) is not None else match.group(2)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def join_paths(prefix: str, path: str) -> str:
|
|
209
|
+
"""Join a group prefix and a route path the way gin / echo / fiber / chi
|
|
210
|
+
do: one ``/`` between them, and the route's trailing slash kept."""
|
|
211
|
+
if not prefix:
|
|
212
|
+
return path
|
|
213
|
+
if not path:
|
|
214
|
+
return prefix
|
|
215
|
+
return prefix.rstrip("/") + "/" + path.lstrip("/")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_CHAIN_SEGMENT = re.compile(r"\s*\.\s*(\w+)\s*\(")
|
|
219
|
+
_MAX_CHAIN = 16
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def walk_chain(content: str, pos: int) -> list[tuple[str, int, int]]:
|
|
223
|
+
"""``(name, open, close)`` of each ``.Name(...)`` call chained from
|
|
224
|
+
``pos`` (gorilla's ``r.Path("/x").Methods("GET").HandlerFunc(h)``),
|
|
225
|
+
at most ``_MAX_CHAIN`` of them; stops at an unbalanced call."""
|
|
226
|
+
segments: list[tuple[str, int, int]] = []
|
|
227
|
+
for _ in range(_MAX_CHAIN):
|
|
228
|
+
match = _CHAIN_SEGMENT.match(content, pos)
|
|
229
|
+
if not match:
|
|
230
|
+
break
|
|
231
|
+
close = matching_close(content, match.end() - 1)
|
|
232
|
+
if close < 0:
|
|
233
|
+
break
|
|
234
|
+
segments.append((match.group(1), match.end() - 1, close))
|
|
235
|
+
pos = close + 1
|
|
236
|
+
return segments
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------- Guards ----------
|
|
240
|
+
|
|
241
|
+
# The middleware expression's name: `AuthRequired`, `jwtauth.Authenticator`,
|
|
242
|
+
# `middleware.JWTWithConfig`, `authMiddleware.MiddlewareFunc`.
|
|
243
|
+
_ARG_NAME = re.compile(r"\s*&?([A-Za-z_][\w.]*)")
|
|
244
|
+
_AUTH_NAME = re.compile(
|
|
245
|
+
r"auth(?!or)|jwt|bearer|api_?key|login_?required|require_?(?:user|login)"
|
|
246
|
+
r"|(?:require|verify|check|validate)_?token",
|
|
247
|
+
re.IGNORECASE,
|
|
248
|
+
)
|
|
249
|
+
# jwtauth.Verifier only parses a token (Authenticator rejects); Optional*
|
|
250
|
+
# middleware admits anonymous callers.
|
|
251
|
+
_NOT_A_GUARD = re.compile(r"optional|\bverifier\b|\.Verifier$|maybe", re.IGNORECASE)
|
|
252
|
+
_CONFIG_CTOR = re.compile(r"(?:WithConfig|New|Config)$")
|
|
253
|
+
_SKIPPER_KEY = re.compile(r"\b(Skipper|Next|Filter)\s*:\s*")
|
|
254
|
+
_FUNC_LIT = re.compile(r"func\s*\(")
|
|
255
|
+
_PATH_EXPR = (
|
|
256
|
+
r"\w+\s*\.\s*(?:Path\s*\(\s*\)|Request\s*\(\s*\)\s*\.\s*URL\s*\.\s*Path"
|
|
257
|
+
r"|OriginalURL\s*\(\s*\)|Route\s*\(\s*\)\s*\.\s*Path)"
|
|
258
|
+
)
|
|
259
|
+
_SKIP_EXACT = re.compile(_PATH_EXPR + r'\s*==\s*"([^"\\]*)"|"([^"\\]*)"\s*==\s*' + _PATH_EXPR)
|
|
260
|
+
_SKIP_PREFIX = re.compile(r'strings\s*\.\s*HasPrefix\s*\(\s*' + _PATH_EXPR + r'\s*,\s*"([^"\\]*)"\s*\)')
|
|
261
|
+
_SKIP_GLUE = re.compile(r"\breturn\b|\|\||[(){}]")
|
|
262
|
+
_SKIPPER_BODY_MAX = 2000
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class Skipper:
|
|
267
|
+
"""A guard's skip function: which paths it lets through unauthenticated."""
|
|
268
|
+
|
|
269
|
+
exact: set[str]
|
|
270
|
+
prefixes: set[str]
|
|
271
|
+
characterized: bool # body is only literal path comparisons
|
|
272
|
+
evidence: str
|
|
273
|
+
|
|
274
|
+
def skips(self, path: str) -> bool:
|
|
275
|
+
return path in self.exact or any(path.startswith(p) for p in self.prefixes)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass
|
|
279
|
+
class Guard:
|
|
280
|
+
name: str
|
|
281
|
+
evidence: str
|
|
282
|
+
skipper: Skipper | None = None
|
|
283
|
+
uncertain: bool = False # e.g. a config passed by variable: may skip anything
|
|
284
|
+
prefix: str | None = None # fiber `Use("/prefix", mw)`: only under this path
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _skipper(content: str, start: int, end: int) -> Skipper | None:
|
|
288
|
+
key = _SKIPPER_KEY.search(content, start, end)
|
|
289
|
+
if not key:
|
|
290
|
+
return None
|
|
291
|
+
evidence = clip(content[key.start() : min(end, key.start() + _EVIDENCE_MAX)])
|
|
292
|
+
func = _FUNC_LIT.match(content, key.end())
|
|
293
|
+
if not func:
|
|
294
|
+
return Skipper(set(), set(), False, evidence) # a named function: body not here
|
|
295
|
+
body_open = content.find("{", matching_close(content, func.end() - 1) + 1, end)
|
|
296
|
+
body_close = matching_close(content, body_open)
|
|
297
|
+
if body_open < 0 or body_close < 0 or body_close - body_open > _SKIPPER_BODY_MAX:
|
|
298
|
+
return Skipper(set(), set(), False, evidence)
|
|
299
|
+
body = content[body_open + 1 : body_close]
|
|
300
|
+
exact = {m.group(1) if m.group(1) is not None else m.group(2) for m in _SKIP_EXACT.finditer(body)}
|
|
301
|
+
prefixes = {m.group(1) for m in _SKIP_PREFIX.finditer(body)}
|
|
302
|
+
residue = _SKIP_GLUE.sub(" ", _SKIP_PREFIX.sub(" ", _SKIP_EXACT.sub(" ", body)))
|
|
303
|
+
evidence = clip(content[key.start() : body_close + 1])
|
|
304
|
+
return Skipper(exact, prefixes, not residue.strip() and bool(exact or prefixes), evidence)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def guard_from_arg(content: str, start: int, end: int, context: str | None = None) -> Guard | None:
|
|
308
|
+
"""A Guard when the middleware argument at ``content[start:end]`` authenticates."""
|
|
309
|
+
end = min(end, start + _ARG_SCAN)
|
|
310
|
+
match = _ARG_NAME.match(content, start, end)
|
|
311
|
+
if not match:
|
|
312
|
+
return None
|
|
313
|
+
name = match.group(1)
|
|
314
|
+
if not _AUTH_NAME.search(name) or _NOT_A_GUARD.search(name):
|
|
315
|
+
return None
|
|
316
|
+
text = content[start:end]
|
|
317
|
+
evidence = clip(context if context is not None else text)
|
|
318
|
+
skipper = _skipper(content, start, end)
|
|
319
|
+
call_open = content.find("(", match.end(), end)
|
|
320
|
+
literal_config = "{" in text
|
|
321
|
+
uncertain = bool(_CONFIG_CTOR.search(name)) and call_open >= 0 and not literal_config and bool(
|
|
322
|
+
content[call_open + 1 : end].strip(" \t\n)")
|
|
323
|
+
)
|
|
324
|
+
return Guard(name, evidence, skipper, uncertain)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# A handler wrapped in middleware: `requireAuth(h)`, `authMW(h)`,
|
|
328
|
+
# `auth.Middleware(h)`, `jwtMiddleware.Handler(h)`. The called name (the last
|
|
329
|
+
# dotted segment) must be middleware-shaped, or be `Handler` / `Wrap` on a
|
|
330
|
+
# middleware-named receiver, so a handler constructor such as
|
|
331
|
+
# `auth.LoginHandler(db)`, `authMW.LoginHandler(db)` or
|
|
332
|
+
# `oauthMiddleware.Callback(cb)` isn't mistaken for a guard.
|
|
333
|
+
_WRAPPER_SEGMENT = re.compile(
|
|
334
|
+
r"(?:\w*(?:middleware|mw|required|guard|protect(?:ed)?|wrap(?:per)?)"
|
|
335
|
+
r"|(?:require|ensure|must|with|verify|check|authenticated?|protect)\w*)$",
|
|
336
|
+
re.IGNORECASE,
|
|
337
|
+
)
|
|
338
|
+
_WRAPPER_METHOD = re.compile(r"(?:Handler|HandlerFunc|Wrap|WrapHandler)$")
|
|
339
|
+
_MIDDLEWARE_RECEIVER = re.compile(r"\w*(?:middleware|mw)$", re.IGNORECASE)
|
|
340
|
+
_CALL_NAME = re.compile(r"\s*([A-Za-z_][\w.]*)\s*\(")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _wrapper_shaped(name: str) -> bool:
|
|
344
|
+
segments = name.split(".")
|
|
345
|
+
if _WRAPPER_SEGMENT.match(segments[-1]):
|
|
346
|
+
return True
|
|
347
|
+
return len(segments) > 1 and bool(_WRAPPER_METHOD.match(segments[-1])) and bool(
|
|
348
|
+
_MIDDLEWARE_RECEIVER.match(segments[-2])
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def wrapper_guard(content: str, start: int, end: int, context: str) -> Guard | None:
|
|
353
|
+
"""A Guard when the handler argument ``content[start:end]`` is an auth
|
|
354
|
+
middleware call wrapping the handler."""
|
|
355
|
+
match = _CALL_NAME.match(content, start, end)
|
|
356
|
+
if not match or match.group(1) == "func":
|
|
357
|
+
return None
|
|
358
|
+
close = matching_close(content, match.end() - 1)
|
|
359
|
+
if close < 0 or close >= end or not content[match.end() : close].strip():
|
|
360
|
+
return None
|
|
361
|
+
if not _wrapper_shaped(match.group(1)):
|
|
362
|
+
return None
|
|
363
|
+
return guard_from_arg(content, start, end, context)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------- Router bindings ----------
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass
|
|
370
|
+
class Binding:
|
|
371
|
+
name: str
|
|
372
|
+
offset: int
|
|
373
|
+
scope: tuple[int, int]
|
|
374
|
+
parent: "Binding | None" = None
|
|
375
|
+
prefix: str | None = "" # path prefix relative to the parent; None: not a literal
|
|
376
|
+
guards: list[Guard] = field(default_factory=list) # added where it was derived
|
|
377
|
+
root: bool = False # a constructor (gin.Default(), chi.NewRouter(), ...)
|
|
378
|
+
uses: list[tuple[int, list[Guard]]] = field(default_factory=list)
|
|
379
|
+
overflow: bool = False
|
|
380
|
+
# Where it hangs off its parent: the parent's `Use` calls before this
|
|
381
|
+
# offset apply. Its own offset, or the `Mount` / `Route` call's.
|
|
382
|
+
link: int = -1
|
|
383
|
+
# The latest earlier binding of the same name in an outer block that
|
|
384
|
+
# contains this one's offset: where a lookup goes once this scope closed.
|
|
385
|
+
enclosing: "Binding | None" = None
|
|
386
|
+
# Mounted under more than one parent: its auth can't be resolved.
|
|
387
|
+
ambiguous: bool = False
|
|
388
|
+
|
|
389
|
+
def __post_init__(self) -> None:
|
|
390
|
+
if self.link < 0:
|
|
391
|
+
self.link = self.offset
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
_MAX_USES = 16
|
|
395
|
+
# A path literal: interpreted (no escapes) or raw, on one line.
|
|
396
|
+
LIT = r"(?:\"([^\"\\\n]{0,512})\"|`([^`\n]{0,512})`)"
|
|
397
|
+
|
|
398
|
+
_ROOT = re.compile(
|
|
399
|
+
r"\b(\w+)\s*:?=\s*(?:gin\s*\.\s*(?:Default|New)|echo\s*\.\s*New|fiber\s*\.\s*New"
|
|
400
|
+
r"|chi\s*\.\s*(?:NewRouter|NewMux)|mux\s*\.\s*NewRouter|http\s*\.\s*NewServeMux)\s*\("
|
|
401
|
+
)
|
|
402
|
+
_DERIVED = re.compile(r"\b(\w+)\s*:?=\s*(\w+)\s*\.\s*(Group|With)\s*\(")
|
|
403
|
+
# gorilla `api := r.PathPrefix("/api").Subrouter()`: the chain is walked to its
|
|
404
|
+
# `.Subrouter()`.
|
|
405
|
+
_SUBROUTER = re.compile(
|
|
406
|
+
r"\b(\w+)\s*:?=\s*(\w+)(?=\s*\.\s*(?:PathPrefix|Host|Methods|Schemes|Headers|Queries"
|
|
407
|
+
r"|NewRoute|MatcherFunc)\s*\()"
|
|
408
|
+
)
|
|
409
|
+
# chi `r.Route("/x", func(r chi.Router) {` / `r.Group(func(r chi.Router) {`,
|
|
410
|
+
# fiber `app.Route("/x", func(api fiber.Router) {`, and the parameterless
|
|
411
|
+
# closure of a router that keeps a group stack (gitea's `m.Group("/x", func() {`),
|
|
412
|
+
# where the closure registers on the receiver itself.
|
|
413
|
+
_CLOSURE = re.compile(
|
|
414
|
+
r"\b(\w+)\s*\.\s*(Group|Route)\s*\(\s*(?:" + LIT + r"\s*,\s*)?"
|
|
415
|
+
r"func\s*\(\s*(?:(\w+)\s+[\w.*]+\s*)?\)\s*\{"
|
|
416
|
+
)
|
|
417
|
+
# chi / fiber `r.Mount("/admin", sub)` / `r.Mount("/admin", adminRouter())` /
|
|
418
|
+
# `r.Mount("/admin", h.Routes())`, and chi `r.Route("/admin", adminRoutes)`.
|
|
419
|
+
_MOUNT = re.compile(
|
|
420
|
+
r"\b(\w+)\s*\.\s*(Mount|Route)\s*\(\s*" + LIT + r"\s*,\s*(\w+)(?:\s*\.\s*(\w+))?\s*(?:(\()|(?=[,)]))"
|
|
421
|
+
)
|
|
422
|
+
# Function and method declarations, with the first parameter's name.
|
|
423
|
+
_FUNC_DEF = re.compile(
|
|
424
|
+
r"^func[ \t]+(\([^()\n]{0,200}\)[ \t]*)?(\w+)\s*(?:\[[^\]\n]{0,200}\]\s*)?\(\s*(?:(\w+)\s+[\w.*\[\]]+\s*[,)])?",
|
|
425
|
+
re.MULTILINE,
|
|
426
|
+
)
|
|
427
|
+
_USE = re.compile(r"\b(\w+)\s*\.\s*Use\s*\(")
|
|
428
|
+
# Identifiers, for checking how a mounted router (or the function that builds
|
|
429
|
+
# it) is used elsewhere in the file; a method call on it (`sub.Get(`) is fine.
|
|
430
|
+
_IDENT = re.compile(r"[A-Za-z_]\w*")
|
|
431
|
+
_METHOD_CALL = re.compile(r"\s*\.\s*(\w+)\s*\(")
|
|
432
|
+
_RETURN_BEFORE = re.compile(r"\breturn\s+$")
|
|
433
|
+
_SELECTOR_BEFORE = re.compile(r"\.\s*$")
|
|
434
|
+
# Identifier occurrences checked per file before giving up; mounted routers
|
|
435
|
+
# not yet cleared are then treated as ambiguous.
|
|
436
|
+
_MAX_MOUNT_USES = 20000
|
|
437
|
+
# Mounted-router scopes checked around one occurrence (sibling scopes before
|
|
438
|
+
# it have closed; this bounds nested ones).
|
|
439
|
+
_MAX_NESTED_SCOPES = 8
|
|
440
|
+
|
|
441
|
+
# chi `r.With(mw...).Route("/x", func(r chi.Router) {...})` / `.Group(func(r chi.Router) {...})`:
|
|
442
|
+
# a closure router carrying the inline middleware. The chain is walked from `.With(`.
|
|
443
|
+
_WITH = re.compile(r"\b(\w+)\s*\.\s*With\s*\(")
|
|
444
|
+
_FUNC_PARAM = re.compile(r"\s*func\s*\(\s*(\w+)\s+[\w.*]+\s*\)\s*\{")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@dataclass
|
|
448
|
+
class Resolution:
|
|
449
|
+
auth: str = UNKNOWN
|
|
450
|
+
guards: list[str] = field(default_factory=list)
|
|
451
|
+
evidence: str | None = None
|
|
452
|
+
# The route's path joined with every literal prefix above it, up to the
|
|
453
|
+
# first one that isn't a literal (or the router's root).
|
|
454
|
+
path: str | None = None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class FileRouters:
|
|
458
|
+
"""Router variables, their derivations and ``Use`` calls in one file."""
|
|
459
|
+
|
|
460
|
+
def __init__(self, content: str) -> None:
|
|
461
|
+
self.content = content
|
|
462
|
+
pairs = bracket_pairs(content)
|
|
463
|
+
self._blocks = sorted((o, c) for o, c in pairs.items() if content[o] == "{")
|
|
464
|
+
# Top-level blocks (function bodies), for routers passed in as parameters.
|
|
465
|
+
self._top: list[tuple[int, int]] = []
|
|
466
|
+
for block in self._blocks:
|
|
467
|
+
if not self._top or block[0] > self._top[-1][1]:
|
|
468
|
+
self._top.append(block)
|
|
469
|
+
self._top_starts = [start for start, _ in self._top]
|
|
470
|
+
self._by_name: dict[str, list[Binding]] = {}
|
|
471
|
+
self._offsets: dict[str, list[int]] = {}
|
|
472
|
+
self._free: dict[tuple[str, int], Binding] = {}
|
|
473
|
+
# Mounted routers to check for other uses: (target, the mount's
|
|
474
|
+
# argument span, variable name or None, builder function name or None,
|
|
475
|
+
# the function name's offset in its declaration).
|
|
476
|
+
self._mounted: list[tuple[Binding, tuple[int, int], str | None, str | None, int, bool]] = []
|
|
477
|
+
self._roots: list[Binding] = [] # in offset order
|
|
478
|
+
self._root_offsets: list[int] = []
|
|
479
|
+
self._funcs: dict[tuple[str, bool], list[re.Match]] | None = None
|
|
480
|
+
mounts: list[re.Match] = []
|
|
481
|
+
# Sweep state: blocks open at the current event offset.
|
|
482
|
+
self._stack: list[tuple[int, int]] = []
|
|
483
|
+
self._next_block = 0
|
|
484
|
+
events: list[tuple[int, int, re.Match]] = []
|
|
485
|
+
for kind, pattern in enumerate((_ROOT, _DERIVED, _SUBROUTER, _CLOSURE, _USE, _MOUNT, _WITH)):
|
|
486
|
+
events += [(m.start(), kind, m) for m in pattern.finditer(content)]
|
|
487
|
+
events.sort(key=lambda e: (e[0], e[1]))
|
|
488
|
+
for offset, kind, match in events:
|
|
489
|
+
scope = self._advance(offset)
|
|
490
|
+
if kind == 0:
|
|
491
|
+
binding = Binding(match.group(1), offset, scope, root=True)
|
|
492
|
+
self._roots.append(binding)
|
|
493
|
+
self._root_offsets.append(offset)
|
|
494
|
+
self._bind(binding)
|
|
495
|
+
elif kind == 1:
|
|
496
|
+
self._derive(match, scope)
|
|
497
|
+
elif kind == 2:
|
|
498
|
+
self._subrouter(match, scope)
|
|
499
|
+
elif kind == 3:
|
|
500
|
+
self._closure(match)
|
|
501
|
+
elif kind == 4:
|
|
502
|
+
self._use(match)
|
|
503
|
+
elif kind == 5:
|
|
504
|
+
mounts.append(match)
|
|
505
|
+
else:
|
|
506
|
+
self._with_closure(match)
|
|
507
|
+
# Mounted routers are often built after the call, in a later function.
|
|
508
|
+
for match in mounts:
|
|
509
|
+
self._mount(match)
|
|
510
|
+
self._check_mounted_uses()
|
|
511
|
+
|
|
512
|
+
# -- scopes --
|
|
513
|
+
|
|
514
|
+
def _advance(self, offset: int) -> tuple[int, int]:
|
|
515
|
+
"""Innermost block containing ``offset``; offsets arrive ascending, so
|
|
516
|
+
each block is pushed and popped once."""
|
|
517
|
+
while self._next_block < len(self._blocks) and self._blocks[self._next_block][0] < offset:
|
|
518
|
+
block = self._blocks[self._next_block]
|
|
519
|
+
self._next_block += 1
|
|
520
|
+
while self._stack and self._stack[-1][1] < block[0]:
|
|
521
|
+
self._stack.pop()
|
|
522
|
+
self._stack.append(block)
|
|
523
|
+
while self._stack and self._stack[-1][1] < offset:
|
|
524
|
+
self._stack.pop()
|
|
525
|
+
return self._stack[-1] if self._stack else (0, len(self.content))
|
|
526
|
+
|
|
527
|
+
def _top_level(self, offset: int) -> tuple[int, int]:
|
|
528
|
+
index = bisect.bisect_right(self._top_starts, offset) - 1
|
|
529
|
+
if index >= 0 and self._top[index][1] >= offset:
|
|
530
|
+
return self._top[index]
|
|
531
|
+
return (0, len(self.content))
|
|
532
|
+
|
|
533
|
+
# -- bindings --
|
|
534
|
+
|
|
535
|
+
def _bind(self, binding: Binding) -> None:
|
|
536
|
+
offsets = self._offsets.setdefault(binding.name, [])
|
|
537
|
+
bindings = self._by_name.setdefault(binding.name, [])
|
|
538
|
+
index = bisect.bisect_right(offsets, binding.offset)
|
|
539
|
+
candidate = bindings[index - 1] if index else None
|
|
540
|
+
for _ in range(_LOOKUP_LIMIT):
|
|
541
|
+
if candidate is None or (
|
|
542
|
+
candidate.scope != binding.scope and candidate.scope[0] <= binding.offset <= candidate.scope[1]
|
|
543
|
+
):
|
|
544
|
+
break
|
|
545
|
+
candidate = candidate.enclosing
|
|
546
|
+
else:
|
|
547
|
+
candidate = None
|
|
548
|
+
binding.enclosing = candidate
|
|
549
|
+
offsets.insert(index, binding.offset)
|
|
550
|
+
bindings.insert(index, binding)
|
|
551
|
+
|
|
552
|
+
def _free_binding(self, name: str, scope: tuple[int, int]) -> Binding:
|
|
553
|
+
free = self._free.get((name, scope[0]))
|
|
554
|
+
if free is None:
|
|
555
|
+
free = self._free[(name, scope[0])] = Binding(name, scope[0], scope)
|
|
556
|
+
return free
|
|
557
|
+
|
|
558
|
+
def resolve(self, name: str, offset: int) -> Binding | None:
|
|
559
|
+
"""The binding of ``name`` visible at ``offset``: the latest earlier
|
|
560
|
+
assignment whose block contains it, else a free binding for the
|
|
561
|
+
enclosing top-level function (a router passed in as a parameter).
|
|
562
|
+
None when the bounded search gives up."""
|
|
563
|
+
bindings = self._by_name.get(name, [])
|
|
564
|
+
index = bisect.bisect_left(self._offsets.get(name, []), offset) - 1
|
|
565
|
+
binding = bindings[index] if index >= 0 else None
|
|
566
|
+
for _ in range(_LOOKUP_LIMIT):
|
|
567
|
+
if binding is None:
|
|
568
|
+
break
|
|
569
|
+
if binding.scope[0] <= offset <= binding.scope[1]:
|
|
570
|
+
return binding
|
|
571
|
+
# Its block closed before `offset`; a binding that is visible here
|
|
572
|
+
# is in an outer block, so climb those rather than every sibling.
|
|
573
|
+
binding = binding.enclosing
|
|
574
|
+
else:
|
|
575
|
+
return None
|
|
576
|
+
return self._free_binding(name, self._top_level(offset))
|
|
577
|
+
|
|
578
|
+
def _call(self, paren: int) -> tuple[list[tuple[int, int]], int] | None:
|
|
579
|
+
"""Top-level argument spans and the closing offset for the ``(`` at ``paren``."""
|
|
580
|
+
close = matching_close(self.content, paren)
|
|
581
|
+
if close < 0:
|
|
582
|
+
return None
|
|
583
|
+
return split_args(self.content, paren, close), close
|
|
584
|
+
|
|
585
|
+
def _context(self, start: int, close: int) -> str:
|
|
586
|
+
return self.content[start : min(close + 1, start + 2 * _EVIDENCE_MAX)]
|
|
587
|
+
|
|
588
|
+
def _derive(self, match: re.Match, scope: tuple[int, int]) -> None:
|
|
589
|
+
parent = self.resolve(match.group(2), match.start())
|
|
590
|
+
call = self._call(match.end() - 1)
|
|
591
|
+
if parent is None or call is None:
|
|
592
|
+
return
|
|
593
|
+
args, close = call
|
|
594
|
+
prefix: str | None = ""
|
|
595
|
+
if match.group(3) == "Group":
|
|
596
|
+
first = string_literal(self.content[args[0][0] : args[0][1]]) if args else None
|
|
597
|
+
prefix, args = first, args[1:]
|
|
598
|
+
context = self._context(match.start(), close)
|
|
599
|
+
guards = [g for s, e in args if (g := guard_from_arg(self.content, s, e, context))]
|
|
600
|
+
self._bind(Binding(match.group(1), match.start(), scope, parent, prefix, guards))
|
|
601
|
+
|
|
602
|
+
def _subrouter(self, match: re.Match, scope: tuple[int, int]) -> None:
|
|
603
|
+
segments = walk_chain(self.content, match.end())
|
|
604
|
+
names = [name for name, _, _ in segments]
|
|
605
|
+
if "Subrouter" not in names:
|
|
606
|
+
return
|
|
607
|
+
prefix: str | None = ""
|
|
608
|
+
for name, open_idx, close in segments[: names.index("Subrouter")]:
|
|
609
|
+
if name != "PathPrefix":
|
|
610
|
+
continue
|
|
611
|
+
args = split_args(self.content, open_idx, close)
|
|
612
|
+
literal = string_literal(self.content[args[0][0] : args[0][1]]) if len(args) == 1 else None
|
|
613
|
+
prefix = None if prefix is None or literal is None else join_paths(prefix, literal)
|
|
614
|
+
parent = self.resolve(match.group(2), match.start())
|
|
615
|
+
if parent is not None:
|
|
616
|
+
self._bind(Binding(match.group(1), match.start(), scope, parent, prefix))
|
|
617
|
+
|
|
618
|
+
def _closure(self, match: re.Match) -> None:
|
|
619
|
+
parent = self.resolve(match.group(1), match.start())
|
|
620
|
+
body_open = match.end() - 1
|
|
621
|
+
body_close = matching_close(self.content, body_open)
|
|
622
|
+
if parent is None or body_close < 0:
|
|
623
|
+
return
|
|
624
|
+
literal = match.group(3) if match.group(3) is not None else match.group(4)
|
|
625
|
+
# A parameterless closure (gitea's `m.Group("/x", func() {...}, mw...)`)
|
|
626
|
+
# registers on the receiver itself. Its trailing middleware belongs to
|
|
627
|
+
# a project's own wrapper whose semantics we can't read (gitea's
|
|
628
|
+
# `checkTokenPublicOnly()` restricts tokens, `webAuth.AllowBasic` only
|
|
629
|
+
# permits a scheme), so the group contributes its prefix, not guards.
|
|
630
|
+
name = match.group(5) or match.group(1)
|
|
631
|
+
self._bind(Binding(name, body_open, (body_open, body_close), parent, literal or ""))
|
|
632
|
+
|
|
633
|
+
def _with_closure(self, match: re.Match) -> None:
|
|
634
|
+
segments = walk_chain(self.content, match.start(1) + len(match.group(1)))
|
|
635
|
+
if len(segments) < 2 or segments[-1][0] not in ("Route", "Group"):
|
|
636
|
+
return
|
|
637
|
+
if any(name != "With" for name, _, _ in segments[:-1]):
|
|
638
|
+
return
|
|
639
|
+
parent = self.resolve(match.group(1), match.start())
|
|
640
|
+
if parent is None:
|
|
641
|
+
return
|
|
642
|
+
guards: list[Guard] = []
|
|
643
|
+
for _, open_idx, close in segments[:-1]:
|
|
644
|
+
context = self._context(match.start(), close)
|
|
645
|
+
args = split_args(self.content, open_idx, close)
|
|
646
|
+
guards += [g for s, e in args if (g := guard_from_arg(self.content, s, e, context))]
|
|
647
|
+
name, open_idx, close = segments[-1]
|
|
648
|
+
args = split_args(self.content, open_idx, close)
|
|
649
|
+
prefix: str | None = ""
|
|
650
|
+
if name == "Route":
|
|
651
|
+
if len(args) != 2:
|
|
652
|
+
return
|
|
653
|
+
prefix = string_literal(self.content[args[0][0] : args[0][1]])
|
|
654
|
+
elif len(args) != 1:
|
|
655
|
+
return
|
|
656
|
+
func = _FUNC_PARAM.match(self.content, args[-1][0], args[-1][1])
|
|
657
|
+
if not func:
|
|
658
|
+
return
|
|
659
|
+
body_open = func.end() - 1
|
|
660
|
+
body_close = matching_close(self.content, body_open)
|
|
661
|
+
if body_close >= 0:
|
|
662
|
+
self._bind(Binding(func.group(1), body_open, (body_open, body_close), parent, prefix, guards))
|
|
663
|
+
|
|
664
|
+
def _use(self, match: re.Match) -> None:
|
|
665
|
+
binding = self.resolve(match.group(1), match.start())
|
|
666
|
+
call = self._call(match.end() - 1)
|
|
667
|
+
if binding is None or call is None:
|
|
668
|
+
return
|
|
669
|
+
args, close = call
|
|
670
|
+
prefix = None
|
|
671
|
+
if args and (literal := string_literal(self.content[args[0][0] : args[0][1]])) is not None:
|
|
672
|
+
prefix, args = literal, args[1:]
|
|
673
|
+
context = self._context(match.start(), close)
|
|
674
|
+
guards = [g for s, e in args if (g := guard_from_arg(self.content, s, e, context))]
|
|
675
|
+
if not guards:
|
|
676
|
+
return
|
|
677
|
+
for guard in guards:
|
|
678
|
+
guard.prefix = prefix
|
|
679
|
+
if len(binding.uses) >= _MAX_USES:
|
|
680
|
+
binding.overflow = True
|
|
681
|
+
return
|
|
682
|
+
binding.uses.append((match.start(), guards))
|
|
683
|
+
|
|
684
|
+
# -- mounts --
|
|
685
|
+
|
|
686
|
+
def _function(self, name: str, method: bool) -> tuple[re.Match, tuple[int, int]] | None:
|
|
687
|
+
"""The one declaration of function (or method) ``name`` in this file
|
|
688
|
+
and its body block; None when there are none or several."""
|
|
689
|
+
key = (name, method)
|
|
690
|
+
if self._funcs is None:
|
|
691
|
+
self._funcs = {}
|
|
692
|
+
for match in _FUNC_DEF.finditer(self.content):
|
|
693
|
+
self._funcs.setdefault((match.group(2), match.group(1) is not None), []).append(match)
|
|
694
|
+
found = self._funcs.get(key, []) # keyed by (name, method): no rescan per lookup
|
|
695
|
+
if len(found) != 1:
|
|
696
|
+
return None
|
|
697
|
+
index = bisect.bisect_left(self._top_starts, found[0].end())
|
|
698
|
+
if index >= len(self._top):
|
|
699
|
+
return None
|
|
700
|
+
return found[0], self._top[index]
|
|
701
|
+
|
|
702
|
+
def _mount(self, match: re.Match) -> None:
|
|
703
|
+
kind, first, second, call = match.group(2), match.group(5), match.group(6), match.group(7)
|
|
704
|
+
if first == "func":
|
|
705
|
+
return
|
|
706
|
+
target: Binding | None = None
|
|
707
|
+
variable: str | None = None
|
|
708
|
+
function: str | None = None
|
|
709
|
+
function_offset = -1
|
|
710
|
+
method = False
|
|
711
|
+
if kind == "Mount" and call is None and second is None:
|
|
712
|
+
# r.Mount("/admin", sub): a router built in this file.
|
|
713
|
+
target = self.resolve(first, match.start())
|
|
714
|
+
if target is None or not target.root:
|
|
715
|
+
return
|
|
716
|
+
variable = first
|
|
717
|
+
elif kind == "Mount" and call is not None:
|
|
718
|
+
# r.Mount("/admin", adminRouter()): the one router that function builds.
|
|
719
|
+
found = self._function(second or first, second is not None)
|
|
720
|
+
if found is None:
|
|
721
|
+
return
|
|
722
|
+
body = found[1]
|
|
723
|
+
first = bisect.bisect_right(self._root_offsets, body[0])
|
|
724
|
+
if bisect.bisect_left(self._root_offsets, body[1]) - first != 1:
|
|
725
|
+
return
|
|
726
|
+
target = self._roots[first]
|
|
727
|
+
variable, function, function_offset = target.name, found[0].group(2), found[0].start(2)
|
|
728
|
+
method = second is not None
|
|
729
|
+
elif kind == "Route" and call is None:
|
|
730
|
+
# r.Route("/admin", adminRoutes): the function's router parameter.
|
|
731
|
+
found = self._function(second or first, second is not None)
|
|
732
|
+
if found is None or found[0].group(3) is None:
|
|
733
|
+
return
|
|
734
|
+
target = self._free_binding(found[0].group(3), found[1])
|
|
735
|
+
function, function_offset = found[0].group(2), found[0].start(2)
|
|
736
|
+
method = second is not None
|
|
737
|
+
if target is None:
|
|
738
|
+
return
|
|
739
|
+
parent = self.resolve(match.group(1), match.start())
|
|
740
|
+
if parent is None:
|
|
741
|
+
return
|
|
742
|
+
node: Binding | None = parent
|
|
743
|
+
for _ in range(_MAX_DEPTH): # never into its own subtree
|
|
744
|
+
if node is None:
|
|
745
|
+
break
|
|
746
|
+
if node is target:
|
|
747
|
+
return
|
|
748
|
+
node = node.parent
|
|
749
|
+
literal = match.group(3) if match.group(3) is not None else match.group(4)
|
|
750
|
+
if target.parent is not None:
|
|
751
|
+
# Mounted twice: reachable under either parent, so neither its
|
|
752
|
+
# prefix nor the parents' middleware is known.
|
|
753
|
+
target.prefix, target.ambiguous = None, True
|
|
754
|
+
return
|
|
755
|
+
target.parent, target.prefix, target.link = parent, literal, match.start()
|
|
756
|
+
paren = self.content.find("(", match.end(2))
|
|
757
|
+
close = matching_close(self.content, paren)
|
|
758
|
+
args = split_args(self.content, paren, close) if close >= 0 else []
|
|
759
|
+
span = args[1] if len(args) >= 2 else (match.start(), match.end())
|
|
760
|
+
self._mounted.append((target, span, variable, function, function_offset, method))
|
|
761
|
+
|
|
762
|
+
def _check_mounted_uses(self) -> None:
|
|
763
|
+
"""A mounted router whose name is used for anything but its own method
|
|
764
|
+
calls (`sub.Get(...)`, `sub.Use(...)`) and the mount itself may be
|
|
765
|
+
served another way: `http.ListenAndServe(addr, sub)`, `http.Serve(ln,
|
|
766
|
+
sub)`, `srv.Handler = sub`, `handlers.LoggingHandler(w, sub)`,
|
|
767
|
+
`mux.HandleFunc("/", sub.ServeHTTP)`. Likewise a builder function
|
|
768
|
+
used again. Either way the router is reachable without the parent's
|
|
769
|
+
middleware: ambiguous (auth unknown, its own path)."""
|
|
770
|
+
# Functions / methods by name -> (target, mount argument span, is a
|
|
771
|
+
# method, declaration offset). Variables by name -> entries sorted by
|
|
772
|
+
# scope start, so an occurrence only checks the scopes around it.
|
|
773
|
+
functions: dict[str, list[tuple[Binding, tuple[int, int], bool, int]]] = {}
|
|
774
|
+
variables: dict[str, list[tuple[int, Binding, tuple[int, int]]]] = {}
|
|
775
|
+
for target, span, variable, function, function_offset, method in self._mounted:
|
|
776
|
+
if variable is not None:
|
|
777
|
+
variables.setdefault(variable, []).append((target.scope[0], target, span))
|
|
778
|
+
if function is not None:
|
|
779
|
+
functions.setdefault(function, []).append((target, span, method, function_offset))
|
|
780
|
+
if not functions and not variables:
|
|
781
|
+
return
|
|
782
|
+
scope_starts: dict[str, list[int]] = {}
|
|
783
|
+
for name, entries in variables.items():
|
|
784
|
+
entries.sort(key=lambda entry: entry[0])
|
|
785
|
+
scope_starts[name] = [entry[0] for entry in entries]
|
|
786
|
+
content = self.content
|
|
787
|
+
seen = 0
|
|
788
|
+
for match in _IDENT.finditer(content):
|
|
789
|
+
name = match.group()
|
|
790
|
+
if name not in functions and name not in variables:
|
|
791
|
+
continue
|
|
792
|
+
seen += 1
|
|
793
|
+
if seen > _MAX_MOUNT_USES:
|
|
794
|
+
for target, *_ in self._mounted:
|
|
795
|
+
target.prefix, target.ambiguous = None, True
|
|
796
|
+
return
|
|
797
|
+
offset = match.start()
|
|
798
|
+
if in_literal(content, offset):
|
|
799
|
+
continue
|
|
800
|
+
selector = _SELECTOR_BEFORE.search(content, max(0, offset - 16), offset) is not None
|
|
801
|
+
for target, span, method, declared in functions.get(name, ()):
|
|
802
|
+
# A method is used as `x.name`, a function as a bare name; the
|
|
803
|
+
# other spelling is something else of the same name.
|
|
804
|
+
if target.ambiguous or span[0] <= offset < span[1] or offset == declared or selector != method:
|
|
805
|
+
continue
|
|
806
|
+
target.prefix, target.ambiguous = None, True
|
|
807
|
+
entries = variables.get(name)
|
|
808
|
+
if not entries or selector:
|
|
809
|
+
continue # `x.sub` is another value's field
|
|
810
|
+
index = bisect.bisect_right(scope_starts[name], offset) - 1
|
|
811
|
+
for _ in range(_MAX_NESTED_SCOPES): # the (few) scopes that can contain it
|
|
812
|
+
if index < 0:
|
|
813
|
+
break
|
|
814
|
+
_, target, span = entries[index]
|
|
815
|
+
index -= 1
|
|
816
|
+
if target.ambiguous or span[0] <= offset < span[1] or offset == target.offset:
|
|
817
|
+
continue
|
|
818
|
+
if not target.scope[0] <= offset <= target.scope[1]:
|
|
819
|
+
continue
|
|
820
|
+
call = _METHOD_CALL.match(content, match.end())
|
|
821
|
+
if call and call.group(1) != "ServeHTTP":
|
|
822
|
+
continue
|
|
823
|
+
if _RETURN_BEFORE.search(content, max(0, offset - 16), offset):
|
|
824
|
+
continue # a builder returning it to the mount
|
|
825
|
+
target.prefix, target.ambiguous = None, True
|
|
826
|
+
|
|
827
|
+
# -- resolution --
|
|
828
|
+
|
|
829
|
+
def is_router(self, text: str, offset: int) -> bool:
|
|
830
|
+
"""``text`` is a router variable built in this file (``r`` in
|
|
831
|
+
``http.Handle("/", r)``), not a handler."""
|
|
832
|
+
name = text.strip()
|
|
833
|
+
if not name.isidentifier():
|
|
834
|
+
return False
|
|
835
|
+
binding = self.resolve(name, offset)
|
|
836
|
+
return binding is not None and (binding.root or binding.parent is not None)
|
|
837
|
+
|
|
838
|
+
def resolve_route(
|
|
839
|
+
self, receiver: str, offset: int, path: str, local: list[Guard] | None, *, ordered: bool = True
|
|
840
|
+
) -> Resolution:
|
|
841
|
+
"""The route's auth and joined path. ``local`` is the route's own
|
|
842
|
+
middleware, None when its arguments couldn't be read (auth unknown).
|
|
843
|
+
``ordered``: a ``Use`` applies only to routes registered after it
|
|
844
|
+
(gin, echo, fiber, chi); gorilla applies it to every route."""
|
|
845
|
+
binding = self.resolve(receiver, offset)
|
|
846
|
+
if binding is None:
|
|
847
|
+
return Resolution(path=path)
|
|
848
|
+
auth_known = local is not None
|
|
849
|
+
applied: list[Guard] = list(local or [])
|
|
850
|
+
rel_path: str | None = path # relative to the binding being walked
|
|
851
|
+
known = path
|
|
852
|
+
position = offset
|
|
853
|
+
top = binding
|
|
854
|
+
for _ in range(_MAX_DEPTH):
|
|
855
|
+
if binding.overflow or binding.ambiguous:
|
|
856
|
+
auth_known = False
|
|
857
|
+
for use_offset, guards in binding.uses if auth_known else ():
|
|
858
|
+
if ordered and use_offset >= position:
|
|
859
|
+
continue
|
|
860
|
+
for guard in guards:
|
|
861
|
+
if guard.prefix is None:
|
|
862
|
+
applied.append(guard)
|
|
863
|
+
elif rel_path is None:
|
|
864
|
+
applied.append(Guard(guard.name, guard.evidence, uncertain=True))
|
|
865
|
+
elif rel_path.startswith(guard.prefix):
|
|
866
|
+
applied.append(guard)
|
|
867
|
+
applied.extend(binding.guards)
|
|
868
|
+
if rel_path is not None:
|
|
869
|
+
rel_path = None if binding.prefix is None else join_paths(binding.prefix, rel_path)
|
|
870
|
+
known = rel_path if rel_path is not None else known
|
|
871
|
+
position = binding.link
|
|
872
|
+
top = binding
|
|
873
|
+
if binding.parent is None:
|
|
874
|
+
break
|
|
875
|
+
binding = binding.parent
|
|
876
|
+
else:
|
|
877
|
+
# Derivation deeper than _MAX_DEPTH: the prefix is unknown. The
|
|
878
|
+
# route's own path, not a partial join that two routes at
|
|
879
|
+
# different depths could share.
|
|
880
|
+
return Resolution(path=path)
|
|
881
|
+
if not auth_known:
|
|
882
|
+
return Resolution(path=known)
|
|
883
|
+
resolution = evaluate(applied, rel_path if top.root else None)
|
|
884
|
+
resolution.path = known
|
|
885
|
+
return resolution
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def evaluate(guards: list[Guard], full_path: str | None) -> Resolution:
|
|
889
|
+
required: list[Guard] = []
|
|
890
|
+
skipped: list[Guard] = []
|
|
891
|
+
uncertain = False
|
|
892
|
+
for guard in guards:
|
|
893
|
+
if guard.uncertain:
|
|
894
|
+
uncertain = True
|
|
895
|
+
elif guard.skipper is None:
|
|
896
|
+
required.append(guard)
|
|
897
|
+
elif not guard.skipper.characterized or full_path is None:
|
|
898
|
+
uncertain = True
|
|
899
|
+
elif guard.skipper.skips(full_path):
|
|
900
|
+
skipped.append(guard)
|
|
901
|
+
else:
|
|
902
|
+
required.append(guard)
|
|
903
|
+
if required:
|
|
904
|
+
names = list(dict.fromkeys(g.name for g in required))
|
|
905
|
+
return Resolution(REQUIRED, names, clip("; ".join(dict.fromkeys(g.evidence for g in required))))
|
|
906
|
+
if uncertain:
|
|
907
|
+
return Resolution()
|
|
908
|
+
if skipped:
|
|
909
|
+
evidence = "; ".join(dict.fromkeys(g.skipper.evidence for g in skipped if g.skipper))
|
|
910
|
+
return Resolution(ANONYMOUS, [], clip(evidence))
|
|
911
|
+
return Resolution()
|