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.
- attackmap_analyzer_rust/__init__.py +3 -0
- attackmap_analyzer_rust/analyzer.py +797 -0
- attackmap_analyzer_rust/frameworks.py +163 -0
- attackmap_analyzer_rust/route_auth.py +446 -0
- attackmap_analyzer_rust/routing.py +909 -0
- attackmap_analyzer_rust-0.2.0.dist-info/METADATA +115 -0
- attackmap_analyzer_rust-0.2.0.dist-info/RECORD +11 -0
- attackmap_analyzer_rust-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_rust-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_rust-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_rust-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|