attackmap-analyzer-rust 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,909 @@
1
+ """Router graphs: the full path and the layers of every chained route (#2).
2
+
3
+ A *chain* is a builder expression such as ``Router::new().route(..).nest(..)``
4
+ (axum), ``web::scope("/api").service(..)`` (actix-web), ``Route::new().at(..)``
5
+ (poem), ``Router::with_path("x").get(h).push(..)`` (salvo) or
6
+ ``Routes::new().prefix("x").add(..)`` (loco). Its *items* are the route,
7
+ layer and mount links in source order. A route's full path is its own path
8
+ joined to the prefixes of every chain that mounts it, up to a root:
9
+
10
+ - **containment** — a chain written inside a mount link of another one
11
+ (``.nest("/api", Router::new()...)``, ``.service(web::scope("/x")...)``,
12
+ ``.push(Router::with_path("x")...)``);
13
+ - **by name** — a chain bound with ``let name = ...`` (same file, nearest
14
+ binding before the use) or returned from ``fn name()`` (any file), mounted by
15
+ ``.nest("/p", name)`` / ``.nest("/p", path::name())`` / ``.merge(..)`` /
16
+ ``.add_route(..)``. Several definitions of one fn name are told apart by the
17
+ module path (``users::routes`` -> ``users.rs`` / ``users/mod.rs``) or by
18
+ living in the caller's file; otherwise the mount is left unresolved;
19
+ - **actix** ``.configure(path::f)`` — the root chains in ``fn f``;
20
+ - **actix attribute handlers** — ``.service(handler)``;
21
+ - **rocket** — ``.mount("/base", routes![a, m::b])`` or
22
+ ``.mount("/base", m::routes())`` where ``fn routes`` returns ``routes![..]``.
23
+
24
+ A router mounted at several prefixes yields one route per prefix, each with the
25
+ layers on its own path. axum and poem layers wrap only the routes added *before*
26
+ them; actix ``wrap``, salvo ``hoop`` and loco ``layer`` cover the whole chain.
27
+
28
+ Everything is bounded: links per chain, mount points per router, depth, prefixes
29
+ per route, and every lookup is memoized, so fan-in stays linear.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import bisect
35
+ import functools
36
+ import re
37
+ from dataclasses import dataclass, field
38
+ from pathlib import PurePosixPath
39
+
40
+ from . import route_auth as ra
41
+ from .route_auth import Layer, PathState
42
+
43
+ _MAX_DEPTH = 16 # mount levels followed upward
44
+ _MAX_REFS = 16 # mount points of one router
45
+ _MAX_PATHS = 16 # distinct prefixes per route
46
+ _MAX_LINKS = 4096
47
+ _OWNER_STEPS = 64
48
+ _MAX_DEFS = 256 # definitions of one name considered when disambiguating
49
+
50
+ HTTP_VERBS = ("get", "post", "put", "delete", "patch", "head", "options", "trace", "connect")
51
+
52
+
53
+ def join_path(prefix: str, path: str) -> str:
54
+ """Join two route segments like core's ``_join_route_parts``."""
55
+
56
+ def norm(value: str) -> str:
57
+ value = (value or "").strip()
58
+ if not value:
59
+ return "/"
60
+ if not value.startswith("/"):
61
+ value = "/" + value
62
+ while "//" in value:
63
+ value = value.replace("//", "/")
64
+ return value
65
+
66
+ base, suffix = norm(prefix), norm(path)
67
+ if base == "/":
68
+ return suffix
69
+ if suffix == "/":
70
+ return base.rstrip("/") or "/"
71
+ return f"{base.rstrip('/')}/{suffix.lstrip('/')}"
72
+
73
+
74
+ # ---------- Small parsers ----------
75
+
76
+ _STR_LIT = re.compile(r'r?"([^"\\\n]*)"')
77
+
78
+
79
+ def literal(content: str, start: int, end: int) -> str | None:
80
+ """The string literal that is the whole of ``content[start:end]``."""
81
+ text = content[start : min(end, start + 600)].strip()
82
+ match = _STR_LIT.fullmatch(text)
83
+ return match.group(1) if match else None
84
+
85
+
86
+ _REF_HEAD = re.compile(
87
+ r"\s*(?:&\s*)?(?:mut\s+)?([A-Za-z_]\w*(?:\s*::\s*[A-Za-z_]\w*){0,16})\s*(?:::\s*<[^<>()]{0,200}>\s*)?"
88
+ )
89
+ _CLOSURE_HEAD = re.compile(r"\s*(?:move\s*)?\|[^|]{0,200}\|\s*")
90
+ # Calls that hand the same router back.
91
+ _REF_TAIL = frozenset(
92
+ {"clone", "with_state", "into", "boxed", "to_owned", "into_make_service", "into_make_service_with_connect_info"}
93
+ )
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Ref:
98
+ name: str
99
+ qualifier: tuple[str, ...]
100
+ call: bool
101
+ offset: int
102
+
103
+
104
+ def parse_ref(content: str, start: int, end: int, *, closure: bool = False) -> Ref | None:
105
+ """``name`` / ``a::b::name(..)`` (optionally ``.clone()`` / ``.with_state(..)``)
106
+ as the whole of ``content[start:end]``; with ``closure``, also the body of
107
+ ``|cfg| a::name(cfg, ..)``."""
108
+ end = min(end, start + 2000)
109
+ pos = start
110
+ if closure:
111
+ head = _CLOSURE_HEAD.match(content, pos, end)
112
+ if head:
113
+ pos = head.end()
114
+ head = _REF_HEAD.match(content, pos, end)
115
+ if not head:
116
+ return None
117
+ segments = [s.strip() for s in head.group(1).split("::")]
118
+ pos = head.end()
119
+ call = False
120
+ if pos < end and content[pos] == "(":
121
+ close = ra.matching_close(content, pos)
122
+ if close < 0 or close >= end:
123
+ return None
124
+ call, pos = True, close + 1
125
+ for link in ra.parse_chain(content, pos, 8):
126
+ if link.close >= end or link.name not in _REF_TAIL:
127
+ return None
128
+ pos = link.close + 1
129
+ if content[pos:end].strip():
130
+ return None
131
+ if len(segments) > 1 and segments[-2][:1].isupper():
132
+ return None # `Type::new(..)`: a value, not a router fn
133
+ qualifier = tuple(s for s in segments[:-1] if s not in ("crate", "self", "super"))
134
+ return Ref(segments[-1], qualifier, call, start)
135
+
136
+
137
+ _BODY_OPEN = re.compile(r"[^{};]{0,400}\{")
138
+
139
+
140
+ @functools.lru_cache(maxsize=4)
141
+ def fn_bodies(content: str) -> tuple[list[int], list[tuple[int, int, str, int]]]:
142
+ """``(body opens, [(open, close, fn name, decl offset)])`` sorted by open."""
143
+ bodies = []
144
+ for match in ra._FN_DECL.finditer(content):
145
+ close = ra.matching_close(content, match.end() - 1)
146
+ if close < 0:
147
+ continue
148
+ brace = _BODY_OPEN.match(content, close + 1)
149
+ if not brace:
150
+ continue
151
+ body_open = brace.end() - 1
152
+ body_close = ra.matching_close(content, body_open)
153
+ if body_close > 0:
154
+ bodies.append((body_open, body_close, match.group(1), match.start()))
155
+ bodies.sort()
156
+ return [b[0] for b in bodies], bodies
157
+
158
+
159
+ def owner(content: str, offset: int) -> tuple[int, int, str, int] | None:
160
+ """The innermost fn body around ``offset``: ``(open, close, name, decl)``."""
161
+ opens, bodies = fn_bodies(content)
162
+ index = bisect.bisect_left(opens, offset) - 1
163
+ for _ in range(_OWNER_STEPS):
164
+ if index < 0:
165
+ break
166
+ if bodies[index][1] > offset:
167
+ return bodies[index]
168
+ index -= 1
169
+ return None
170
+
171
+
172
+ _TAIL = re.compile(ra._GAP + r"\}")
173
+ _RETURN_BEFORE = re.compile(r"\breturn\s*$")
174
+
175
+
176
+ def _module_match(file: str, qualifier: tuple[str, ...], strict: bool) -> bool:
177
+ last = qualifier[-1].replace("-", "_")
178
+ parts = [p.replace("-", "_") for p in PurePosixPath(file).parts]
179
+ if not parts:
180
+ return False
181
+ stem = parts[-1].rsplit(".", 1)[0]
182
+ if strict:
183
+ return stem == last or (stem in ("mod", "lib") and len(parts) > 1 and parts[-2] == last)
184
+ return last in parts[:-1]
185
+
186
+
187
+ def pick_definition(defs: dict[tuple[str, int], list], ref: Ref, from_file: str) -> list:
188
+ """The one definition ``ref`` names, by module path or by sharing the
189
+ caller's file; nothing when that's ambiguous."""
190
+ if len(defs) <= 1:
191
+ return [x for group in defs.values() for x in group]
192
+ if len(defs) > _MAX_DEFS:
193
+ return []
194
+ if ref.qualifier:
195
+ for strict in (True, False):
196
+ keys = [k for k in defs if _module_match(k[0], ref.qualifier, strict)]
197
+ if len(keys) == 1:
198
+ return defs[keys[0]]
199
+ return []
200
+ keys = [k for k in defs if k[0] == from_file]
201
+ return defs[keys[0]] if len(keys) == 1 else []
202
+
203
+
204
+ # ---------- Chains ----------
205
+
206
+
207
+ @dataclass
208
+ class Mount:
209
+ ref: Ref | None
210
+ prefix: str | None # None: not a literal
211
+
212
+
213
+ @dataclass
214
+ class Item:
215
+ kind: str # "route" | "layer" | "mount" | "service" | "configure"
216
+ dot: int
217
+ open: int
218
+ close: int
219
+ value: object = None # Layer | Mount | Ref | method name
220
+
221
+
222
+ @dataclass(eq=False)
223
+ class Chain:
224
+ framework: str # "axum" | "actix" | "poem" | "salvo" | "loco" | "loco_app"
225
+ file: str
226
+ start: int
227
+ end: int # offset of the last character
228
+ ordered: bool = True # layers wrap only the items before them
229
+ prefix: str | None = "" # the chain's own path (scope / resource / with_path / prefix)
230
+ resource: bool = False # actix web::resource
231
+ name: str | None = None
232
+ name_kind: str | None = None # "let" | "fn"
233
+ definition: int = -1 # offset of the binding / fn that names it
234
+ owner: tuple[int, int, str, int] | None = None
235
+ items: list[Item] = field(default_factory=list)
236
+ parent: "Chain | None" = None
237
+ parent_index: int | None = None
238
+ inline_mounts: set[int] = field(default_factory=set)
239
+ extends: Ref | None = None # `let api = api.route(..)`: continues the router bound to `api`
240
+ _opens: list[int] = field(default_factory=list, repr=False)
241
+
242
+ @property
243
+ def scope(self) -> int:
244
+ """The fn a binding lives in (offset of its decl; -1 outside any fn)."""
245
+ return self.owner[3] if self.owner else -1
246
+
247
+ def item_at(self, offset: int) -> int | None:
248
+ """Index of the item whose arguments contain ``offset``."""
249
+ if len(self._opens) != len(self.items):
250
+ self._opens = [item.open for item in self.items]
251
+ index = bisect.bisect_right(self._opens, offset) - 1
252
+ if index >= 0 and self.items[index].close > offset:
253
+ return index
254
+ return None
255
+
256
+
257
+ def _layer(content: str, link: ra.Link) -> Layer:
258
+ evidence = ra.clip(content[link.start : min(link.close + 1, link.start + 2 * ra._EVIDENCE_MAX)])
259
+ return ra.classify_layer(content, link.open + 1, link.close, evidence)
260
+
261
+
262
+ def _name_chain(content: str, chain: Chain) -> None:
263
+ """``let name = <chain>`` or a chain that is what ``fn name`` returns."""
264
+ window = content[max(0, chain.start - 200) : chain.start]
265
+ let = ra._LET_NAME.search(window)
266
+ if let:
267
+ chain.name, chain.name_kind = let.group(1), "let"
268
+ chain.definition = chain.start
269
+ return
270
+ body = chain.owner
271
+ if body is None:
272
+ return
273
+ head = content[body[0] + 1 : chain.start]
274
+ first = not head.strip() or ra._FN_RETURNING.search(content[max(0, chain.start - 600) : chain.start])
275
+ tail = _TAIL.match(content, chain.end + 1)
276
+ returned = tail is not None and tail.end() - 1 == body[1]
277
+ if returned or first or _RETURN_BEFORE.search(content[max(0, chain.start - 40) : chain.start]):
278
+ chain.name, chain.name_kind, chain.definition = body[2], "fn", body[3]
279
+
280
+
281
+ def _start_chain(content: str, file: str, framework: str, start: int, idx: int, **kw) -> tuple[Chain, list[ra.Link]]:
282
+ links = ra.parse_chain(content, idx, _MAX_LINKS)
283
+ end = links[-1].close if links else idx - 1
284
+ chain = Chain(framework, file, start, end, **kw)
285
+ chain.owner = owner(content, start)
286
+ _name_chain(content, chain)
287
+ return chain, links
288
+
289
+
290
+ def _args(content: str, link: ra.Link) -> list[tuple[int, int]]:
291
+ return ra.split_args(content, link.open, link.close)
292
+
293
+
294
+ def _mount(content: str, link: ra.Link, with_prefix: bool) -> Mount:
295
+ args = _args(content, link)
296
+ prefix: str | None = ""
297
+ if with_prefix:
298
+ prefix = literal(content, *args[0]) if len(args) > 1 else None
299
+ target = parse_ref(content, *args[-1]) if args else None
300
+ return Mount(target, prefix)
301
+
302
+
303
+ _AXUM_START = ra._AXUM_START
304
+ _REBIND = re.compile(r"\blet\s+(?:mut\s+)?(\w+)\s*=\s*(\w+)(?=\s*\.\s*(?:route|layer|route_layer|merge|nest|nest_service)\s*\()")
305
+ _POEM_START = re.compile(r"\bRoute\s*::\s*new\s*\(\s*\)")
306
+ _LOCO_START = re.compile(r"\bRoutes\s*::\s*new\s*\(\s*\)")
307
+ _LOCO_APP_START = re.compile(r"\bAppRoutes\s*::\s*(?:with_default_routes|empty)\s*\(\s*\)")
308
+ _SALVO_START = re.compile(r"\bRouter\s*::\s*(?:(new)\s*\(\s*\)|(with_path|with_hoop)\s*\()")
309
+ _ACTIX_START = re.compile(r"\bApp\s*::\s*new\s*\(\s*\)|\bweb\s*::\s*(scope|resource)\s*\(")
310
+ _ACTIX_BARE_START = re.compile(r"(?<![\w:.])(scope|resource)\s*\(")
311
+ _ACTIX_USE = re.compile(r"\buse\s+actix_web\b")
312
+ _WORD_SCOPE = re.compile(r"\b(?:scope|resource)\b|\bweb\s*::\s*\*")
313
+
314
+
315
+ def axum_like_chains(content: str, file: str, framework: str) -> list[Chain]:
316
+ """axum ``Router::new()`` and poem ``Route::new()`` chains."""
317
+ start_re, route_link = (_AXUM_START, "route") if framework == "axum" else (_POEM_START, "at")
318
+ mounts = ("merge", "nest", "nest_service") if framework == "axum" else ("nest", "nest_no_strip")
319
+ layers = ("layer", "route_layer") if framework == "axum" else ("with", "around")
320
+ starts: list[tuple[int, int, Ref | None]] = [(m.start(), m.end(), None) for m in start_re.finditer(content)]
321
+ if framework == "axum":
322
+ # `let api = api.route(..)`: a chain that continues an earlier binding.
323
+ for m in _REBIND.finditer(content):
324
+ starts.append((m.start(2), m.end(2), Ref(m.group(2), (), False, m.start(2))))
325
+ starts.sort(key=lambda s: s[0])
326
+ chains = []
327
+ for start, idx, extends in starts:
328
+ chain, links = _start_chain(content, file, framework, start, idx)
329
+ if extends is not None:
330
+ if not links:
331
+ continue
332
+ chain.extends = extends
333
+ for link in links:
334
+ if link.name == route_link:
335
+ chain.items.append(Item("route", link.start, link.open, link.close))
336
+ elif link.name in layers:
337
+ chain.items.append(Item("layer", link.start, link.open, link.close, _layer(content, link)))
338
+ elif link.name in mounts:
339
+ mount = _mount(content, link, link.name != "merge")
340
+ chain.items.append(Item("mount", link.start, link.open, link.close, mount))
341
+ chains.append(chain)
342
+ return chains
343
+
344
+
345
+ def loco_chains(content: str, file: str) -> list[Chain]:
346
+ """loco ``Routes::new().prefix("x").add("/y", get(h))`` and
347
+ ``AppRoutes::..().prefix("/api").add_route(m::routes())``."""
348
+ chains = []
349
+ for match in _LOCO_START.finditer(content):
350
+ chain, links = _start_chain(content, file, "loco", match.start(), match.end(), ordered=False)
351
+ for link in links:
352
+ if link.name == "prefix":
353
+ args = _args(content, link)
354
+ chain.prefix = literal(content, *args[0]) if len(args) == 1 else None
355
+ elif link.name == "add":
356
+ chain.items.append(Item("route", link.start, link.open, link.close))
357
+ elif link.name == "layer":
358
+ chain.items.append(Item("layer", link.start, link.open, link.close, _layer(content, link)))
359
+ chains.append(chain)
360
+ for match in _LOCO_APP_START.finditer(content):
361
+ chain, links = _start_chain(content, file, "loco_app", match.start(), match.end(), ordered=False)
362
+ current: str | None = ""
363
+ for link in links:
364
+ if link.name == "prefix":
365
+ args = _args(content, link)
366
+ current = literal(content, *args[0]) if len(args) == 1 else None
367
+ elif link.name == "add_route":
368
+ mount = _mount(content, link, False)
369
+ mount.prefix = current
370
+ chain.items.append(Item("mount", link.start, link.open, link.close, mount))
371
+ elif link.name == "layer":
372
+ chain.items.append(Item("layer", link.start, link.open, link.close, _layer(content, link)))
373
+ chains.append(chain)
374
+ return chains
375
+
376
+
377
+ def salvo_chains(content: str, file: str) -> list[Chain]:
378
+ """salvo ``Router::with_path("x").hoop(m).get(h).push(child)``."""
379
+ chains = []
380
+ for match in _SALVO_START.finditer(content):
381
+ prefix: str | None = ""
382
+ pre_items: list[Item] = []
383
+ idx = match.end()
384
+ if match.group(2):
385
+ open_idx = match.end() - 1
386
+ close = ra.matching_close(content, open_idx)
387
+ if close < 0:
388
+ continue
389
+ idx = close + 1
390
+ if match.group(2) == "with_path":
391
+ prefix = literal(content, open_idx + 1, close)
392
+ else:
393
+ link = ra.Link("hoop", match.start(), open_idx, close)
394
+ pre_items.append(Item("layer", link.start, open_idx, close, _layer(content, link)))
395
+ chain, links = _start_chain(content, file, "salvo", match.start(), idx, ordered=False, prefix=prefix)
396
+ chain.items.extend(pre_items)
397
+ for link in links:
398
+ if link.name == "path":
399
+ args = _args(content, link)
400
+ path = literal(content, *args[0]) if len(args) == 1 else None
401
+ chain.prefix = None if path is None or chain.prefix is None else join_path(chain.prefix, path)
402
+ elif link.name in HTTP_VERBS or link.name in ("goal", "any"):
403
+ method = link.name.upper() if link.name in HTTP_VERBS else "ANY"
404
+ chain.items.append(Item("route", link.start, link.open, link.close, method))
405
+ elif link.name == "hoop":
406
+ chain.items.append(Item("layer", link.start, link.open, link.close, _layer(content, link)))
407
+ elif link.name == "push":
408
+ chain.items.append(Item("mount", link.start, link.open, link.close, _mount(content, link, False)))
409
+ chains.append(chain)
410
+ return chains
411
+
412
+
413
+ def _actix_bare_imported(content: str) -> bool:
414
+ """``use actix_web::web::{scope, resource}`` or ``web::*`` (so a bare
415
+ ``scope("..")`` is actix's)."""
416
+ for match in _ACTIX_USE.finditer(content):
417
+ end = content.find(";", match.end(), match.end() + 4000)
418
+ if end > 0 and _WORD_SCOPE.search(content, match.end(), end):
419
+ return True
420
+ return False
421
+
422
+
423
+ def actix_chains(content: str, file: str) -> list[Chain]:
424
+ """``App::new()`` / ``web::scope("/x")`` / ``web::resource("/x")`` chains."""
425
+ starts: list[tuple[int, int, str | None]] = [
426
+ (m.start(), m.end(), m.group(1)) for m in _ACTIX_START.finditer(content)
427
+ ]
428
+ if _actix_bare_imported(content):
429
+ starts += [(m.start(), m.end(), m.group(1)) for m in _ACTIX_BARE_START.finditer(content)]
430
+ chains: list[Chain] = []
431
+ for start, end, kind in sorted(starts):
432
+ idx, prefix = end, ""
433
+ if kind: # scope("..") / resource("..")
434
+ close = ra.matching_close(content, end - 1)
435
+ if close < 0:
436
+ continue
437
+ args = ra.split_args(content, end - 1, close)
438
+ if len(args) != 1:
439
+ continue
440
+ prefix = literal(content, *args[0])
441
+ idx = close + 1
442
+ chain, links = _start_chain(
443
+ content, file, "actix", start, idx, ordered=False, prefix=prefix, resource=kind == "resource"
444
+ )
445
+ for link in links:
446
+ if link.name in ("wrap", "wrap_fn"):
447
+ chain.items.append(Item("layer", link.start, link.open, link.close, _layer(content, link)))
448
+ elif link.name == "route" or (link.name == "to" and chain.resource):
449
+ chain.items.append(Item("route", link.start, link.open, link.close, link.name))
450
+ elif link.name in ("service", "default_service"):
451
+ ref = parse_ref(content, link.open + 1, link.close)
452
+ chain.items.append(Item("service", link.start, link.open, link.close, ref))
453
+ elif link.name == "configure":
454
+ ref = parse_ref(content, link.open + 1, link.close, closure=True)
455
+ chain.items.append(Item("configure", link.start, link.open, link.close, ref))
456
+ chains.append(chain)
457
+ return chains
458
+
459
+
460
+ def nest_chains(chains: list[Chain]) -> None:
461
+ """Link each chain written inside another chain's link arguments to it."""
462
+ stack: list[Chain] = []
463
+ for chain in sorted(chains, key=lambda c: (c.start, -c.end)):
464
+ while stack and stack[-1].end < chain.start:
465
+ stack.pop()
466
+ if stack and stack[-1].start < chain.start:
467
+ outer = stack[-1]
468
+ index = outer.item_at(chain.start)
469
+ if index is not None:
470
+ chain.parent, chain.parent_index = outer, index
471
+ outer.inline_mounts.add(index)
472
+ stack.append(chain)
473
+
474
+
475
+ # ---------- Served routers ----------
476
+
477
+ _SERVE_CALL = re.compile(r"\b(?:serve|run|run_with_graceful_shutdown|launch)\s*\(")
478
+ _MAX_SERVE_CALLS = 64
479
+
480
+
481
+ def served_refs(content: str) -> list[tuple[Ref, int]]:
482
+ """Routers handed to a server (``axum::serve(l, app)``, ``.serve(app.into_make_service())``,
483
+ poem ``.run(app)``, salvo ``.serve(router)``), with the fn they're in.
484
+ Only these roots count as fully seen: any other unmounted router may be
485
+ mounted somewhere not visible."""
486
+ found = []
487
+ for match in list(_SERVE_CALL.finditer(content))[:_MAX_SERVE_CALLS]:
488
+ close = ra.matching_close(content, match.end() - 1)
489
+ if close < 0:
490
+ continue
491
+ for start, end in ra.split_args(content, match.end() - 1, close)[:8]:
492
+ ref = parse_ref(content, start, end)
493
+ if ref is not None:
494
+ body = owner(content, match.start())
495
+ found.append((ref, body[3] if body else -1))
496
+ return found
497
+
498
+
499
+ # ---------- Opaque router names (axum) ----------
500
+
501
+ # Anchored on the `.`; the receiver name is read backwards (bounded), so a
502
+ # long word or whitespace run can't make the scan quadratic.
503
+ _ROUTER_CALL = re.compile(r"\.[ \t\r\n]*(merge|nest|nest_service|layer|route_layer)[ \t\r\n]*\(")
504
+
505
+
506
+ def _receiver(content: str, end: int) -> str | None:
507
+ """The identifier right before ``end`` (whitespace skipped), bounded."""
508
+ i = end
509
+ while i > 0 and end - i < 200 and content[i - 1].isspace():
510
+ i -= 1
511
+ j = i
512
+ while j > 0 and i - j < 200 and (content[j - 1].isalnum() or content[j - 1] == "_"):
513
+ j -= 1
514
+ return content[j:i] or None
515
+
516
+
517
+ def opaque_router_names(content: str, dots: set[int]) -> set[str]:
518
+ """Router names used by ``.layer`` / ``.merge`` / ``.nest`` calls that
519
+ aren't links of a parsed chain (``dots``): their full layer path isn't
520
+ visible."""
521
+ names: set[str] = set()
522
+ for match in _ROUTER_CALL.finditer(content):
523
+ if match.start() in dots:
524
+ continue
525
+ receiver = _receiver(content, match.start())
526
+ if receiver:
527
+ names.add(receiver)
528
+ if match.group(1) in ("merge", "nest", "nest_service"):
529
+ close = ra.matching_close(content, match.end() - 1)
530
+ args = ra.split_args(content, match.end() - 1, close) if close > 0 else []
531
+ target = parse_ref(content, *args[-1]) if args else None
532
+ if target is not None:
533
+ names.add(target.name)
534
+ return names
535
+
536
+
537
+ # ---------- Rocket mounts ----------
538
+
539
+ _ROCKET_MOUNT = re.compile(r"\.\s*mount\s*\(")
540
+ _ROUTES_MACRO = re.compile(r"\broutes!\s*[\[(]")
541
+
542
+
543
+ def _macro_refs(content: str, open_idx: int) -> list[Ref]:
544
+ close = ra.matching_close(content, open_idx)
545
+ if close < 0:
546
+ return []
547
+ refs = [parse_ref(content, s, e) for s, e in ra.split_args(content, open_idx, close)[:256]]
548
+ return [r for r in refs if r is not None and not r.call]
549
+
550
+
551
+ @dataclass
552
+ class RocketMount:
553
+ base: str | None
554
+ file: str
555
+ refs: list[Ref] = field(default_factory=list) # handlers in routes![..]
556
+ via: Ref | None = None # `m::routes()` returning routes![..]
557
+
558
+
559
+ def rocket_mounts(content: str, file: str) -> tuple[list[RocketMount], list[tuple[str, int, list[Ref]]]]:
560
+ """``.mount(base, routes![..] | f())`` calls, and the ``routes![..]`` that
561
+ each fn returns: ``[(fn name, decl offset, refs)]``."""
562
+ mounts: list[RocketMount] = []
563
+ in_mount: set[int] = set()
564
+ for match in _ROCKET_MOUNT.finditer(content):
565
+ close = ra.matching_close(content, match.end() - 1)
566
+ if close < 0:
567
+ continue
568
+ args = ra.split_args(content, match.end() - 1, close)
569
+ if len(args) != 2:
570
+ continue
571
+ mount = RocketMount(literal(content, *args[0]), file)
572
+ target = content[args[1][0] : args[1][1]]
573
+ macro = _ROUTES_MACRO.match(content, args[1][0] + len(target) - len(target.lstrip()))
574
+ if macro:
575
+ in_mount.add(macro.start())
576
+ mount.refs = _macro_refs(content, macro.end() - 1)
577
+ else:
578
+ mount.via = parse_ref(content, *args[1])
579
+ if mount.via is None or not mount.via.call:
580
+ continue
581
+ mounts.append(mount)
582
+ returned = []
583
+ for macro in _ROUTES_MACRO.finditer(content):
584
+ if macro.start() in in_mount:
585
+ continue
586
+ body = owner(content, macro.start())
587
+ if body is not None:
588
+ returned.append((body[2], body[3], _macro_refs(content, macro.end() - 1)))
589
+ return mounts, returned
590
+
591
+
592
+ # ---------- Registry ----------
593
+
594
+ MountPath = tuple[str, PathState]
595
+
596
+
597
+ def _combine(local: PathState, outer: PathState) -> PathState:
598
+ return PathState(local.guards + outer.guards, local.clean and outer.clean, local.known and outer.known)
599
+
600
+
601
+ def _merge(paths: list[MountPath]) -> list[MountPath]:
602
+ """One entry per prefix; mount points sharing a prefix must agree."""
603
+ grouped: dict[str, list[PathState]] = {}
604
+ for prefix, state in paths:
605
+ grouped.setdefault(prefix, []).append(state)
606
+ out = []
607
+ for prefix, states in list(grouped.items())[:_MAX_PATHS]:
608
+ if len(states) == 1:
609
+ out.append((prefix, states[0]))
610
+ continue
611
+ merged = PathState()
612
+ if all(s.guards for s in states):
613
+ merged.guards = [g for s in states for g in s.guards]
614
+ merged.clean = all(s.clean for s in states)
615
+ merged.known = all(s.known for s in states)
616
+ out.append((prefix, merged))
617
+ return out
618
+
619
+
620
+ @dataclass
621
+ class Handler:
622
+ file: str
623
+ signature: ra.Signature
624
+
625
+
626
+ class Registry:
627
+ """Router chains, mounts and handler signatures across the repo."""
628
+
629
+ def __init__(self) -> None:
630
+ self.chains: list[Chain] = []
631
+ self.signatures: dict[str, list[Handler]] = {}
632
+ # Router names also touched outside a parsed chain (`app = app.layer(..)`,
633
+ # `app.merge(api)`): their full layer path isn't visible.
634
+ self.opaque: set[str] = set()
635
+ # Extractor type -> one flag per visible FromRequest(Parts) impl: can it reject?
636
+ self.extractors: dict[str, list[bool]] = {}
637
+ self.attr_handlers: dict[str, set[str]] = {} # actix/rocket handler fn -> files
638
+ self.rocket: list[RocketMount] = []
639
+ self.rocket_fns: dict[str, list[tuple[str, int, list[Ref]]]] = {}
640
+ self.served: list[tuple[Ref, str, int]] = [] # (router ref, file, fn) handed to a server
641
+ self._served: set[Chain] = set()
642
+ self._final = False
643
+ self._mounted_by: dict[Chain, list[tuple[Chain, int, str | None]]] = {}
644
+ self._services: dict[str, list[tuple[Chain, int, Ref]]] = {}
645
+ self._rocket_refs: dict[str, list[tuple[str | None, Ref, str]]] = {}
646
+ self._memo: dict[tuple[Chain, int | None, int], list[MountPath]] = {}
647
+ self._sig_memo: dict[tuple[str, tuple[str, ...], str], ra.Signature | None] = {}
648
+ self._service_memo: dict[tuple[str, str], list[MountPath]] = {}
649
+ self._suffix: dict[Chain, list[tuple[tuple[Layer, ...], bool]]] = {}
650
+
651
+ # -- collection --
652
+
653
+ def add_chains(self, chains: list[Chain]) -> None:
654
+ self.chains.extend(chains)
655
+ self._final = False
656
+
657
+ def add_signatures(self, file: str, found: dict[str, list[tuple[int, ra.Signature]]]) -> None:
658
+ for name, sigs in found.items():
659
+ self.signatures.setdefault(name, []).extend(Handler(file, sig) for _, sig in sigs)
660
+
661
+ def add_served(self, refs: list[tuple[Ref, int]], file: str) -> None:
662
+ self.served.extend((ref, file, scope) for ref, scope in refs)
663
+ self._final = False
664
+
665
+ def add_rocket(self, mounts: list[RocketMount], returned: list[tuple[str, int, list[Ref]]], file: str) -> None:
666
+ self.rocket.extend(mounts)
667
+ for name, decl, refs in returned:
668
+ self.rocket_fns.setdefault(name, []).append((file, decl, refs))
669
+ self._final = False
670
+
671
+ # -- resolution --
672
+
673
+ def _finalize(self) -> None:
674
+ if self._final:
675
+ return
676
+ self._final = True
677
+ self._mounted_by.clear()
678
+ self._services.clear()
679
+ self._memo.clear()
680
+ self._service_memo.clear()
681
+ self._suffix.clear()
682
+ # `let` bindings: (family, file, fn, name) -> chains sorted by offset.
683
+ lets: dict[tuple[str, str, int, str], list[Chain]] = {}
684
+ # Router fns: (family, name) -> {(file, fn offset): chains}.
685
+ fns: dict[tuple[str, str], dict[tuple[str, int], list[Chain]]] = {}
686
+ # actix root chains by the fn they're in (for `.configure(f)`).
687
+ roots: dict[str, dict[tuple[str, int], list[Chain]]] = {}
688
+ for chain in self.chains:
689
+ family = "loco" if chain.framework == "loco_app" else chain.framework
690
+ if chain.name and chain.name_kind == "let":
691
+ lets.setdefault((family, chain.file, chain.scope, chain.name), []).append(chain)
692
+ elif chain.name and chain.name_kind == "fn":
693
+ fns.setdefault((family, chain.name), {}).setdefault((chain.file, chain.definition), []).append(chain)
694
+ if chain.framework == "actix" and chain.parent is None and chain.owner is not None:
695
+ roots.setdefault(chain.owner[2], {}).setdefault((chain.file, chain.owner[3]), []).append(chain)
696
+ let_starts: dict[tuple[str, str, int, str], list[int]] = {}
697
+ for key, bound in lets.items():
698
+ bound.sort(key=lambda c: c.start)
699
+ let_starts[key] = [c.start for c in bound]
700
+ picked: dict[tuple, list[Chain]] = {}
701
+
702
+ def resolve(family: str, ref: Ref, file: str, scope: int) -> list[Chain]:
703
+ if not ref.call:
704
+ # A `let` binding: the nearest one before the use, in the same fn.
705
+ key = (family, file, scope, ref.name)
706
+ at = bisect.bisect_left(let_starts.get(key, []), ref.offset)
707
+ return [lets[key][at - 1]] if at > 0 else []
708
+ memo = (family, ref.name, ref.qualifier, file)
709
+ if memo not in picked:
710
+ picked[memo] = pick_definition(fns.get((family, ref.name), {}), ref, file)
711
+ return picked[memo]
712
+
713
+ for chain in self.chains:
714
+ family = "loco" if chain.framework == "loco_app" else chain.framework
715
+ if chain.extends is not None:
716
+ for target in resolve(family, chain.extends, chain.file, chain.scope):
717
+ if target is not chain:
718
+ self._mounted_by.setdefault(target, []).append((chain, -1, ""))
719
+ for index, item in enumerate(chain.items):
720
+ if index in chain.inline_mounts:
721
+ continue
722
+ targets: list[Chain] = []
723
+ prefix: str | None = ""
724
+ if item.kind == "mount" and isinstance(item.value, Mount) and item.value.ref:
725
+ ref, prefix = item.value.ref, item.value.prefix
726
+ targets = resolve(family, ref, chain.file, chain.scope)
727
+ elif item.kind == "configure" and isinstance(item.value, Ref):
728
+ memo = ("actix-configure", item.value.name, item.value.qualifier, chain.file)
729
+ if memo not in picked:
730
+ picked[memo] = pick_definition(roots.get(item.value.name, {}), item.value, chain.file)
731
+ targets = picked[memo]
732
+ for target in targets:
733
+ if target is not chain:
734
+ self._mounted_by.setdefault(target, []).append((chain, index, prefix))
735
+ if item.kind == "service" and isinstance(item.value, Ref) and not item.value.call:
736
+ self._services.setdefault(item.value.name, []).append((chain, index, item.value))
737
+ self._served = set()
738
+ for ref, file, scope in self.served:
739
+ for family in ("axum", "poem", "salvo", "loco"):
740
+ self._served.update(resolve(family, ref, file, scope))
741
+ self._rocket_refs.clear()
742
+ for mount in self.rocket:
743
+ refs = mount.refs
744
+ if mount.via is not None:
745
+ defs = {(f, d): r for f, d, r in self.rocket_fns.get(mount.via.name, [])}
746
+ refs = pick_definition(defs, mount.via, mount.file)
747
+ for ref in refs[:256]:
748
+ self._rocket_refs.setdefault(ref.name, []).append((mount.base, ref, mount.file))
749
+
750
+ def _layers(self, chain: Chain, index: int | None) -> tuple[tuple[Layer, ...], bool]:
751
+ """Auth layers wrapping item ``index`` and whether every layer there is
752
+ a known non-auth one: the layers after it (ordered chains) or all of
753
+ them. Suffixes are computed once per chain."""
754
+ suffix = self._suffix.get(chain)
755
+ if suffix is None:
756
+ suffix = [((), True)]
757
+ for item in reversed(chain.items):
758
+ guards, clean = suffix[-1]
759
+ if item.kind == "layer" and isinstance(item.value, Layer):
760
+ if item.value.kind == "auth":
761
+ guards = (item.value,) + guards
762
+ elif item.value.kind == "other":
763
+ clean = False
764
+ suffix.append((guards, clean))
765
+ suffix.reverse() # suffix[i]: layers in items[i:]
766
+ self._suffix[chain] = suffix
767
+ if chain.ordered and index is not None:
768
+ return suffix[min(index + 1, len(suffix) - 1)]
769
+ return suffix[0]
770
+
771
+ def _parents(self, chain: Chain) -> list[tuple[Chain, int, str | None]] | None:
772
+ """Where ``chain`` is mounted; ``[]`` for a root, ``None`` when too many."""
773
+ if chain.parent is not None:
774
+ assert chain.parent_index is not None
775
+ item = chain.parent.items[chain.parent_index]
776
+ if item.kind == "mount" and isinstance(item.value, Mount):
777
+ return [(chain.parent, chain.parent_index, item.value.prefix)]
778
+ if item.kind == "service":
779
+ return [(chain.parent, chain.parent_index, "")]
780
+ return []
781
+ refs = self._mounted_by.get(chain, [])
782
+ return None if len(refs) > _MAX_REFS else refs
783
+
784
+ def mounts(self, chain: Chain, index: int | None, depth: int = 0) -> list[MountPath]:
785
+ """``(prefix, layers)`` for item ``index`` of ``chain`` — one entry per
786
+ distinct prefix it is reachable under (the chain's own path included)."""
787
+ self._finalize()
788
+ key = (chain, index, depth)
789
+ cached = self._memo.get(key)
790
+ if cached is not None:
791
+ return cached
792
+ guards, clean = self._layers(chain, index)
793
+ local = PathState(list(guards), clean)
794
+ if chain.framework == "axum" and chain.name and chain.name in self.opaque:
795
+ local.known = False
796
+ if chain.prefix is None:
797
+ local.known = False # its own path isn't a literal
798
+ own = chain.prefix or ""
799
+ parents = self._parents(chain)
800
+ if parents is None or depth >= _MAX_DEPTH:
801
+ local.known = False
802
+ out = [(join_path("", own), local)]
803
+ elif not parents:
804
+ # A named router that nothing visible mounts may be mounted where we
805
+ # can't see (another prefix, more layers): only a router handed to a
806
+ # server, or an unnamed one, is a root.
807
+ if chain.parent is None and chain.name and chain not in self._served:
808
+ local.known = False
809
+ out = [(join_path("", own), local)]
810
+ else:
811
+ paths: list[MountPath] = []
812
+ for parent, parent_index, prefix in parents:
813
+ for outer_prefix, outer in self.mounts(parent, parent_index, depth + 1):
814
+ full = join_path(join_path(outer_prefix, prefix or ""), own)
815
+ state = _combine(local, outer)
816
+ if prefix is None:
817
+ state.known = False # mounted under a non-literal prefix
818
+ paths.append((full, state))
819
+ out = _merge(paths)
820
+ self._memo[key] = out
821
+ return out
822
+
823
+ def service_mounts(self, handler: str, file: str) -> list[MountPath]:
824
+ """Prefixes and layers for an actix attribute handler mounted with
825
+ ``.service(handler)``."""
826
+ self._finalize()
827
+ key = (handler, file)
828
+ if key not in self._service_memo:
829
+ refs = self._services.get(handler, [])
830
+ refs = self._pick_refs(refs, handler, file, lambda r: r[0].file, lambda r: r[2])
831
+ if not refs or len(refs) > _MAX_REFS:
832
+ self._service_memo[key] = [("/", PathState(known=False))]
833
+ else:
834
+ paths: list[MountPath] = []
835
+ for chain, index, _ in refs:
836
+ paths.extend(self.mounts(chain, index))
837
+ self._service_memo[key] = _merge(paths)
838
+ return self._service_memo[key]
839
+
840
+ def rocket_prefixes(self, handler: str, file: str) -> list[str]:
841
+ self._finalize()
842
+ refs = self._rocket_refs.get(handler, [])
843
+ refs = self._pick_refs(refs, handler, file, lambda r: r[2], lambda r: r[1])
844
+ if not refs:
845
+ return ["/"]
846
+ bases = [join_path("", base) if base is not None else "/" for base, _, _ in refs[:_MAX_PATHS]]
847
+ return list(dict.fromkeys(bases))
848
+
849
+ def _pick_refs(self, refs: list, handler: str, file: str, file_of, ref_of) -> list:
850
+ """The references that mean the handler defined in ``file``."""
851
+ if not refs or len(refs) > _MAX_DEFS:
852
+ return []
853
+ same = [r for r in refs if file_of(r) == file]
854
+ if same:
855
+ return same
856
+ qualified = [r for r in refs if ref_of(r).qualifier]
857
+ matching = [r for r in qualified if _module_match(file, ref_of(r).qualifier, True)]
858
+ if matching:
859
+ return matching
860
+ if len(self.attr_handlers.get(handler, ())) == 1:
861
+ return [r for r in refs if not ref_of(r).qualifier]
862
+ return []
863
+
864
+ # -- handlers --
865
+
866
+ def signature(self, handler: Ref | str | None, file: str) -> ra.Signature | None:
867
+ if not handler:
868
+ return None
869
+ ref = handler if isinstance(handler, Ref) else Ref(handler.split("::")[-1], tuple(handler.split("::")[:-1]), False, -1)
870
+ key = (ref.name, ref.qualifier, file)
871
+ if key not in self._sig_memo:
872
+ defs: dict[tuple[str, int], list[ra.Signature]] = {}
873
+ for i, found in enumerate(self.signatures.get(ref.name, [])[: _MAX_DEFS + 1]):
874
+ defs.setdefault((found.file, i), []).append(found.signature)
875
+ picked = pick_definition(defs, ref, file)
876
+ self._sig_memo[key] = picked[0] if len(picked) == 1 else None
877
+ return self.confirm(self._sig_memo[key])
878
+
879
+ def confirm(self, sig: ra.Signature | None) -> ra.Signature | None:
880
+ """Keep only extractors that are known to reject anonymous callers: a
881
+ visible ``FromRequest(Parts)`` impl that can fail, or a well-known
882
+ library auth extractor."""
883
+ if sig is None:
884
+ return None
885
+
886
+ def ok(type_name: str) -> bool:
887
+ flags = self.extractors.get(type_name)
888
+ if flags:
889
+ return all(flags)
890
+ return type_name in ra.LIBRARY_AUTH_EXTRACTORS
891
+
892
+ return ra.Signature(sig.name, [g for g in sig.guards if ok(g[0])], [g for g in sig.optional if ok(g[0])])
893
+
894
+
895
+ __all__ = [
896
+ "Chain",
897
+ "Registry",
898
+ "Ref",
899
+ "actix_chains",
900
+ "axum_like_chains",
901
+ "join_path",
902
+ "literal",
903
+ "loco_chains",
904
+ "nest_chains",
905
+ "opaque_router_names",
906
+ "parse_ref",
907
+ "rocket_mounts",
908
+ "salvo_chains",
909
+ ]