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.
- attackmap_analyzer_swift/__init__.py +4 -0
- attackmap_analyzer_swift/analyzer.py +296 -0
- attackmap_analyzer_swift/apple_surface.py +765 -0
- attackmap_analyzer_swift/objc.py +545 -0
- attackmap_analyzer_swift/routes.py +1195 -0
- attackmap_analyzer_swift/sbom.py +58 -0
- attackmap_analyzer_swift/swiftsrc.py +239 -0
- attackmap_analyzer_swift-0.2.0.dist-info/METADATA +175 -0
- attackmap_analyzer_swift-0.2.0.dist-info/RECORD +13 -0
- attackmap_analyzer_swift-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_swift-0.2.0.dist-info/entry_points.txt +3 -0
- attackmap_analyzer_swift-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_swift-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|