attackmap-analyzer-php-web 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_php_web/__init__.py +4 -0
- attackmap_analyzer_php_web/analyzer.py +629 -0
- attackmap_analyzer_php_web/laravel_routes.py +694 -0
- attackmap_analyzer_php_web/route_auth.py +668 -0
- attackmap_analyzer_php_web/symfony_routes.py +428 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/METADATA +160 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/RECORD +11 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
"""Laravel route registrations, groups and route-file mounts (#3).
|
|
2
|
+
|
|
3
|
+
One pass per file over every ``Route::...`` statement. Each statement is a
|
|
4
|
+
chain of calls (``Route::prefix('admin')->middleware('auth')->group(...)``);
|
|
5
|
+
the links before the terminal call (a verb, ``match``, ``resource`` or
|
|
6
|
+
``group``) are the statement's scope, the links after it are route options.
|
|
7
|
+
|
|
8
|
+
- **Registrations**: ``Route::get|post|put|patch|delete|options|head|any``,
|
|
9
|
+
array-first ``Route::match([...], ...)``, and ``Route::resource`` /
|
|
10
|
+
``apiResource`` / ``resources`` / ``apiResources`` expanded to their REST
|
|
11
|
+
routes (honouring ``->only()`` / ``->except()`` / ``->shallow()`` and an
|
|
12
|
+
``['only' => ...]`` options array). A chained registration
|
|
13
|
+
(``Route::middleware('auth')->get(...)``) carries the chain's scope.
|
|
14
|
+
- **Groups**: ``...->group(function () { ... })`` and
|
|
15
|
+
``Route::group(['prefix' => ..., 'middleware' => ...], fn)``. The group's
|
|
16
|
+
argument list is its span; the registrations inside it inherit its prefix,
|
|
17
|
+
middleware and controller.
|
|
18
|
+
- **Mounts**: a route file loaded from another file
|
|
19
|
+
(``->group(base_path('routes/api.php'))``, ``require base_path(...)`` or
|
|
20
|
+
``$this->loadRoutesFrom(__DIR__.'/../routes/web.php')`` inside a group, and
|
|
21
|
+
``withRouting(api: ..., apiPrefix: ...)`` in ``bootstrap/app.php``) inherits
|
|
22
|
+
the groups around the mount.
|
|
23
|
+
|
|
24
|
+
Brackets come from ``route_auth.bracket_pairs`` (one string-, comment- and
|
|
25
|
+
heredoc-aware pass per file), argument heads are read up to a fixed bound,
|
|
26
|
+
and group nesting past ``route_auth.MAX_NESTING`` is not resolved, so hostile
|
|
27
|
+
input stays linear.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import bisect
|
|
33
|
+
import posixpath
|
|
34
|
+
import re
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
|
|
37
|
+
from . import route_auth as ra
|
|
38
|
+
|
|
39
|
+
# A prefix is a literal string, a `config('file.key')` reference resolved
|
|
40
|
+
# against the repo's config/ directory, or dynamic (anything else).
|
|
41
|
+
LITERAL = "lit"
|
|
42
|
+
CONFIG = "config"
|
|
43
|
+
DYNAMIC = "dyn"
|
|
44
|
+
Prefix = tuple[str, str]
|
|
45
|
+
NO_PREFIX: Prefix = (LITERAL, "")
|
|
46
|
+
# Shown in place of a prefix that can't be read statically.
|
|
47
|
+
DYNAMIC_SEGMENT = ra.DYNAMIC_SEGMENT
|
|
48
|
+
|
|
49
|
+
VERBS = frozenset({"get", "post", "put", "patch", "delete", "options", "head", "any"})
|
|
50
|
+
RESOURCE_CALLS = frozenset({"resource", "apiResource", "resources", "apiResources"})
|
|
51
|
+
# Calls that may precede a terminal in a `Route::` chain (RouteRegistrar).
|
|
52
|
+
SCOPE_CALLS = frozenset(
|
|
53
|
+
{
|
|
54
|
+
"prefix",
|
|
55
|
+
"middleware",
|
|
56
|
+
"withoutMiddleware",
|
|
57
|
+
"controller",
|
|
58
|
+
"name",
|
|
59
|
+
"as",
|
|
60
|
+
"domain",
|
|
61
|
+
"namespace",
|
|
62
|
+
"where",
|
|
63
|
+
"whereNumber",
|
|
64
|
+
"whereAlpha",
|
|
65
|
+
"whereAlphaNumeric",
|
|
66
|
+
"whereUuid",
|
|
67
|
+
"whereUlid",
|
|
68
|
+
"whereIn",
|
|
69
|
+
"scopeBindings",
|
|
70
|
+
"withoutScopedBindings",
|
|
71
|
+
"missing",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Resource actions: (action, methods, member route?, suffix).
|
|
76
|
+
_RESOURCE_ACTIONS = (
|
|
77
|
+
("index", ("GET",), False, ""),
|
|
78
|
+
("create", ("GET",), False, "/create"),
|
|
79
|
+
("store", ("POST",), False, ""),
|
|
80
|
+
("show", ("GET",), True, ""),
|
|
81
|
+
("edit", ("GET",), True, "/edit"),
|
|
82
|
+
("update", ("PUT", "PATCH"), True, ""),
|
|
83
|
+
("destroy", ("DELETE",), True, ""),
|
|
84
|
+
)
|
|
85
|
+
_API_ACTIONS = frozenset({"index", "store", "show", "update", "destroy"})
|
|
86
|
+
# A controller class reference is read this far.
|
|
87
|
+
_NAME_SCAN = 500
|
|
88
|
+
# `Route::resources([...])` entries read per statement.
|
|
89
|
+
MAX_RESOURCES = 64
|
|
90
|
+
# Route-file mounts followed transitively this deep, and at most this many
|
|
91
|
+
# distinct mount chains per file.
|
|
92
|
+
MAX_MOUNT_DEPTH = 8
|
|
93
|
+
MAX_MOUNT_CHAINS = 8
|
|
94
|
+
# Route files Laravel's default bootstrap loads itself.
|
|
95
|
+
FRAMEWORK_ROUTE_FILES = frozenset({"routes/web.php", "routes/api.php"})
|
|
96
|
+
# Mount edges followed per repo before the rest stay unresolved, and the
|
|
97
|
+
# recursion depth of one resolution.
|
|
98
|
+
MAX_MOUNT_VISITS = 4096
|
|
99
|
+
MAX_MOUNT_PATH = 64
|
|
100
|
+
|
|
101
|
+
_STATEMENT = re.compile(r"\bRoute::(\w+)\s*\(")
|
|
102
|
+
_STRING_ARG = re.compile(r"\s*(?:'([^'\\]*)'|\"([^\"\\]*)\")")
|
|
103
|
+
_ARRAY_ARG = re.compile(r"\s*\[")
|
|
104
|
+
_COMMA = re.compile(r"\s*,")
|
|
105
|
+
_QUOTED = re.compile(r"['\"]([^'\"]*)['\"]")
|
|
106
|
+
_CLASS_REF = re.compile(r"\s*\\?([\w\\]+)::class")
|
|
107
|
+
_CONFIG_CALL = re.compile(r"\s*config\s*\(\s*['\"]([\w-]+)\.([\w-]+)['\"]\s*(?:,[^)]*)?\)\s*$")
|
|
108
|
+
_GROUP_ATTR = re.compile(r"['\"](prefix|controller)['\"]\s*=>\s*")
|
|
109
|
+
_RESOURCES_PAIR = re.compile(r"['\"]([^'\"]+)['\"]\s*=>\s*\\?([\w\\]+)::class")
|
|
110
|
+
_OPTIONS_KEY = re.compile(r"['\"](only|except)['\"]\s*=>\s*(\[[^\]]*\]|['\"][^'\"]*['\"])")
|
|
111
|
+
_WITH_ROUTING = re.compile(r"->\s*withRouting\s*\(")
|
|
112
|
+
_API_PREFIX = re.compile(r"\bapiPrefix\s*:\s*['\"]([^'\"]*)['\"]")
|
|
113
|
+
_MOUNT_REF = re.compile(
|
|
114
|
+
r"\bbase_path\s*\(\s*['\"]([^'\"]+\.php)['\"]\s*\)|\b__DIR__\s*\.\s*['\"]([^'\"]+\.php)['\"]"
|
|
115
|
+
)
|
|
116
|
+
# What precedes a mount reference: the file is loaded, not just named.
|
|
117
|
+
_MOUNT_LEAD = re.compile(
|
|
118
|
+
r"(?:\b(?:require|include)(?:_once)?\s*\(?|\bloadRoutesFrom\s*\(|->\s*group\s*\(|\b(?:api|web)\s*:\s*\[?)\s*$"
|
|
119
|
+
)
|
|
120
|
+
_ONCE_LEAD = re.compile(r"\b(?:require|include)_once\s*\(?\s*$")
|
|
121
|
+
# Mounts in test code (a test that requires routes/api.php) don't count.
|
|
122
|
+
_TEST_DIRS = frozenset({"test", "tests", "spec", "specs", "__tests__", "testing"})
|
|
123
|
+
_TEST_FILE = re.compile(r"(?:Test|_test|\.test|Spec)\.php$")
|
|
124
|
+
_API_ARG = re.compile(r"\bapi\s*:\s*")
|
|
125
|
+
# Mount references and withRouting calls read per file.
|
|
126
|
+
MAX_MOUNT_REFS = 512
|
|
127
|
+
MAX_ROUTING_CALLS = 16
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class LaravelGroup:
|
|
132
|
+
"""A group's span (its argument list) and what it applies inside."""
|
|
133
|
+
|
|
134
|
+
start: int
|
|
135
|
+
end: int
|
|
136
|
+
scope: ra.LaravelScope = field(default_factory=ra.LaravelScope)
|
|
137
|
+
prefix: Prefix = NO_PREFIX
|
|
138
|
+
controller: str | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class LaravelRoute:
|
|
143
|
+
methods: list[str]
|
|
144
|
+
uri: str
|
|
145
|
+
offset: int
|
|
146
|
+
scope: ra.LaravelScope
|
|
147
|
+
action: tuple[str, str] | None
|
|
148
|
+
# Enclosing in-file groups, outermost first; None past MAX_NESTING.
|
|
149
|
+
groups: list[LaravelGroup] | None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class Mount:
|
|
154
|
+
"""``target`` (repo-relative route file) is loaded inside ``groups``."""
|
|
155
|
+
|
|
156
|
+
target: str
|
|
157
|
+
groups: list[LaravelGroup] | None
|
|
158
|
+
# `require_once` / `include_once`: a no-op when the file is already loading.
|
|
159
|
+
once: bool = False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class FileScan:
|
|
164
|
+
routes: list[LaravelRoute] = field(default_factory=list)
|
|
165
|
+
mounts: list[Mount] = field(default_factory=list)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _string_at(content: str, pos: int, end: int) -> tuple[str | None, int]:
|
|
169
|
+
"""A string literal starting at ``pos`` (after whitespace), and the
|
|
170
|
+
offset past it; ``(None, pos)`` when there isn't one. Read at most
|
|
171
|
+
``_ARG_SCAN`` characters."""
|
|
172
|
+
match = _STRING_ARG.match(content, pos, min(end, pos + ra._ARG_SCAN))
|
|
173
|
+
if not match:
|
|
174
|
+
return None, pos
|
|
175
|
+
value = match.group(1) if match.group(1) is not None else match.group(2)
|
|
176
|
+
return value, match.end()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _prefix_value(content: str, start: int, end: int) -> Prefix:
|
|
180
|
+
"""Classify the expression ``content[start:end]`` as a prefix."""
|
|
181
|
+
if end - start > ra._ARG_SCAN:
|
|
182
|
+
return (DYNAMIC, "") # not a literal or a config() call
|
|
183
|
+
value, after = _string_at(content, start, end)
|
|
184
|
+
if value is not None and not content[after:end].strip():
|
|
185
|
+
return (LITERAL, value)
|
|
186
|
+
text = content[start:end]
|
|
187
|
+
config = _CONFIG_CALL.match(text)
|
|
188
|
+
if config:
|
|
189
|
+
return (CONFIG, f"{config.group(1)}.{config.group(2)}")
|
|
190
|
+
return (DYNAMIC, "")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _expr_end(content: str, start: int, end: int) -> int:
|
|
194
|
+
"""End of the expression starting at ``start``: the next top-level comma
|
|
195
|
+
or ``end``. Brackets are skipped via the cached pair table."""
|
|
196
|
+
pairs = ra.bracket_pairs(content)
|
|
197
|
+
pos = start
|
|
198
|
+
limit = min(end, start + ra._ARG_SCAN)
|
|
199
|
+
while pos < limit:
|
|
200
|
+
ch = content[pos]
|
|
201
|
+
if ch == ",":
|
|
202
|
+
return pos
|
|
203
|
+
if ch in "([{":
|
|
204
|
+
close = pairs.get(pos, -1)
|
|
205
|
+
if close < 0:
|
|
206
|
+
return end
|
|
207
|
+
pos = close + 1
|
|
208
|
+
continue
|
|
209
|
+
if ch in "'\"":
|
|
210
|
+
_, after = _string_at(content, pos, end)
|
|
211
|
+
pos = after if after > pos else pos + 1
|
|
212
|
+
continue
|
|
213
|
+
pos += 1
|
|
214
|
+
return limit
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _array_span(content: str, link: ra.Link) -> tuple[int, int] | None:
|
|
218
|
+
"""``(open, close)`` of an array literal that is the call's first argument."""
|
|
219
|
+
match = _ARRAY_ARG.match(content, link.open + 1, link.close)
|
|
220
|
+
if not match:
|
|
221
|
+
return None
|
|
222
|
+
close = ra.matching_close(content, match.end() - 1)
|
|
223
|
+
return (match.end() - 1, close) if 0 <= close <= link.close else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _class_name(text: str) -> str | None:
|
|
227
|
+
match = _CLASS_REF.match(text)
|
|
228
|
+
if match:
|
|
229
|
+
return match.group(1).rsplit("\\", 1)[-1]
|
|
230
|
+
quoted = _STRING_ARG.match(text)
|
|
231
|
+
if quoted:
|
|
232
|
+
value = quoted.group(1) if quoted.group(1) is not None else quoted.group(2)
|
|
233
|
+
return value.rsplit("\\", 1)[-1] or None
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _second_arg(content: str, link: ra.Link) -> int | None:
|
|
238
|
+
"""Offset where the call's second argument starts, or None."""
|
|
239
|
+
first_end = _expr_end(content, link.open + 1, link.close)
|
|
240
|
+
if first_end >= link.close or content[first_end] != ",":
|
|
241
|
+
return None
|
|
242
|
+
return first_end + 1
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _group_from(links: list[ra.Link], group: ra.Link, content: str) -> LaravelGroup:
|
|
246
|
+
scope = ra.LaravelScope()
|
|
247
|
+
scope.add_links([link for link in links if link is not group])
|
|
248
|
+
prefix = NO_PREFIX
|
|
249
|
+
prefixes: list[Prefix] = []
|
|
250
|
+
controller = None
|
|
251
|
+
for link in links:
|
|
252
|
+
if link is group:
|
|
253
|
+
break
|
|
254
|
+
if link.name == "prefix":
|
|
255
|
+
prefixes.append(_prefix_value(content, link.open + 1, link.close))
|
|
256
|
+
elif link.name == "controller":
|
|
257
|
+
controller = _class_name(content[link.open + 1 : min(link.close, link.open + _NAME_SCAN)])
|
|
258
|
+
span = _array_span(content, group)
|
|
259
|
+
if span is not None:
|
|
260
|
+
# Legacy `Route::group(['prefix' => 'x', 'middleware' => ...], fn)`.
|
|
261
|
+
attrs_open, attrs_close = span
|
|
262
|
+
attrs_end = min(attrs_close, attrs_open + ra._ARG_SCAN)
|
|
263
|
+
ra.add_group_attr_middleware(scope, content[attrs_open + 1 : attrs_end])
|
|
264
|
+
# PHP keeps the last of duplicate keys; only that one is read.
|
|
265
|
+
last: dict[str, re.Match] = {}
|
|
266
|
+
for attr in _GROUP_ATTR.finditer(content, attrs_open + 1, attrs_end):
|
|
267
|
+
last[attr.group(1)] = attr
|
|
268
|
+
for attr in sorted(last.values(), key=lambda m: m.start()):
|
|
269
|
+
value_end = _expr_end(content, attr.end(), attrs_close)
|
|
270
|
+
if attr.group(1) == "prefix":
|
|
271
|
+
prefixes.append(_prefix_value(content, attr.end(), value_end))
|
|
272
|
+
else:
|
|
273
|
+
controller = _class_name(content[attr.end() : min(value_end, attr.end() + _NAME_SCAN)]) or controller
|
|
274
|
+
if prefixes:
|
|
275
|
+
prefix = _join_prefixes(prefixes)
|
|
276
|
+
return LaravelGroup(group.open, group.close, scope, prefix, controller)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _join_prefixes(prefixes: list[Prefix]) -> Prefix:
|
|
280
|
+
"""Several prefixes on one chain collapse to one (Laravel joins them)."""
|
|
281
|
+
if len(prefixes) == 1:
|
|
282
|
+
return prefixes[0]
|
|
283
|
+
if all(kind == LITERAL for kind, _ in prefixes):
|
|
284
|
+
return (LITERAL, "/".join(p.strip("/") for _, p in prefixes if p.strip("/")))
|
|
285
|
+
return (DYNAMIC, "")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _list_values(text: str) -> set[str]:
|
|
289
|
+
return {m.group(1) for m in _QUOTED.finditer(text)}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def singular(word: str) -> str:
|
|
293
|
+
"""Laravel's resource wildcard: the singular of the segment, ``-`` → ``_``.
|
|
294
|
+
|
|
295
|
+
A small English heuristic; the parameter name doesn't change what the
|
|
296
|
+
route exposes."""
|
|
297
|
+
word = word.replace("-", "_")
|
|
298
|
+
if word.endswith("ies") and len(word) > 3:
|
|
299
|
+
return word[:-3] + "y"
|
|
300
|
+
if word.endswith(("sses", "shes", "ches", "xes", "zes")):
|
|
301
|
+
return word[:-2]
|
|
302
|
+
if word.endswith("s") and not word.endswith(("ss", "us", "is")) and len(word) > 1:
|
|
303
|
+
return word[:-1]
|
|
304
|
+
return word
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def resource_routes(
|
|
308
|
+
name: str, api: bool, only: set[str] | None, except_: set[str], shallow: bool
|
|
309
|
+
) -> list[tuple[str, str, str]]:
|
|
310
|
+
"""``(method, uri, action)`` for a resource registration."""
|
|
311
|
+
name = name.strip().strip("/")
|
|
312
|
+
if not name:
|
|
313
|
+
return []
|
|
314
|
+
base_prefix = ""
|
|
315
|
+
if "/" in name:
|
|
316
|
+
base_prefix, name = name.rsplit("/", 1)
|
|
317
|
+
segments = [s for s in name.split(".") if s]
|
|
318
|
+
if not segments:
|
|
319
|
+
return []
|
|
320
|
+
parents = "/".join(f"{s}/{{{singular(s)}}}" for s in segments[:-1])
|
|
321
|
+
last = segments[-1]
|
|
322
|
+
collection = "/".join(p for p in (base_prefix, parents, last) if p)
|
|
323
|
+
member_base = "/".join(p for p in (base_prefix, last) if p) if shallow else collection
|
|
324
|
+
member = f"{member_base}/{{{singular(last)}}}"
|
|
325
|
+
out = []
|
|
326
|
+
for action, methods, is_member, suffix in _RESOURCE_ACTIONS:
|
|
327
|
+
if api and action not in _API_ACTIONS:
|
|
328
|
+
continue
|
|
329
|
+
if only is not None and action not in only:
|
|
330
|
+
continue
|
|
331
|
+
if action in except_:
|
|
332
|
+
continue
|
|
333
|
+
uri = (member if is_member else collection) + suffix
|
|
334
|
+
out.extend((method, uri, action) for method in methods)
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _resource_options(content: str, link: ra.Link, after: list[ra.Link]) -> tuple[set[str] | None, set[str], bool]:
|
|
339
|
+
only: set[str] | None = None
|
|
340
|
+
except_: set[str] = set()
|
|
341
|
+
shallow = False
|
|
342
|
+
# `Route::resource('x', C::class, ['only' => [...]])`.
|
|
343
|
+
for option in _OPTIONS_KEY.finditer(link.head()):
|
|
344
|
+
values = _list_values(option.group(2))
|
|
345
|
+
if option.group(1) == "only":
|
|
346
|
+
only = values
|
|
347
|
+
else:
|
|
348
|
+
except_ |= values
|
|
349
|
+
for chained in after:
|
|
350
|
+
if chained.name == "only":
|
|
351
|
+
only = _list_values(chained.head())
|
|
352
|
+
elif chained.name == "except":
|
|
353
|
+
except_ |= _list_values(chained.head())
|
|
354
|
+
elif chained.name == "shallow":
|
|
355
|
+
shallow = True
|
|
356
|
+
return only, except_, shallow
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _statement_routes(
|
|
360
|
+
content: str, link: ra.Link, after: list[ra.Link], scope: ra.LaravelScope
|
|
361
|
+
) -> list[tuple[list[str], str, tuple[str, str] | None]]:
|
|
362
|
+
"""``(methods, uri, action)`` for a terminal registration call."""
|
|
363
|
+
name = link.name
|
|
364
|
+
end = link.close
|
|
365
|
+
if name in VERBS or name == "match":
|
|
366
|
+
pos = link.open + 1
|
|
367
|
+
if name == "match":
|
|
368
|
+
span = _array_span(content, link)
|
|
369
|
+
if span is None:
|
|
370
|
+
return []
|
|
371
|
+
methods = [m.upper() for m in _list_values(content[span[0] + 1 : min(span[1], span[0] + 300)])]
|
|
372
|
+
methods = sorted(m for m in methods if m.lower() in VERBS and m != "ANY")
|
|
373
|
+
if not methods:
|
|
374
|
+
return []
|
|
375
|
+
comma = _COMMA.match(content, span[1] + 1, end)
|
|
376
|
+
if not comma:
|
|
377
|
+
return []
|
|
378
|
+
pos = comma.end()
|
|
379
|
+
else:
|
|
380
|
+
methods = ["ANY" if name == "any" else name.upper()]
|
|
381
|
+
uri, _ = _string_at(content, pos, end)
|
|
382
|
+
if uri is None:
|
|
383
|
+
return []
|
|
384
|
+
return [(methods, uri, ra.laravel_action(content, link.open, link.close))]
|
|
385
|
+
# Resources.
|
|
386
|
+
api = name.startswith("api")
|
|
387
|
+
only, except_, shallow = _resource_options(content, link, after)
|
|
388
|
+
entries: list[tuple[str, str | None]] = []
|
|
389
|
+
if name in ("resource", "apiResource"):
|
|
390
|
+
resource, _ = _string_at(content, link.open + 1, end)
|
|
391
|
+
if resource is None:
|
|
392
|
+
return []
|
|
393
|
+
second = _second_arg(content, link)
|
|
394
|
+
controller = None
|
|
395
|
+
if second is not None:
|
|
396
|
+
controller = _class_name(content[second : min(_expr_end(content, second, end), second + _NAME_SCAN)])
|
|
397
|
+
entries.append((resource, controller))
|
|
398
|
+
else:
|
|
399
|
+
span = _array_span(content, link)
|
|
400
|
+
if span is None:
|
|
401
|
+
return []
|
|
402
|
+
body = content[span[0] + 1 : min(span[1], span[0] + 1 + ra._ARG_SCAN)]
|
|
403
|
+
for pair in _RESOURCES_PAIR.finditer(body):
|
|
404
|
+
entries.append((pair.group(1), pair.group(2).rsplit("\\", 1)[-1]))
|
|
405
|
+
if len(entries) >= MAX_RESOURCES:
|
|
406
|
+
break
|
|
407
|
+
out = []
|
|
408
|
+
for resource, controller in entries:
|
|
409
|
+
for method, uri, action in resource_routes(resource, api, only, except_, shallow):
|
|
410
|
+
out.append(([method], uri, (controller, action) if controller else None))
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def scan_file(content: str, relative: str) -> FileScan:
|
|
415
|
+
"""Registrations and route-file mounts in one PHP file."""
|
|
416
|
+
scan = FileScan()
|
|
417
|
+
mounts_ok = not is_test_path(relative)
|
|
418
|
+
if "Route::" not in content:
|
|
419
|
+
if mounts_ok and ("base_path" in content or "__DIR__" in content):
|
|
420
|
+
# `$this->loadRoutesFrom(__DIR__.'/../routes/api.php')` outside
|
|
421
|
+
# any group still claims the file (no default /api).
|
|
422
|
+
scan.mounts = _mounts(content, relative, [])
|
|
423
|
+
return scan
|
|
424
|
+
pairs = ra.bracket_pairs(content)
|
|
425
|
+
groups: list[LaravelGroup] = []
|
|
426
|
+
pending: list[tuple[int, list[tuple[list[str], str, tuple[str, str] | None]], ra.LaravelScope, str | None]] = []
|
|
427
|
+
for match in _STATEMENT.finditer(content):
|
|
428
|
+
open_idx = match.end() - 1
|
|
429
|
+
close = pairs.get(open_idx, -1)
|
|
430
|
+
if close < 0:
|
|
431
|
+
continue # in a comment or string, or unbalanced
|
|
432
|
+
first = ra.Link(match.group(1), content, match.start(), open_idx, close)
|
|
433
|
+
links = [first, *ra.parse_chain(content, close + 1)]
|
|
434
|
+
terminal = None
|
|
435
|
+
for index, link in enumerate(links):
|
|
436
|
+
if link.name == "group" or link.name in VERBS or link.name == "match" or link.name in RESOURCE_CALLS:
|
|
437
|
+
terminal = index
|
|
438
|
+
break
|
|
439
|
+
if link.name not in SCOPE_CALLS:
|
|
440
|
+
break
|
|
441
|
+
if terminal is None:
|
|
442
|
+
continue
|
|
443
|
+
link = links[terminal]
|
|
444
|
+
if link.name == "group":
|
|
445
|
+
groups.append(_group_from(links[: terminal + 1], link, content))
|
|
446
|
+
continue
|
|
447
|
+
before, after = links[:terminal], links[terminal + 1 :]
|
|
448
|
+
scope = ra.LaravelScope()
|
|
449
|
+
scope.add_links(before + after)
|
|
450
|
+
controller = None
|
|
451
|
+
prefixes: list[Prefix] = []
|
|
452
|
+
for chained in before:
|
|
453
|
+
if chained.name == "controller":
|
|
454
|
+
controller = _class_name(content[chained.open + 1 : min(chained.close, chained.open + _NAME_SCAN)])
|
|
455
|
+
elif chained.name == "prefix":
|
|
456
|
+
prefixes.append(_prefix_value(content, chained.open + 1, chained.close))
|
|
457
|
+
found = _statement_routes(content, link, after, scope)
|
|
458
|
+
if not found:
|
|
459
|
+
continue
|
|
460
|
+
if prefixes:
|
|
461
|
+
# `Route::prefix('x')->get('/y')`: a one-route group.
|
|
462
|
+
prefix = _join_prefixes(prefixes)
|
|
463
|
+
found = [(methods, _join([prefix], uri), action) for methods, uri, action in found]
|
|
464
|
+
pending.append((match.start(), found, scope, controller, link.open))
|
|
465
|
+
|
|
466
|
+
ordered = sorted(pending, key=lambda item: item[0])
|
|
467
|
+
enclosing = ra.enclosing(groups, [offset for offset, *_ in ordered])
|
|
468
|
+
for (offset, found, scope, controller, args_open), outer in zip(ordered, enclosing):
|
|
469
|
+
# The route's own `->controller()` wins, then the innermost group's.
|
|
470
|
+
group_controller = controller
|
|
471
|
+
if group_controller is None and outer:
|
|
472
|
+
group_controller = next((g.controller for g in reversed(outer) if g.controller), None)
|
|
473
|
+
for methods, uri, action in found:
|
|
474
|
+
action = action or _controller_action(content, args_open, group_controller)
|
|
475
|
+
scan.routes.append(LaravelRoute(methods, uri, offset, scope, action, outer))
|
|
476
|
+
|
|
477
|
+
if mounts_ok:
|
|
478
|
+
scan.mounts = _mounts(content, relative, groups)
|
|
479
|
+
return scan
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
_BARE_ACTION = re.compile(r"\(\s*['\"][^'\"]*['\"]\s*,\s*['\"](\w+)['\"]")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _controller_action(content: str, args_open: int, controller: str | None) -> tuple[str, str] | None:
|
|
486
|
+
"""``Route::controller(C::class)->group(fn () => Route::get('/x', 'show'))``:
|
|
487
|
+
a bare method name as the verb's second argument (``args_open`` is its
|
|
488
|
+
``(``)."""
|
|
489
|
+
if controller is None:
|
|
490
|
+
return None
|
|
491
|
+
match = _BARE_ACTION.match(content, args_open, args_open + 500)
|
|
492
|
+
return (controller, match.group(1)) if match else None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def is_test_path(relative: str) -> bool:
|
|
496
|
+
parts = relative.split("/")
|
|
497
|
+
return any(part.lower() in _TEST_DIRS for part in parts[:-1]) or bool(_TEST_FILE.search(parts[-1]))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _api_ranges(content: str) -> list[tuple[int, int, str]]:
|
|
501
|
+
"""``(start, end, apiPrefix)`` for each ``withRouting(api: ...)``
|
|
502
|
+
argument: one file, or an array of files (Laravel 11)."""
|
|
503
|
+
ranges = []
|
|
504
|
+
pairs = ra.bracket_pairs(content)
|
|
505
|
+
for match in _WITH_ROUTING.finditer(content):
|
|
506
|
+
close = pairs.get(match.end() - 1, -1)
|
|
507
|
+
if close < 0:
|
|
508
|
+
continue
|
|
509
|
+
api_prefix = _API_PREFIX.search(content, match.end(), close)
|
|
510
|
+
prefix = api_prefix.group(1) if api_prefix else "api"
|
|
511
|
+
api_arg = _API_ARG.search(content, match.end(), close)
|
|
512
|
+
if api_arg is None:
|
|
513
|
+
continue
|
|
514
|
+
value = api_arg.end()
|
|
515
|
+
if content.startswith("[", value):
|
|
516
|
+
value_end = pairs.get(value, -1)
|
|
517
|
+
if value_end < 0:
|
|
518
|
+
continue
|
|
519
|
+
else:
|
|
520
|
+
value_end = _expr_end(content, value, close)
|
|
521
|
+
ranges.append((value, value_end, prefix))
|
|
522
|
+
if len(ranges) >= MAX_ROUTING_CALLS:
|
|
523
|
+
break
|
|
524
|
+
return ranges
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _mounts(content: str, relative: str, groups: list[LaravelGroup]) -> list[Mount]:
|
|
528
|
+
refs: list[tuple[int, str]] = []
|
|
529
|
+
for match in _MOUNT_REF.finditer(content):
|
|
530
|
+
if match.group(1) is not None:
|
|
531
|
+
target = posixpath.normpath(match.group(1).lstrip("/"))
|
|
532
|
+
else:
|
|
533
|
+
target = posixpath.normpath(posixpath.join(posixpath.dirname(relative), match.group(2).lstrip("/")))
|
|
534
|
+
if target.startswith("..") or target == relative:
|
|
535
|
+
continue
|
|
536
|
+
refs.append((match.start(), target))
|
|
537
|
+
if len(refs) >= MAX_MOUNT_REFS:
|
|
538
|
+
break
|
|
539
|
+
if not refs:
|
|
540
|
+
return []
|
|
541
|
+
api_ranges = _api_ranges(content) if "withRouting" in content else []
|
|
542
|
+
api_starts = [start for start, _, _ in api_ranges]
|
|
543
|
+
enclosing = ra.enclosing(groups, [offset for offset, _ in refs])
|
|
544
|
+
mounts = []
|
|
545
|
+
for (offset, target), outer in zip(refs, enclosing):
|
|
546
|
+
api = None
|
|
547
|
+
index = bisect.bisect_right(api_starts, offset) - 1
|
|
548
|
+
if index >= 0 and api_ranges[index][0] <= offset <= api_ranges[index][1]:
|
|
549
|
+
api = api_ranges[index]
|
|
550
|
+
lead = content[max(0, offset - 60) : offset]
|
|
551
|
+
# The file is loaded (not just named): a require/loadRoutesFrom/
|
|
552
|
+
# ->group( lead, inside a group, or a withRouting `api:` argument.
|
|
553
|
+
if api is None and outer == [] and not _MOUNT_LEAD.search(lead):
|
|
554
|
+
continue
|
|
555
|
+
if outer is not None:
|
|
556
|
+
outer = list(outer)
|
|
557
|
+
if api is not None:
|
|
558
|
+
# `->withRouting(api: [__DIR__.'/../routes/api.php', ...], apiPrefix: 'api/v1')`.
|
|
559
|
+
outer.append(LaravelGroup(api[0], api[1], prefix=(LITERAL, api[2])))
|
|
560
|
+
mounts.append(Mount(target, outer, bool(_ONCE_LEAD.search(lead))))
|
|
561
|
+
return mounts
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _join(prefixes: list[Prefix], uri: str) -> str:
|
|
565
|
+
"""Laravel's URI for ``uri`` under literal/dynamic ``prefixes``."""
|
|
566
|
+
if all(kind == LITERAL and not value.strip("/") for kind, value in prefixes):
|
|
567
|
+
return uri if uri.startswith("/") else "/" + uri
|
|
568
|
+
parts = []
|
|
569
|
+
for kind, value in prefixes:
|
|
570
|
+
if kind == LITERAL:
|
|
571
|
+
parts.append(value.strip("/"))
|
|
572
|
+
else:
|
|
573
|
+
parts.append(DYNAMIC_SEGMENT)
|
|
574
|
+
parts.append(uri.strip("/"))
|
|
575
|
+
return "/" + "/".join(part for part in parts if part)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def join_path(prefixes: list[Prefix], uri: str) -> str:
|
|
579
|
+
return _join(prefixes, uri)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def prefix_base(prefixes: list[Prefix]) -> str | None:
|
|
583
|
+
"""The joined prefix (``/a/b``), or None when every prefix is empty;
|
|
584
|
+
``join_base(prefix_base(p), uri) == join_path(p, uri)``."""
|
|
585
|
+
parts = [value.strip("/") if kind == LITERAL else DYNAMIC_SEGMENT for kind, value in prefixes]
|
|
586
|
+
parts = [part for part in parts if part]
|
|
587
|
+
return "/" + "/".join(parts) if parts else None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def join_base(base: str | None, uri: str) -> str:
|
|
591
|
+
if base is None:
|
|
592
|
+
return uri if uri.startswith("/") else "/" + uri
|
|
593
|
+
tail = uri.strip("/")
|
|
594
|
+
return base + "/" + tail if tail else base
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class MountResolver:
|
|
598
|
+
"""The outer group chains each route file is loaded under.
|
|
599
|
+
|
|
600
|
+
``sites[target]`` lists ``(source file, groups at the mount, once)``. A
|
|
601
|
+
file no one mounts is loaded at the root, except ``routes/api.php``,
|
|
602
|
+
which Laravel's default provider mounts under ``default_api``
|
|
603
|
+
(``/api``).
|
|
604
|
+
|
|
605
|
+
A mount that loops back to a file still being resolved closes a cycle:
|
|
606
|
+
if any mount on that cycle is ``require_once`` / ``include_once`` it is
|
|
607
|
+
a no-op (PHP skips the already-loaded file); otherwise the chain is
|
|
608
|
+
None (unresolved). A file mounted more than MAX_MOUNT_DEPTH levels below
|
|
609
|
+
a root is None. Results that depend on the in-progress path (cut by a
|
|
610
|
+
cycle, MAX_MOUNT_PATH or the MAX_MOUNT_VISITS edge budget) aren't
|
|
611
|
+
memoised, so the answer doesn't depend on which file is resolved first;
|
|
612
|
+
the budget bounds the total work.
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
def __init__(
|
|
616
|
+
self,
|
|
617
|
+
sites: dict[str, list[tuple[str, list[LaravelGroup] | None, bool]]],
|
|
618
|
+
default_api: Prefix,
|
|
619
|
+
framework_roots: frozenset[str] = frozenset(),
|
|
620
|
+
) -> None:
|
|
621
|
+
# The framework loads these before any route file runs, so a
|
|
622
|
+
# `require_once` of one from a route file is a no-op.
|
|
623
|
+
self.framework_roots = framework_roots
|
|
624
|
+
self.sites = {
|
|
625
|
+
target: [site for site in target_sites if not (site[2] and target in framework_roots)]
|
|
626
|
+
for target, target_sites in sites.items()
|
|
627
|
+
}
|
|
628
|
+
self.default_api = default_api
|
|
629
|
+
self._memo: dict[str, tuple[list[list[LaravelGroup] | None], int]] = {}
|
|
630
|
+
self._path: list[str] = [] # files being resolved, outermost first
|
|
631
|
+
self._edge_once: list[bool] = [] # once-ness of the mount from _path[i] to _path[i + 1]
|
|
632
|
+
self._visits = 0
|
|
633
|
+
|
|
634
|
+
def chains(self, target: str) -> list[list[LaravelGroup] | None]:
|
|
635
|
+
out, _, _ = self._chains(target)
|
|
636
|
+
# Loaded only through no-op `require_once` cycles: treat as a root.
|
|
637
|
+
return out or [[]]
|
|
638
|
+
|
|
639
|
+
def _chains(self, target: str) -> tuple[list[list[LaravelGroup] | None], int, bool]:
|
|
640
|
+
"""``(chains, height, truncated)``. ``height`` is the longest mount
|
|
641
|
+
path from a root, a property of the file itself, so the
|
|
642
|
+
MAX_MOUNT_DEPTH cut is memo-safe; ``truncated`` marks results cut by
|
|
643
|
+
the in-progress path or the budget, which aren't memoised."""
|
|
644
|
+
if target in self._memo:
|
|
645
|
+
chains, height = self._memo[target]
|
|
646
|
+
return chains, height, False
|
|
647
|
+
sites = self.sites.get(target)
|
|
648
|
+
if not sites:
|
|
649
|
+
if target == "routes/api.php":
|
|
650
|
+
return [[LaravelGroup(-1, -1, prefix=self.default_api)]], 0, False
|
|
651
|
+
return [[]], 0, False
|
|
652
|
+
if len(self._path) >= MAX_MOUNT_PATH or self._visits >= MAX_MOUNT_VISITS:
|
|
653
|
+
return [None], 0, True
|
|
654
|
+
self._path.append(target)
|
|
655
|
+
out: list[list[LaravelGroup] | None] = []
|
|
656
|
+
height = 0
|
|
657
|
+
truncated = False
|
|
658
|
+
exhausted = False
|
|
659
|
+
for source, groups, once in sites:
|
|
660
|
+
self._visits += 1
|
|
661
|
+
if self._visits > MAX_MOUNT_VISITS:
|
|
662
|
+
truncated = exhausted = True
|
|
663
|
+
break
|
|
664
|
+
if source in self._path:
|
|
665
|
+
truncated = True
|
|
666
|
+
start = self._path.index(source)
|
|
667
|
+
if once or any(self._edge_once[start:]):
|
|
668
|
+
continue # a no-op re-include
|
|
669
|
+
parents: list[list[LaravelGroup] | None] = [None]
|
|
670
|
+
else:
|
|
671
|
+
self._edge_once.append(once)
|
|
672
|
+
parents, parent_height, cut = self._chains(source)
|
|
673
|
+
self._edge_once.pop()
|
|
674
|
+
truncated = truncated or cut
|
|
675
|
+
height = max(height, parent_height + 1)
|
|
676
|
+
for parent in parents:
|
|
677
|
+
chain = None if parent is None or groups is None else parent + groups
|
|
678
|
+
if not any(chain is other or chain == other for other in out):
|
|
679
|
+
out.append(chain)
|
|
680
|
+
if len(out) >= MAX_MOUNT_CHAINS:
|
|
681
|
+
break
|
|
682
|
+
self._path.pop()
|
|
683
|
+
if not out and not exhausted and all(once and groups == [] for _, groups, once in sites):
|
|
684
|
+
# Every loader is an un-grouped `require_once` that yields
|
|
685
|
+
# nothing (a no-op re-include): the file was loaded first by
|
|
686
|
+
# something we don't see as a mount, so it is a root. A plain
|
|
687
|
+
# `require` that only closes a cycle, or a loader that wraps
|
|
688
|
+
# the include in a group, still contributes nothing.
|
|
689
|
+
out = [[]]
|
|
690
|
+
if height > MAX_MOUNT_DEPTH:
|
|
691
|
+
out = [None] # mounted deeper than we follow
|
|
692
|
+
if not truncated:
|
|
693
|
+
self._memo[target] = (out, height)
|
|
694
|
+
return out, height, truncated
|