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.
@@ -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()