attackmap-analyzer-swift 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,1195 @@
1
+ """Server-side Swift routes (#187): Vapor 4 and Hummingbird 1.x/2.x.
2
+
3
+ Each route carries ``auth`` (``required`` / ``unknown``), the ``guards`` that
4
+ apply and the ``guard_evidence`` that established them (AttackMap#256). Only
5
+ what the source verifiably says is declared:
6
+
7
+ - A route is a verb call (``get``/``post``/..., ``on``, ``webSocket``/``ws``)
8
+ with a handler (``use:`` or a trailing closure) whose receiver resolves to a
9
+ router: ``app`` / ``app.routes`` / ``app.router``, ``Router(...)``,
10
+ ``RouteCollection(...)``, a parameter typed ``Application`` /
11
+ ``RoutesBuilder`` / ``Router`` / ``RouterGroup`` / ``RouterMethods``, a
12
+ variable bound to one, or the parameter (or ``$0``) of a
13
+ ``group(...) { ... }`` closure. Anything else (``parameters.get("id")``,
14
+ ``cache.get(...)``) is not a route.
15
+ - Prefixes come from ``grouped("api", "v1")`` and ``group("x")`` (closure or
16
+ chained), and from where a ``RouteCollection`` (Vapor
17
+ ``register(collection:)``, Hummingbird ``addRoutes(_:atPath:)``) or a
18
+ controller (``addRoutes(to:)``) is mounted, when every mount in the same
19
+ Swift package agrees. An unknown mount is kept as ``$(TypeName)`` and a
20
+ computed path (``SiteURL.faq.pathComponents``) as ``$(SiteURL.faq)``.
21
+ - ``required`` needs an enforcing guard: Vapor's ``X.guardMiddleware()`` /
22
+ ``X.redirectMiddleware(...)``, Hummingbird's ``IsAuthenticatedMiddleware``,
23
+ or a handler that calls ``req.auth.require(...)`` /
24
+ ``context.requireIdentity()`` or opens with
25
+ ``guard let user = context.identity else { throw HTTPError(.unauthorized) }``.
26
+ An authenticator alone (``UserAuthenticator()``, ``SessionAuthenticator``)
27
+ only identifies a caller who sent credentials, so the route stays
28
+ ``unknown``.
29
+ - Hummingbird ``add(middleware:)`` / ``addMiddleware { }`` apply to routes
30
+ added after them on that router or group, in source order. Middleware added
31
+ under ``if`` / ``#if`` (or in a closure other than a ``group { }``) may not
32
+ run, so it never makes a route ``required``. A handler's identity check
33
+ counts only as a top-level statement (plain ``try``, not inside ``#if``,
34
+ not continued from the previous line) that comes before anything in the
35
+ handler that can ``return``, ``throw`` (incl. ``try``) or ``guard``.
36
+ - A ``use:`` handler is resolved in the type that registers it (or the type
37
+ named, ``use: Controller.show``); an ambiguous name leaves auth unknown. A
38
+ mount whose type can't be resolved (``register(collection: items[0])``), or
39
+ more than 16 mounts, leaves every mount in that package unknown.
40
+
41
+ Linear per file: brackets are matched once (``swiftsrc.parse``), receiver
42
+ chains are bounded, and router resolution is memoized with a depth limit.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import bisect
48
+ import posixpath
49
+ import re
50
+ from dataclasses import dataclass, field
51
+
52
+ from attackmap.sdk import line_of
53
+
54
+ from . import swiftsrc
55
+ from .swiftsrc import Source, arg_label, clip, split_args, string_value
56
+
57
+ REQUIRED = "required"
58
+ UNKNOWN = "unknown"
59
+
60
+ _VERBS = {
61
+ "get": "GET", "post": "POST", "put": "PUT", "patch": "PATCH", "delete": "DELETE",
62
+ "head": "HEAD", "options": "OPTIONS", "on": None, "webSocket": "GET", "ws": "GET",
63
+ }
64
+ _HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
65
+ _ROUTE_CALL = re.compile(r"\.[ \t]*(get|post|put|patch|delete|head|options|on|webSocket|ws)[ \t]*(?=[({])")
66
+ _IMPORT = re.compile(r"^[ \t]*(?:@\w+[ \t]+)*import[ \t]+(Vapor|Hummingbird\w*)\b", re.MULTILINE)
67
+ _IDENT = re.compile(r"\$\d+|[A-Za-z_]\w*")
68
+ _IDENT_TAIL = re.compile(r"[\w$]+$")
69
+ _BINDING = re.compile(r"\b(?:let|var)[ \t]+([A-Za-z_]\w*)[ \t]*(?::[^=\n{]{1,80})?=[ \t]*")
70
+ _PARAM_ROUTER_TYPE = re.compile(
71
+ r"([A-Za-z_]\w*)[ \t]*:[ \t]*(?:inout[ \t]+)?(?:some[ \t]+|any[ \t]+)?(?:Vapor\.|Hummingbird\.)?"
72
+ r"(Application|RoutesBuilder|HBApplication|HBRouterBuilder|HBRouterGroup|Router|RouterGroup|RouterMethods)\b"
73
+ )
74
+ _FUNC = re.compile(r"\bfunc[ \t]+([A-Za-z_]\w*)[ \t]*(?:<[^>\n]{0,200}>)?[ \t]*\(")
75
+ _TYPE_DECL = re.compile(r"\b(?:struct|class|extension|enum|actor)[ \t]+([A-Za-z_][\w.]*)")
76
+ # `{ r in`, `{ [weak self] (r) in`, `{ r, _ in`: parsed from a bounded window
77
+ # after the brace (see `_closure_param`), so hostile whitespace stays linear.
78
+ _CLOSURE_HEAD = re.compile(r"(?:\[[^\]\n]{0,80}\][ \t]*)?\(?[ \t]*([A-Za-z_]\w{0,60})[ \t]*(?:,[^)\n{]{0,80})?\)?")
79
+ _CLOSURE_WINDOW = 240
80
+ _REGISTER = re.compile(r"\.[ \t]*register[ \t]*\([ \t]*collection[ \t]*:")
81
+ _ADD_ROUTES = re.compile(r"\.[ \t]*addRoutes[ \t]*\(")
82
+ _MUTATION = re.compile(r"\.[ \t]*(?:add[ \t]*\([ \t]*middleware[ \t]*:|addMiddleware[ \t]*\{)")
83
+ _GROUP_CLOSURE = re.compile(r"\.[ \t]*group[ \t]*\(")
84
+ _CTOR = re.compile(r"\s*([A-Z]\w*)\s*(?:<[^>\n]{0,120}>)?\s*\(")
85
+ _METHOD = re.compile(r"\s*(?:HTTPMethod|HTTPRequest\.Method)?\.([A-Za-z]+)\s*")
86
+ _PATH_PARAM_CTOR = re.compile(r'(?:PathComponent)?\.(parameter|constant)\(\s*"([^"\\\n]{1,80})"\s*\)')
87
+ # Handler bodies that enforce authentication themselves.
88
+ _HANDLER_GUARD = re.compile(
89
+ r"\bauth[ \t]*\.[ \t]*require[ \t]*\("
90
+ r"|\brequireIdentity[ \t]*\("
91
+ # A `guard ... else { throw HTTPError(.unauthorized) }`; its condition is
92
+ # checked by `_strict_identity_condition` (only a plain binding of, or a
93
+ # nil test on, the request's identity counts).
94
+ r"|\bguard\b(?P<cond>[^{\n]{1,240}?)\belse[ \t]*\{[ \t\r\n]*throw[ \t]+(?:HTTPError|Abort)[ \t]*\([ \t]*\.unauthorized"
95
+ )
96
+ # The caller's identity: `context.identity`, `req.auth.get(User.self)`, ...
97
+ _IDENTITY_REF = (
98
+ r"(?:context|req|request)[ \t]*\.[ \t]*"
99
+ r"(?:identity|currentUser|user|auth[ \t]*\.[ \t]*get[ \t]*\([ \t]*[\w.]{1,80}[ \t]*\))"
100
+ )
101
+ # The whole first clause of the guard: `let u = <ref>` or `<ref> != nil`
102
+ # (optionally `&& <ref>.<member>`).
103
+ _IDENTITY_CLAUSE = re.compile(
104
+ r"[ \t]*(?:(?:let|var)[ \t]+\w{1,60}(?:[ \t]*:[ \t]*[\w.<>]{1,60}[?]?)?[ \t]*=[ \t]*" + _IDENTITY_REF
105
+ + r"|" + _IDENTITY_REF + r"[ \t]*!=[ \t]*nil(?:[ \t]*&&[ \t]*" + _IDENTITY_REF + r"(?:[ \t]*\.[ \t]*\w{1,60}){1,4})?)[ \t]*"
106
+ )
107
+ _WEAKENING = re.compile(r"\|\||\?\?|==[ \t]*nil|!(?!=)")
108
+
109
+
110
+ def _strict_identity_condition(condition: str) -> bool:
111
+ """`guard let user = context.identity, let id = user.id` -> True;
112
+ anything with `||`, `??`, `== nil` or a `!` (negation / force unwrap),
113
+ or whose first clause isn't the identity itself, -> False."""
114
+ if _WEAKENING.search(condition):
115
+ return False
116
+ first = condition.split(",", 1)[0]
117
+ return _IDENTITY_CLAUSE.fullmatch(first) is not None
118
+
119
+ _MAX_CHAIN = 24
120
+ _MAX_DEPTH = 8
121
+ _MAX_MOUNTS = 16
122
+ _PATH_EXPR_MAX = 60
123
+
124
+ # Router-ish property names that don't change the scope (`app.routes`, `app.router`).
125
+ _TRANSPARENT = {"routes", "router", "self"}
126
+ _ROOT_CTORS = {"Router", "HBRouter", "HBRouterBuilder", "HBApplication"}
127
+ _COLLECTION_CTORS = {"RouteCollection"}
128
+
129
+ _GUARD_MEMBER = re.compile(r"\.[ \t]*(?:guardMiddleware|redirectMiddleware)[ \t]*\(")
130
+ _GUARD_TYPES = re.compile(r"\bIsAuthenticatedMiddleware\b|\b\w*Authoriz\w*Middleware\b")
131
+ _AUTHENTICATOR = re.compile(r"(?i)authenticat")
132
+ # Role / permission checks in a handler body (Swift idioms), reported as guards
133
+ # so core's function-level authorization check (BFLA) sees them.
134
+ _HANDLER_ROLE = re.compile(
135
+ r"\.(?:roles?|permissions?)[ \t]*\.[ \t]*contains[ \t]*\("
136
+ r"|\bisAdmin\b|\bhasRole[ \t]*\(|\bhasPermission[ \t]*\(|\bRolePolicy[ \t]*\(|\bPermissionPolicy[ \t]*\("
137
+ )
138
+ _PP_DIRECTIVE = re.compile(r"^[ \t]*#(if|endif)\b", re.MULTILINE)
139
+ _MIDDLEWARE_NAME = re.compile(r"(?i)middleware|auth|guard|session|token|jwt|cors|limit|log|error|file")
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class Guard:
144
+ name: str
145
+ enforcing: bool # False for an authenticator that only identifies the caller
146
+ evidence: str
147
+ # Added under `if` / `#if` / a non-group closure: it may not apply, so it
148
+ # never makes a route `required` on its own.
149
+ conditional: bool = False
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class Scope:
154
+ """A router value: its path prefix and the middleware that applies to
155
+ routes added on it."""
156
+
157
+ prefix: tuple[str, ...] = ()
158
+ guards: tuple[Guard, ...] = ()
159
+
160
+
161
+ @dataclass
162
+ class RouteHit:
163
+ path: str
164
+ method: str
165
+ line: int
166
+ auth: str = UNKNOWN
167
+ guards: list[str] = field(default_factory=list)
168
+ evidence: str | None = None
169
+
170
+
171
+ @dataclass
172
+ class _Seg:
173
+ name: str
174
+ start: int
175
+ args: tuple[int, int] | None = None # bracket offsets of a call's argument list
176
+ closure: tuple[int, int] | None = None # a trailing closure's braces
177
+
178
+
179
+ # ---------- Path components ----------
180
+
181
+ def _component(text: str) -> list[str] | None:
182
+ """Path components for one argument; None when it isn't a path."""
183
+ value = string_value(text)
184
+ if value is not None:
185
+ return [_normalize_segment(part) for part in value.split("/") if part]
186
+ stripped = " ".join(text.split())
187
+ if stripped in (".catchall", "PathComponent.catchall"):
188
+ return ["**"]
189
+ if stripped in (".anything", "PathComponent.anything"):
190
+ return ["*"]
191
+ m = _PATH_PARAM_CTOR.fullmatch(stripped)
192
+ if m:
193
+ return [f"{{{m.group(2)}}}" if m.group(1) == "parameter" else m.group(2)]
194
+ if stripped.endswith((".pathComponents", ".pathComponent")):
195
+ return [_computed(stripped.rsplit(".", 1)[0])]
196
+ return None
197
+
198
+
199
+ def _computed(expr: str) -> str:
200
+ return f"$({' '.join(expr.split())[:_PATH_EXPR_MAX]})"
201
+
202
+
203
+ def _normalize_segment(part: str) -> str:
204
+ if part.startswith(":") and len(part) > 1:
205
+ return "{" + part[1:] + "}"
206
+ return part
207
+
208
+
209
+ def _is_middleware_expr(text: str) -> bool:
210
+ stripped = text.strip()
211
+ return "(" in stripped or bool(_MIDDLEWARE_NAME.search(stripped))
212
+
213
+
214
+ def _guard_for(text: str, evidence: str) -> Guard:
215
+ enforcing = bool(_GUARD_MEMBER.search(text) or _GUARD_TYPES.search(text))
216
+ return Guard(_guard_name(text), enforcing, evidence)
217
+
218
+
219
+ def _guard_name(text: str) -> str:
220
+ """The middleware expression, without a trailing closure's body:
221
+ `BasicAuthenticator { username, _ in ... }` -> `BasicAuthenticator`."""
222
+ flat = " ".join(text.split())
223
+ brace = flat.find("{")
224
+ if brace > 0:
225
+ head = flat[:brace].strip()
226
+ if head and head.count("(") == head.count(")"):
227
+ flat = head
228
+ return clip(flat, 80)
229
+
230
+
231
+ def _closure_guards(body: str, evidence: str) -> tuple[Guard, ...]:
232
+ """One guard per middleware listed in an `addMiddleware { ... }` builder:
233
+ each top-level line, extended over the brackets it opens."""
234
+ src = swiftsrc.parse(body)
235
+ end = len(body) - 1 if body.endswith("}") else len(body)
236
+ pos = 1 if body.startswith("{") else 0
237
+ guards: list[Guard] = []
238
+ for _ in range(64):
239
+ while pos < end and body[pos].isspace():
240
+ pos += 1
241
+ if pos >= end:
242
+ break
243
+ start = pos
244
+ nl = body.find("\n", pos, end)
245
+ stop = end if nl < 0 else nl
246
+ k = pos
247
+ while k < stop:
248
+ ch = body[k]
249
+ if ch in "({[":
250
+ close = src.close_of(k)
251
+ if close < 0 or close >= end:
252
+ k = stop
253
+ break
254
+ k = close + 1
255
+ if k > stop:
256
+ nl = body.find("\n", k, end)
257
+ stop = end if nl < 0 else nl
258
+ continue
259
+ k += 1
260
+ item = body[start:k].strip()
261
+ if item and not item.startswith("//"):
262
+ guards.append(_guard_for(item, evidence))
263
+ pos = max(k, start + 1)
264
+ return tuple(guards) or (_guard_for(body, evidence),)
265
+
266
+
267
+ def _method(value: str) -> str | None:
268
+ m = _METHOD.fullmatch(value)
269
+ if m is None:
270
+ return None
271
+ name = m.group(1).upper()
272
+ return name if name in _HTTP_METHODS else None
273
+
274
+
275
+ # ---------- Per-file model ----------
276
+
277
+ class _SpanIndex:
278
+ """Innermost-enclosing lookup over properly nested (open, close, payload)
279
+ spans. A sweep over the open/close events records which span is innermost
280
+ after each event, so a lookup is one bisect, at any nesting depth."""
281
+
282
+ def __init__(self, spans) -> None:
283
+ self.spans = sorted(spans, key=lambda s: (s[0], -s[1]))
284
+ events: list[tuple[int, int, int]] = [] # (position, order, span index or -1 for a close)
285
+ for i, span in enumerate(self.spans):
286
+ events.append((span[0], 1, i))
287
+ events.append((span[1], 0, -1))
288
+ events.sort()
289
+ self.positions: list[int] = []
290
+ self.innermost_after: list[int] = []
291
+ stack: list[int] = []
292
+ for position, _order, index in events:
293
+ if index >= 0:
294
+ stack.append(index)
295
+ elif stack:
296
+ stack.pop()
297
+ self.positions.append(position)
298
+ self.innermost_after.append(stack[-1] if stack else -1)
299
+
300
+ def innermost(self, offset: int):
301
+ k = bisect.bisect_left(self.positions, offset) - 1
302
+ if k < 0:
303
+ return None
304
+ i = self.innermost_after[k]
305
+ if i < 0:
306
+ return None
307
+ span = self.spans[i]
308
+ return span if span[0] < offset < span[1] else None
309
+
310
+
311
+ # Guards a route reports by name; past this many middleware statements only
312
+ # the enforcing ones are added (the auth decision never depends on the rest).
313
+ _MAX_LISTED_GUARDS = 16
314
+
315
+
316
+ class _MutationRun:
317
+ """The `x.add(middleware:)` statements on one router name in one
318
+ function, in source order, with each position's next unconditional
319
+ enforcing guard precomputed so a lookup is two bisects."""
320
+
321
+ def __init__(self, items: list[tuple[int, Guard]]) -> None:
322
+ items.sort(key=lambda item: item[0])
323
+ self.offsets = [at for at, _ in items]
324
+ self.guards = [g for _, g in items]
325
+ self.next_enforcing = [len(items)] * (len(items) + 1)
326
+ for i in range(len(items) - 1, -1, -1):
327
+ g = self.guards[i]
328
+ self.next_enforcing[i] = i if (g.enforcing and not g.conditional) else self.next_enforcing[i + 1]
329
+
330
+ def between(self, since: int, before: int) -> list[Guard]:
331
+ lo = bisect.bisect_right(self.offsets, since)
332
+ hi = bisect.bisect_left(self.offsets, before)
333
+ if lo >= hi:
334
+ return []
335
+ listed = self.guards[lo:min(hi, lo + _MAX_LISTED_GUARDS)]
336
+ k = self.next_enforcing[lo + len(listed)] if lo + len(listed) < hi else hi
337
+ if k < hi:
338
+ listed.append(self.guards[k])
339
+ return listed
340
+
341
+
342
+ def _closure_param(text: str, brace: int, close: int) -> str:
343
+ """The parameter of a `{ r in ...}` closure, else `$0`."""
344
+ end = min(close, brace + _CLOSURE_WINDOW)
345
+ pos = brace + 1
346
+ while pos < end and text[pos].isspace():
347
+ pos += 1
348
+ m = _CLOSURE_HEAD.match(text, pos, end)
349
+ if m is None:
350
+ return "$0"
351
+ k = m.end()
352
+ while k < end and text[k] in " \t":
353
+ k += 1
354
+ if not text.startswith("in", k) or k + 2 > end or (k + 2 < len(text) and (text[k + 2].isalnum() or text[k + 2] == "_")):
355
+ return "$0"
356
+ return m.group(1)
357
+
358
+
359
+ @dataclass
360
+ class _Closure:
361
+ open: int
362
+ close: int
363
+ param: str
364
+ call_dot: int # the `.group(` dot
365
+ args: tuple[int, int]
366
+
367
+
368
+ class SwiftFile:
369
+ def __init__(self, rel: str, text: str, package: str = "") -> None:
370
+ self.rel = rel
371
+ self.text = text
372
+ self.package = package
373
+ imports = {m.group(1) for m in _IMPORT.finditer(text)}
374
+ self.vapor = "Vapor" in imports
375
+ self.hummingbird = any(i.startswith("Hummingbird") for i in imports)
376
+ self.closures: list[_Closure] = []
377
+ self.bindings: dict[str, list[int]] = {}
378
+ self.params: dict[str, list[tuple[int, int, str]]] = {} # name -> (body_open, body_close, type)
379
+ self.functions: dict[str, list[tuple[int, int]]] = {}
380
+ self.types: list[tuple[int, int, str]] = []
381
+ self.mutations: dict[str, list[tuple[int, Guard]]] = {}
382
+ self.handler_cache: dict[tuple[int, int], tuple[tuple[str, str] | None, str | None]] = {}
383
+ self.expression_end_cache: dict[int, int] = {}
384
+ self.scope_cache: dict[tuple[str, int], Scope | None] = {}
385
+ self.group_bodies: set[tuple[int, int]] = set()
386
+ self.pp_if: list[int] = []
387
+ self.pp_endif: list[int] = []
388
+ self.function_index = _SpanIndex([])
389
+ self.type_index = _SpanIndex([])
390
+ self.block_index = _SpanIndex([])
391
+ self.paren_index = _SpanIndex([])
392
+ self.closures_by_param: dict[str, _SpanIndex] = {}
393
+ self.functions_by_owner: dict[tuple[str | None, str], list[tuple[int, int]]] = {}
394
+ self.mutation_runs: dict[tuple[str, int], _MutationRun] = {}
395
+ self.src: Source = swiftsrc.parse(text) if self.is_server else Source(text)
396
+ if self.is_server:
397
+ self._index()
398
+
399
+ @property
400
+ def is_server(self) -> bool:
401
+ return self.vapor or self.hummingbird
402
+
403
+ def code(self, offset: int) -> bool:
404
+ return not self.src.in_masked(offset)
405
+
406
+ def _index(self) -> None:
407
+ text, src = self.text, self.src
408
+ for m in _FUNC.finditer(text):
409
+ if not self.code(m.start()):
410
+ continue
411
+ paren = m.end() - 1
412
+ close = src.close_of(paren)
413
+ if close < 0:
414
+ continue
415
+ body = text.find("{", close, close + 400)
416
+ body_close = src.close_of(body) if body >= 0 else -1
417
+ if body_close < 0:
418
+ continue
419
+ self.functions.setdefault(m.group(1), []).append((body, body_close))
420
+ for p in _PARAM_ROUTER_TYPE.finditer(text, paren, close + 1):
421
+ self.params.setdefault(p.group(1), []).append((body, body_close, p.group(2)))
422
+ for m in _TYPE_DECL.finditer(text):
423
+ if not self.code(m.start()):
424
+ continue
425
+ body = text.find("{", m.end(), m.end() + 600)
426
+ close = src.close_of(body) if body >= 0 else -1
427
+ if close > 0:
428
+ self.types.append((body, close, m.group(1).rsplit(".", 1)[-1]))
429
+ for m in _BINDING.finditer(text):
430
+ if self.code(m.start()):
431
+ self.bindings.setdefault(m.group(1), []).append(m.end())
432
+ for m in _GROUP_CLOSURE.finditer(text):
433
+ if not self.code(m.start()):
434
+ continue
435
+ paren = m.end() - 1
436
+ close = src.close_of(paren)
437
+ if close < 0:
438
+ continue
439
+ brace = _next_code_char(text, close + 1)
440
+ if brace < 0 or text[brace] != "{":
441
+ continue
442
+ body_close = src.close_of(brace)
443
+ if body_close < 0:
444
+ continue
445
+ param = _closure_param(text, brace, body_close)
446
+ self.closures.append(_Closure(brace, body_close, param, m.start(), (paren, close)))
447
+ self.group_bodies.add((brace, body_close))
448
+ self.function_index = _SpanIndex([(o, c, n) for n, spans in self.functions.items() for o, c in spans])
449
+ self.type_index = _SpanIndex(self.types)
450
+ self.block_index = _SpanIndex([(o, c, "") for o, c in src.pairs.items() if text[o] == "{"])
451
+ self.paren_index = _SpanIndex([(o, c, "") for o, c in src.pairs.items() if text[o] in "(["])
452
+ by_param: dict[str, list[tuple[int, int, _Closure]]] = {}
453
+ for c in self.closures:
454
+ by_param.setdefault(c.param, []).append((c.open, c.close, c))
455
+ self.closures_by_param = {name: _SpanIndex(spans) for name, spans in by_param.items()}
456
+ # (declaring type or None, func name) -> body spans, for `use:` handlers.
457
+ for name, spans in self.functions.items():
458
+ for span in spans:
459
+ declared = self.type_index.innermost(span[0])
460
+ self.functions_by_owner.setdefault((declared[2] if declared else None, name), []).append(span)
461
+ for m in _PP_DIRECTIVE.finditer(text):
462
+ (self.pp_endif if m.group(1) == "endif" else self.pp_if).append(m.start())
463
+ grouped: dict[tuple[str, int], list[tuple[int, Guard]]] = {}
464
+ for m in _MUTATION.finditer(text):
465
+ if not self.code(m.start()):
466
+ continue
467
+ start, chain = receiver_chain(self, m.start())
468
+ # Only a call on the router itself (`router.add(middleware:)`, or later in
469
+ # a chain of `add`/verb calls, which return the same group) mutates it.
470
+ if not chain or chain[0].args is not None:
471
+ continue
472
+ if any(s.args is not None and s.name not in _VERBS and s.name != "add" for s in chain[1:]):
473
+ continue
474
+ if any(s.closure is not None and s.args is None and s.name != "addMiddleware" for s in chain[1:]):
475
+ continue
476
+ conditional = self.is_conditional(m.start())
477
+ function = self.function_index.innermost(m.start())
478
+ for guard in self._mutation_guard(m.start(), start) or ():
479
+ if conditional:
480
+ guard = Guard(guard.name, guard.enforcing, guard.evidence, conditional=True)
481
+ self.mutations.setdefault(chain[0].name, []).append((m.start(), guard))
482
+ grouped.setdefault((chain[0].name, function[0] if function is not None else -1), []).append(
483
+ (m.start(), guard)
484
+ )
485
+ self.mutation_runs = {key: _MutationRun(items) for key, items in grouped.items()}
486
+
487
+ def _mutation_guard(self, dot: int, start: int) -> tuple[Guard, ...] | None:
488
+ text, src = self.text, self.src
489
+ opener = text.find("(", dot, dot + 40)
490
+ brace = text.find("{", dot, dot + 40)
491
+ if "addMiddleware" in text[dot:dot + 20] and brace >= 0:
492
+ close = src.close_of(brace)
493
+ if close < 0:
494
+ return None
495
+ return _closure_guards(text[brace:close + 1], clip(text[start:close + 1]))
496
+ close = src.close_of(opener)
497
+ if close < 0:
498
+ return None
499
+ args = split_args(src, opener, close)
500
+ if not args:
501
+ return None
502
+ _, value = arg_label(text[args[0][0]:args[0][1]])
503
+ return (_guard_for(value, clip(text[start:close + 1])),)
504
+
505
+ def enclosing_type(self, offset: int) -> tuple[int, int, str] | None:
506
+ found = self.type_index.innermost(offset)
507
+ return found # type: ignore[return-value]
508
+
509
+ def enclosing_function(self, offset: int) -> tuple[int, int] | None:
510
+ found = self.function_index.innermost(offset)
511
+ return (found[0], found[1]) if found is not None else None
512
+
513
+ def is_conditional(self, offset: int) -> bool:
514
+ """True when ``offset`` sits in a block that may not run: inside an
515
+ `if`/`for`/other closure body rather than directly in a function or a
516
+ `group { }` closure, or between `#if` and `#endif`."""
517
+ block = self.block_index.innermost(offset)
518
+ function = self.function_index.innermost(offset)
519
+ if block is not None and (function is None or block[0] != function[0]):
520
+ if (block[0], block[1]) not in self.group_bodies:
521
+ return True
522
+ start = function[0] if function is not None else 0
523
+ opened = bisect.bisect_left(self.pp_if, offset) - bisect.bisect_left(self.pp_if, start)
524
+ closed = bisect.bisect_left(self.pp_endif, offset) - bisect.bisect_left(self.pp_endif, start)
525
+ return opened > closed
526
+
527
+ def mutation_run(self, name: str, offset: int) -> _MutationRun | None:
528
+ function = self.function_index.innermost(offset)
529
+ return self.mutation_runs.get((name, function[0] if function is not None else -1))
530
+
531
+ def function_body(self, name: str, near: int, type_name: str | None = None) -> tuple[int, int] | None:
532
+ """Body of ``func name`` declared in ``type_name`` (default: the type
533
+ enclosing ``near``, or top level), across its extensions in this file.
534
+ None unless exactly one candidate: an ambiguous handler stays unseen."""
535
+ if type_name is None:
536
+ owner = self.enclosing_type(near)
537
+ type_name = owner[2] if owner is not None else None
538
+ candidates = self.functions_by_owner.get((type_name, name), ())
539
+ return candidates[0] if len(candidates) == 1 else None
540
+
541
+
542
+ def _next_code_char(text: str, pos: int, limit: int = 200) -> int:
543
+ end = min(len(text), pos + limit)
544
+ while pos < end:
545
+ if not text[pos].isspace():
546
+ return pos
547
+ pos += 1
548
+ return -1
549
+
550
+
551
+ def chain_start(f: SwiftFile, dot: int) -> int:
552
+ """Offset where the receiver chain ending at ``dot`` begins (bounded)."""
553
+ text, src = f.text, f.src
554
+ pos = dot
555
+ for _ in range(_MAX_CHAIN):
556
+ j = pos - 1
557
+ while j >= 0 and text[j].isspace():
558
+ j -= 1
559
+ while j >= 0 and text[j] in "?!":
560
+ j -= 1
561
+ if j < 0 or src.in_masked(j):
562
+ return pos
563
+ if text[j] == "}": # a trailing closure: `.post("x") { ... }.get(...)`
564
+ open_ = src.open_of(j)
565
+ if open_ < 0:
566
+ return pos
567
+ j = open_ - 1
568
+ while j >= 0 and text[j].isspace():
569
+ j -= 1
570
+ if j < 0:
571
+ return pos
572
+ if text[j] == ")":
573
+ open_ = src.open_of(j)
574
+ if open_ < 0:
575
+ return pos
576
+ j = open_ - 1
577
+ while j >= 0 and text[j] in " \t":
578
+ j -= 1
579
+ if j >= 0 and text[j] == ">":
580
+ open_ = text.rfind("<", max(0, j - 120), j)
581
+ if open_ < 0:
582
+ return pos
583
+ j = open_ - 1
584
+ m = _IDENT_TAIL.search(text, max(0, j - 80), j + 1) if j >= 0 else None
585
+ if m is None or m.end() != j + 1:
586
+ return pos
587
+ start = m.start()
588
+ k = start - 1
589
+ while k >= 0 and text[k].isspace():
590
+ k -= 1
591
+ if k >= 0 and text[k] in "?!":
592
+ k -= 1
593
+ if k >= 0 and text[k] == "." and not src.in_masked(k):
594
+ pos = k
595
+ continue
596
+ return start
597
+ return pos
598
+
599
+
600
+ def parse_chain(f: SwiftFile, start: int, stop: int) -> list[_Seg] | None:
601
+ """Segments of the chain expression ``text[start:stop]``: names, calls and
602
+ trailing closures."""
603
+ text, src = f.text, f.src
604
+ segs: list[_Seg] = []
605
+ pos = start
606
+ for _ in range(_MAX_CHAIN):
607
+ while pos < stop and text[pos].isspace():
608
+ pos += 1
609
+ if pos >= stop:
610
+ break
611
+ m = _IDENT.match(text, pos)
612
+ if m is None or m.end() > stop:
613
+ return None
614
+ seg = _Seg(m.group(0), m.start())
615
+ pos = m.end()
616
+ if pos < stop and text[pos] == "<":
617
+ close = text.find(">", pos, min(stop, pos + 120))
618
+ if close < 0:
619
+ return None
620
+ pos = close + 1
621
+ while pos < stop and text[pos] in " \t":
622
+ pos += 1
623
+ if pos < stop and text[pos] == "(":
624
+ close = src.close_of(pos)
625
+ if close < 0 or close >= stop:
626
+ return None
627
+ seg.args = (pos, close)
628
+ pos = close + 1
629
+ brace = _next_code_char(text, pos)
630
+ if 0 <= brace < stop and text[brace] == "{":
631
+ close = src.close_of(brace)
632
+ if close < 0 or close >= stop:
633
+ return None
634
+ seg.closure = (brace, close)
635
+ pos = close + 1
636
+ segs.append(seg)
637
+ while pos < stop and (text[pos].isspace() or text[pos] in "?!"):
638
+ pos += 1
639
+ if pos < stop and text[pos] == ".":
640
+ pos += 1
641
+ continue
642
+ if pos < stop:
643
+ return None
644
+ return segs or None
645
+
646
+
647
+ def receiver_chain(f: SwiftFile, dot: int) -> tuple[int, list[_Seg] | None]:
648
+ start = chain_start(f, dot)
649
+ return start, parse_chain(f, start, dot)
650
+
651
+
652
+ # ---------- Resolution ----------
653
+
654
+ class RouteResolver:
655
+ """Resolves routers across the repo's server files (memoized, bounded)."""
656
+
657
+ def __init__(self, files: list[SwiftFile]) -> None:
658
+ self.files = [f for f in files if f.is_server]
659
+ # (package, type name) -> mount sites: (kind, file, offset or arg span, atPath prefix)
660
+ self._mounts: dict[tuple[str, str], list[tuple[str, SwiftFile, object, tuple[str, ...]]]] | None = None
661
+ self._type_scope: dict[tuple[str, str], Scope | None] = {}
662
+ self._resolving: set[tuple[str, str]] = set()
663
+ # Packages with a mount whose type can't be resolved.
664
+ self.unresolved_packages: set[str] = set()
665
+
666
+ def mounts(self) -> dict[tuple[str, str], list[tuple[str, SwiftFile, object, tuple[str, ...]]]]:
667
+ if self._mounts is not None:
668
+ return self._mounts
669
+ mounts: dict[tuple[str, str], list[tuple[str, SwiftFile, object, tuple[str, ...]]]] = {}
670
+ for f in self.files:
671
+ text = f.text
672
+ for m in _REGISTER.finditer(text):
673
+ if not f.code(m.start()):
674
+ continue
675
+ args = self._call_args(f, m.start())
676
+ if not args:
677
+ self.unresolved_packages.add(f.package)
678
+ continue
679
+ start, end = args[0]
680
+ _, value = arg_label(text[start:end])
681
+ start = end - len(value) - (len(text[start:end]) - len(text[start:end].rstrip()))
682
+ chain = parse_chain(f, start, end)
683
+ type_name = self._type_of(f, chain[0], m.start()) if chain and len(chain) == 1 else None
684
+ if type_name is None:
685
+ # `register(collection: controllers[i])`: some type is mounted
686
+ # somewhere we can't see, so no mount in this package is certain.
687
+ self.unresolved_packages.add(f.package)
688
+ continue
689
+ mounts.setdefault((f.package, type_name), []).append(("receiver", f, m.start(), ()))
690
+ for m in _ADD_ROUTES.finditer(text):
691
+ if not f.code(m.start()):
692
+ continue
693
+ args = self._call_args(f, m.start())
694
+ if not args:
695
+ continue
696
+ label, value = arg_label(text[args[0][0]:args[0][1]])
697
+ if label == "to":
698
+ # Controller(...).addRoutes(to: router.group("x"))
699
+ type_name = self._controller_type(f, m.start())
700
+ if type_name is None:
701
+ self.unresolved_packages.add(f.package)
702
+ continue
703
+ mounts.setdefault((f.package, type_name), []).append(("expr", f, args[0], ()))
704
+ elif label is None:
705
+ # router.addRoutes(Controller(...).endpoints, atPath: "/x")
706
+ type_name = self._collection_type(f, args[0])
707
+ if type_name is None:
708
+ self.unresolved_packages.add(f.package)
709
+ continue
710
+ prefix: tuple[str, ...] = ()
711
+ for start, end in args[1:]:
712
+ alabel, avalue = arg_label(text[start:end])
713
+ if alabel == "atPath":
714
+ comps = _component(avalue)
715
+ prefix = tuple(comps) if comps is not None else (_computed(avalue),)
716
+ mounts.setdefault((f.package, type_name), []).append(("receiver", f, m.start(), prefix))
717
+ self._mounts = mounts
718
+ return mounts
719
+
720
+ @staticmethod
721
+ def _call_args(f: SwiftFile, dot: int) -> list[tuple[int, int]]:
722
+ paren = f.text.find("(", dot, dot + 40)
723
+ close = f.src.close_of(paren) if paren >= 0 else -1
724
+ return split_args(f.src, paren, close) if close >= 0 else []
725
+
726
+ def _controller_type(self, f: SwiftFile, dot: int) -> str | None:
727
+ _, chain = receiver_chain(f, dot)
728
+ if not chain:
729
+ return None
730
+ return self._type_of(f, chain[0], dot) if len(chain) == 1 else None
731
+
732
+ def _collection_type(self, f: SwiftFile, span: tuple[int, int]) -> str | None:
733
+ start, end = span
734
+ while start < end and f.text[start].isspace():
735
+ start += 1
736
+ chain = parse_chain(f, start, end)
737
+ if not chain or len(chain) > 3:
738
+ return None
739
+ return self._type_of(f, chain[0], start)
740
+
741
+ @staticmethod
742
+ def _type_of(f: SwiftFile, seg: _Seg, offset: int) -> str | None:
743
+ if seg.args is not None and seg.name[:1].isupper():
744
+ return seg.name
745
+ if seg.args is None:
746
+ binding = _nearest(f.bindings.get(seg.name, ()), offset)
747
+ if binding is not None:
748
+ ctor = _CTOR.match(f.text, binding)
749
+ if ctor:
750
+ return ctor.group(1)
751
+ return None
752
+
753
+ def type_scope(self, f: SwiftFile, offset: int, depth: int) -> Scope:
754
+ """Scope the type enclosing ``offset`` is mounted at, when every mount
755
+ in the same package agrees; else ``$(TypeName)``."""
756
+ owner = f.enclosing_type(offset)
757
+ if owner is None:
758
+ return Scope(("$(mount)",))
759
+ key = (f.package, owner[2])
760
+ if key not in self._type_scope:
761
+ self._type_scope[key] = self._resolve_type(key, depth)
762
+ scope = self._type_scope[key]
763
+ return scope if scope is not None else Scope((f"$({owner[2]})",))
764
+
765
+ def _resolve_type(self, key: tuple[str, str], depth: int) -> Scope | None:
766
+ if key in self._resolving or depth > _MAX_DEPTH:
767
+ return None
768
+ sites = self.mounts().get(key, [])
769
+ if key[0] in self.unresolved_packages or len(sites) > _MAX_MOUNTS:
770
+ return None # a mount we can't see (or too many to check) may differ
771
+ self._resolving.add(key)
772
+ try:
773
+ scopes: list[Scope | None] = []
774
+ for kind, f, where, prefix in sites:
775
+ if kind == "receiver":
776
+ assert isinstance(where, int)
777
+ _, chain = receiver_chain(f, where)
778
+ scope = self.eval_chain(f, chain, where, depth + 1) if chain else None
779
+ else:
780
+ assert isinstance(where, tuple)
781
+ start, end = where
782
+ _, value = arg_label(f.text[start:end])
783
+ vstart = end - len(value)
784
+ chain = parse_chain(f, vstart, end)
785
+ scope = self.eval_chain(f, chain, start, depth + 1) if chain else None
786
+ if scope is not None and prefix:
787
+ scope = Scope(scope.prefix + prefix, scope.guards)
788
+ scopes.append(scope)
789
+ finally:
790
+ self._resolving.discard(key)
791
+ if not scopes or None in scopes:
792
+ return None
793
+ known = set(scopes)
794
+ return known.pop() if len(known) == 1 else None
795
+
796
+ def base_scope(self, f: SwiftFile, name: str, offset: int, depth: int) -> Scope | None:
797
+ if depth > _MAX_DEPTH:
798
+ return None
799
+ # Innermost group closure binding this name ($0 or its parameter).
800
+ index = f.closures_by_param.get(name)
801
+ found = index.innermost(offset) if index is not None else None
802
+ best: _Closure | None = found[2] if found is not None else None
803
+ if best is not None:
804
+ key = ("closure", best.open)
805
+ if key not in f.scope_cache:
806
+ f.scope_cache[key] = None # breaks self-reference cycles
807
+ start, chain = receiver_chain(f, best.call_dot)
808
+ parent = self.eval_chain(f, chain, best.call_dot, depth + 1) if chain else None
809
+ evidence = clip(f.text[start:best.args[1] + 1])
810
+ f.scope_cache[key] = _apply_args(f, parent, best.args, evidence) if parent is not None else None
811
+ scope = f.scope_cache[key]
812
+ if scope is None:
813
+ return None
814
+ return self._with_mutations(f, name, offset, scope, best.open)
815
+ # A local binding: `let api = app.grouped("api")`.
816
+ binding = _nearest(f.bindings.get(name, ()), offset)
817
+ if binding is not None and _same_function(f, binding, offset):
818
+ key = ("binding", binding)
819
+ if key not in f.scope_cache:
820
+ f.scope_cache[key] = None
821
+ chain = parse_chain(f, binding, _expression_end(f, binding))
822
+ f.scope_cache[key] = self.eval_chain(f, chain, binding, depth + 1) if chain is not None else None
823
+ scope = f.scope_cache[key]
824
+ if scope is not None:
825
+ return self._with_mutations(f, name, offset, scope, binding)
826
+ # A parameter typed as a router.
827
+ for open_, close, type_name in f.params.get(name, ()):
828
+ if open_ < offset < close:
829
+ if type_name in ("Application", "HBApplication", "Router", "HBRouterBuilder"):
830
+ scope = Scope(()) # the root: only a RouterGroup / RoutesBuilder can carry a prefix
831
+ else:
832
+ scope = self.type_scope(f, offset, depth + 1)
833
+ return self._with_mutations(f, name, offset, scope, open_)
834
+ if name == "app": # Vapor's Application, Hummingbird 1.x's HBApplication
835
+ return self._with_mutations(f, name, offset, Scope(()), -1)
836
+ return None
837
+
838
+ def _with_mutations(self, f: SwiftFile, name: str, offset: int, scope: Scope, since: int) -> Scope:
839
+ """Apply `name.add(middleware: X)` statements made before ``offset``
840
+ (and after ``since``) in the same function."""
841
+ run = f.mutation_run(name, offset)
842
+ if run is None:
843
+ return scope
844
+ added = run.between(since, offset)
845
+ added = [g for g in added if g not in scope.guards]
846
+ if not added:
847
+ return scope
848
+ return Scope(scope.prefix, scope.guards + tuple(dict.fromkeys(added)))
849
+
850
+ def eval_chain(self, f: SwiftFile, chain: list[_Seg] | None, offset: int, depth: int) -> Scope | None:
851
+ if not chain or depth > _MAX_DEPTH:
852
+ return None
853
+ base = chain[0]
854
+ scope: Scope | None
855
+ if base.args is not None:
856
+ if base.name in _ROOT_CTORS:
857
+ scope = Scope(())
858
+ elif base.name in _COLLECTION_CTORS:
859
+ scope = self.type_scope(f, offset, depth + 1)
860
+ else:
861
+ return None
862
+ else:
863
+ scope = self.base_scope(f, base.name, offset, depth + 1)
864
+ if scope is None:
865
+ return None
866
+ text = f.text
867
+ for seg in chain[1:]:
868
+ if seg.args is None and seg.closure is None:
869
+ if seg.name in _TRANSPARENT:
870
+ continue
871
+ return None
872
+ end = (seg.closure or seg.args)[1] # type: ignore[index]
873
+ evidence = clip(text[chain[0].start:end + 1])
874
+ if seg.name in ("grouped", "group") and seg.args is not None:
875
+ scope = _apply_args(f, scope, seg.args, evidence)
876
+ elif seg.name == "add" and seg.args is not None:
877
+ args = split_args(f.src, *seg.args)
878
+ if not args:
879
+ return None
880
+ label, value = arg_label(text[args[0][0]:args[0][1]])
881
+ if label != "middleware":
882
+ return None
883
+ scope = Scope(scope.prefix, scope.guards + (_guard_for(value, evidence),))
884
+ elif seg.name == "addMiddleware" and seg.closure is not None:
885
+ body = text[seg.closure[0]:seg.closure[1] + 1]
886
+ scope = Scope(scope.prefix, scope.guards + _closure_guards(body, evidence))
887
+ elif seg.name in _VERBS or seg.name == "addRoutes":
888
+ continue # Hummingbird verb calls and addRoutes return the router itself
889
+ else:
890
+ return None
891
+ return scope
892
+
893
+
894
+ def _apply_args(f: SwiftFile, scope: Scope, span: tuple[int, int], evidence: str) -> Scope:
895
+ prefix = list(scope.prefix)
896
+ guards = list(scope.guards)
897
+ for start, end in split_args(f.src, *span):
898
+ label, value = arg_label(f.text[start:end])
899
+ if label is not None:
900
+ continue # `context:` and other labeled arguments don't change the path
901
+ comps = _component(value)
902
+ if comps is not None:
903
+ prefix.extend(comps)
904
+ elif _is_middleware_expr(value):
905
+ guards.append(_guard_for(value, evidence))
906
+ else:
907
+ prefix.append(_computed(value))
908
+ return Scope(tuple(prefix), tuple(guards))
909
+
910
+
911
+ def _nearest(offsets, before: int) -> int | None:
912
+ """The last offset before ``before`` (``offsets`` are ascending)."""
913
+ i = bisect.bisect_left(offsets, before)
914
+ return offsets[i - 1] if i > 0 else None
915
+
916
+
917
+ def _same_function(f: SwiftFile, a: int, b: int) -> bool:
918
+ return f.enclosing_function(a) == f.enclosing_function(b)
919
+
920
+
921
+ def _expression_end(f: SwiftFile, start: int) -> int:
922
+ """End of the expression starting at ``start``: its line, extended over
923
+ brackets opened on it and over following lines that start with `.`
924
+ (cached per start offset)."""
925
+ cached = f.expression_end_cache.get(start)
926
+ if cached is None:
927
+ cached = f.expression_end_cache[start] = _compute_expression_end(f, start)
928
+ return cached
929
+
930
+
931
+ def _compute_expression_end(f: SwiftFile, start: int) -> int:
932
+ text, src = f.text, f.src
933
+ n = len(text)
934
+ pos = start
935
+ for _ in range(_MAX_CHAIN * 4):
936
+ nl = text.find("\n", pos)
937
+ if nl < 0:
938
+ return n
939
+ k = pos
940
+ while True:
941
+ opener = _first_open(text, k, nl)
942
+ if opener < 0:
943
+ break
944
+ close = src.close_of(opener)
945
+ if close < 0:
946
+ return nl
947
+ if close > nl:
948
+ nl = text.find("\n", close)
949
+ if nl < 0:
950
+ return n
951
+ k = close + 1
952
+ nxt = _next_code_char(text, nl + 1)
953
+ if nxt >= 0 and text[nxt] == ".":
954
+ pos = nxt
955
+ continue
956
+ return nl
957
+ return pos
958
+
959
+
960
+ _OPEN_BRACKET = re.compile(r"[(\[{]")
961
+
962
+
963
+ def _first_open(text: str, start: int, end: int) -> int:
964
+ m = _OPEN_BRACKET.search(text, start, end)
965
+ return m.start() if m else -1
966
+
967
+
968
+ # ---------- Route extraction ----------
969
+
970
+ def extract_routes(f: SwiftFile, resolver: RouteResolver) -> list[RouteHit]:
971
+ if not f.is_server:
972
+ return []
973
+ text, src = f.text, f.src
974
+ hits: list[RouteHit] = []
975
+ for m in _ROUTE_CALL.finditer(text):
976
+ dot = m.start()
977
+ if not f.code(dot):
978
+ continue
979
+ verb = m.group(1)
980
+ start, chain = receiver_chain(f, dot)
981
+ if not chain:
982
+ continue
983
+ scope = resolver.eval_chain(f, chain, dot, 0)
984
+ if scope is None:
985
+ continue
986
+ opener = m.end()
987
+ args: list[tuple[int, int]] = []
988
+ end = opener - 1
989
+ if text[opener] == "(":
990
+ end = src.close_of(opener)
991
+ if end < 0:
992
+ continue
993
+ args = split_args(src, opener, end)
994
+ method = _VERBS[verb]
995
+ comps: list[str] = []
996
+ use: str | None = None
997
+ for a_start, a_end in args:
998
+ label, value = arg_label(text[a_start:a_end])
999
+ if label == "use":
1000
+ use = value
1001
+ elif label == "method":
1002
+ method = _method(value) or method
1003
+ elif label is None:
1004
+ if verb == "on" and method is None and _method(value):
1005
+ method = _method(value)
1006
+ continue
1007
+ part = _component(value)
1008
+ comps.extend(part if part is not None else [_computed(value)])
1009
+ handler = _handler_span(f, dot, end + 1, use)
1010
+ if method is None or (use is None and handler is None):
1011
+ continue # not a route registration
1012
+ hit = RouteHit(path="/" + "/".join(scope.prefix + tuple(comps)), method=method, line=line_of(text, dot))
1013
+ _resolve_auth(f, hit, scope, handler)
1014
+ hits.append(hit)
1015
+ return hits
1016
+
1017
+
1018
+ def _handler_span(f: SwiftFile, dot: int, after: int, use: str | None) -> tuple[int, int] | None:
1019
+ """The handler body: a trailing closure, or `use: name` resolved to a
1020
+ function in the same file."""
1021
+ text, src = f.text, f.src
1022
+ if use is None:
1023
+ brace = _next_code_char(text, after)
1024
+ if brace >= 0 and text[brace] == "{":
1025
+ end = src.close_of(brace)
1026
+ if end > 0:
1027
+ return (brace, end)
1028
+ return None
1029
+ qualifier, _, name = use.strip().rpartition(".")
1030
+ if not name.isidentifier():
1031
+ return None
1032
+ # `use: Controller.show` names the type; `self.x` / `x` / `Self.x` mean the enclosing one.
1033
+ owner = qualifier if qualifier[:1].isupper() and qualifier.isidentifier() and qualifier != "Self" else None
1034
+ return f.function_body(name, dot, owner)
1035
+
1036
+
1037
+ # `try? req.auth.require(...)` / `(try? context.requireIdentity())` swallow the
1038
+ # error: the handler carries on for anonymous callers.
1039
+ # What may precede an identity check on its line for it to count: the check
1040
+ # is the statement itself, a plain `try` (not `try?`) whose error leaves the
1041
+ # handler, optionally bound: `let user = try await req.auth.require(User.self)`.
1042
+ _ENFORCED_CALL_PREFIX = re.compile(
1043
+ r"[ \t]*(?:(?:let|var)[ \t]+[A-Za-z_]\w{0,60}(?:[ \t]*:[^=\n]{0,80})?[ \t]*=[ \t]*|_[ \t]*=[ \t]*)?"
1044
+ r"try!?[ \t]+(?:await[ \t]+)?(?:[\w$]{1,60}[ \t]*\.[ \t]*){1,6}"
1045
+ )
1046
+
1047
+
1048
+ def _top_level_statement(f: SwiftFile, handler: tuple[int, int], offset: int) -> bool:
1049
+ """True when ``offset`` is directly in the handler body: not inside an
1050
+ `if` / `guard ... else` / `switch` / `do` / `catch` block or a nested
1051
+ closure, and not inside parentheses (a condition or an argument)."""
1052
+ block = f.block_index.innermost(offset)
1053
+ if block is None or block[0] != handler[0]:
1054
+ return False
1055
+ paren = f.paren_index.innermost(offset)
1056
+ return paren is None or paren[0] < handler[0]
1057
+
1058
+
1059
+ _EXIT_TOKEN = re.compile(r"\b(?:return|throw|try|guard|fallthrough)\b")
1060
+ # A line ending like this continues on the next one (`let u = flag ? nil :`).
1061
+ _CONTINUES = set("=+-*/%&|^!<>?:,.([{")
1062
+
1063
+
1064
+ def _enforced_check(f: SwiftFile, handler: tuple[int, int], m: re.Match[str], first_exit: int) -> tuple[str, str] | None:
1065
+ """The identity check at ``m`` as (guard name, evidence line), when it
1066
+ always runs before the handler can answer; else None."""
1067
+ text = f.text
1068
+ if not _top_level_statement(f, handler, m.start()):
1069
+ return None
1070
+ line = text.rfind("\n", handler[0], m.start()) + 1 or handler[0] + 1
1071
+ statement = max(line, text.rfind(";", line, m.start()) + 1)
1072
+ # The statement must really start on this line: not continued from the
1073
+ # previous one (`let u = flag ? nil :` / `=` / `,` ...). Comments are skipped.
1074
+ k = statement - 1
1075
+ for _ in range(64):
1076
+ while k > handler[0] and text[k].isspace():
1077
+ k -= 1
1078
+ span = f.src.masked_span(k) if k > handler[0] else None
1079
+ if span is None or text[span[0]] == '"' or text[span[0]] == "#":
1080
+ break
1081
+ k = span[0] - 1
1082
+ else:
1083
+ return None
1084
+ if k > handler[0] and (text[k] in _CONTINUES or f.src.in_masked(k) and text[k] != '"'):
1085
+ return None
1086
+ if m.group(0).startswith("guard"):
1087
+ # The guard is the statement, and it guards on the caller's identity
1088
+ # (a login handler's `guard let user = found else { throw .unauthorized }`
1089
+ # checks credentials, not the caller).
1090
+ ok = not text[statement:m.start()].strip() and _strict_identity_condition(m.group("cond") or "")
1091
+ else:
1092
+ ok = _ENFORCED_CALL_PREFIX.fullmatch(text, statement, m.start()) is not None
1093
+ if not ok:
1094
+ return None # `try?`, `if let ... = try? ...`, a condition, a line break after `try`
1095
+ if first_exit < statement:
1096
+ return None # something can return or throw before the check
1097
+ # Not between `#if` and `#endif` inside the handler.
1098
+ opened = bisect.bisect_left(f.pp_if, m.start()) - bisect.bisect_left(f.pp_if, handler[0])
1099
+ closed = bisect.bisect_left(f.pp_endif, m.start()) - bisect.bisect_left(f.pp_endif, handler[0])
1100
+ if opened > closed:
1101
+ return None
1102
+ return (_handler_guard_name(m.group(0)), clip(_line_at(text, m.start())))
1103
+
1104
+
1105
+ def _handler_checks(f: SwiftFile, handler: tuple[int, int]) -> tuple[tuple[str, str] | None, str | None]:
1106
+ """(identity guard name + line, role-guard line) in a handler body,
1107
+ computed once per handler."""
1108
+ cached = f.handler_cache.get(handler)
1109
+ if cached is not None:
1110
+ return cached
1111
+ text = f.text
1112
+ identity: tuple[str, str] | None = None
1113
+ # The first statement in the handler that can leave it (`return`, `throw`,
1114
+ # `try`, `guard`), at any depth, must be the identity check itself: an
1115
+ # earlier `if flag { return ... }` or `try` serves (or fails) the request
1116
+ # before the check runs.
1117
+ first_exit = -1
1118
+ for e in _EXIT_TOKEN.finditer(text, handler[0] + 1, handler[1]):
1119
+ if f.code(e.start()):
1120
+ first_exit = e.start()
1121
+ break
1122
+ for m in _HANDLER_GUARD.finditer(text, handler[0], handler[1]):
1123
+ if not f.code(m.start()):
1124
+ continue
1125
+ identity = _enforced_check(f, handler, m, first_exit)
1126
+ break # only the first check is considered; when in doubt, unknown
1127
+ role: str | None = None
1128
+ for m in _HANDLER_ROLE.finditer(text, handler[0], handler[1]):
1129
+ line = _line_at(text, m.start()).strip()
1130
+ if f.code(m.start()) and line.startswith("guard ") and _top_level_statement(f, handler, m.start()):
1131
+ role = clip(line, 80)
1132
+ break
1133
+ f.handler_cache[handler] = (identity, role)
1134
+ return identity, role
1135
+
1136
+
1137
+ def _resolve_auth(f: SwiftFile, hit: RouteHit, scope: Scope, handler: tuple[int, int] | None) -> None:
1138
+ enforcing = [g for g in scope.guards if g.enforcing and not g.conditional]
1139
+ identity, role = _handler_checks(f, handler) if handler is not None else (None, None)
1140
+ if enforcing:
1141
+ hit.auth = REQUIRED
1142
+ hit.guards = list(dict.fromkeys(
1143
+ g.name for g in scope.guards
1144
+ if not g.conditional and (g.enforcing or _AUTHENTICATOR.search(g.name))
1145
+ ))
1146
+ hit.evidence = clip("; ".join(dict.fromkeys(g.evidence for g in enforcing)))
1147
+ elif identity is not None:
1148
+ hit.auth = REQUIRED
1149
+ hit.guards = list(dict.fromkeys(
1150
+ [g.name for g in scope.guards if not g.conditional and _AUTHENTICATOR.search(g.name)] + [identity[0]]
1151
+ ))
1152
+ hit.evidence = identity[1]
1153
+ if hit.auth == REQUIRED and role is not None:
1154
+ # A `guard` on a role or permission in the handler is a guard too.
1155
+ hit.guards.append(role)
1156
+
1157
+
1158
+ def _line_at(text: str, offset: int) -> str:
1159
+ start = text.rfind("\n", 0, offset) + 1
1160
+ end = text.find("\n", offset)
1161
+ return text[start:end if end >= 0 else len(text)]
1162
+
1163
+
1164
+ def _handler_guard_name(matched: str) -> str:
1165
+ if "require(" in matched.replace(" ", ""):
1166
+ return "auth.require"
1167
+ if "requireIdentity" in matched:
1168
+ return "requireIdentity"
1169
+ return "guard identity else unauthorized"
1170
+
1171
+
1172
+ def package_of(rel: str, package_dirs: list[str]) -> str:
1173
+ """The nearest enclosing directory with a Package.swift ('' = repo root)."""
1174
+ parent = posixpath.dirname(rel)
1175
+ best = ""
1176
+ for d in package_dirs:
1177
+ if (d == "" or parent == d or parent.startswith(d + "/")) and len(d) >= len(best):
1178
+ best = d
1179
+ return best
1180
+
1181
+
1182
+ def extract_repo_routes(files: list[tuple[str, str]], package_dirs: list[str] | None = None) -> dict[str, list[RouteHit]]:
1183
+ """Routes per relative file for every (rel_path, text) Swift source."""
1184
+ dirs = package_dirs or []
1185
+ parsed = [SwiftFile(rel, text, package_of(rel, dirs)) for rel, text in files]
1186
+ resolver = RouteResolver(parsed)
1187
+ out: dict[str, list[RouteHit]] = {}
1188
+ for f in resolver.files:
1189
+ hits = extract_routes(f, resolver)
1190
+ if hits:
1191
+ out[f.rel] = hits
1192
+ return out
1193
+
1194
+
1195
+ __all__ = ["REQUIRED", "UNKNOWN", "RouteHit", "extract_repo_routes", "package_of"]