attackmap-analyzer-php-laminas 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_laminas/__init__.py +4 -0
- attackmap_analyzer_php_laminas/analyzer.py +367 -0
- attackmap_analyzer_php_laminas/phparray.py +447 -0
- attackmap_analyzer_php_laminas/routes.py +623 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/METADATA +97 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/RECORD +10 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_php_laminas-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Structural Laminas router and Mezzio route extraction (mlaify/attackmap-analyzer-php-laminas#2).
|
|
2
|
+
|
|
3
|
+
Laminas router config is a tree::
|
|
4
|
+
|
|
5
|
+
'router' => ['routes' => [
|
|
6
|
+
'admin' => [
|
|
7
|
+
'type' => Literal::class,
|
|
8
|
+
'options' => ['route' => '/admin', 'defaults' => [...]],
|
|
9
|
+
'may_terminate' => true,
|
|
10
|
+
'child_routes' => [
|
|
11
|
+
'user' => ['type' => Segment::class, 'options' => ['route' => '/users[/:id]']],
|
|
12
|
+
'save' => ['type' => Method::class, 'options' => ['verb' => 'post']],
|
|
13
|
+
],
|
|
14
|
+
],
|
|
15
|
+
]]
|
|
16
|
+
|
|
17
|
+
:func:`router_routes` walks it with :mod:`.phparray` and returns one record per
|
|
18
|
+
reachable path:
|
|
19
|
+
|
|
20
|
+
- **child_routes are joined** onto their parent's path (`/admin/users`). A
|
|
21
|
+
parent is itself reachable only if it has no children or sets
|
|
22
|
+
`'may_terminate' => true`.
|
|
23
|
+
- **Method routes** (`Method::class`, `'verb' => 'post'`, comma lists) put
|
|
24
|
+
their HTTP method(s) on every form of the parent's path instead of adding
|
|
25
|
+
a path.
|
|
26
|
+
- **Segment optional parts** (`[/:action[/:id]]`) are expanded progressively:
|
|
27
|
+
the minimal form, then one more optional group opened at a time in source
|
|
28
|
+
order (`/users`, `/users/:id`). That is at most ``MAX_FORMS`` paths per route,
|
|
29
|
+
so `/:controller[/:action[/:id]]` yields 3 paths, not every combination.
|
|
30
|
+
A child path joins its parent's *maximal* form, because Laminas' parent
|
|
31
|
+
part consumes greedily (`/h[/:action]` + `/x` is `/h/:action/x`), unless
|
|
32
|
+
the child can match nothing (`[/:id]`), which keeps every parent form.
|
|
33
|
+
Method children cover every parent form. `:param{delims}` and
|
|
34
|
+
translated `{literal}` parts are reduced to `:param` and `literal`.
|
|
35
|
+
- `Hostname`/`Scheme` routes contribute no path; `Regex` routes use their
|
|
36
|
+
`spec` (`/install`, with `%param%` as `:param`). Part, Wildcard, Query and
|
|
37
|
+
console routes are skipped.
|
|
38
|
+
|
|
39
|
+
:func:`mezzio_routes` reads Mezzio/Expressive `$app->get('/x', ...)`,
|
|
40
|
+
`->post/put/patch/delete/any` and `$app->route('/x', $mw, ['GET', 'POST'])`.
|
|
41
|
+
FastRoute placeholders keep their name (`{id:\\d+}` becomes `{id}`) and
|
|
42
|
+
optional `[...]` parts expand the same way.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import bisect
|
|
48
|
+
import re
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
|
|
51
|
+
from .phparray import (
|
|
52
|
+
ARROW,
|
|
53
|
+
LPAREN,
|
|
54
|
+
OP,
|
|
55
|
+
OPEN,
|
|
56
|
+
WORD,
|
|
57
|
+
PhpArray,
|
|
58
|
+
PhpExpr,
|
|
59
|
+
PhpString,
|
|
60
|
+
PhpValue,
|
|
61
|
+
STR,
|
|
62
|
+
Token,
|
|
63
|
+
TokenList,
|
|
64
|
+
is_true,
|
|
65
|
+
keyed_array_spans,
|
|
66
|
+
parse_array_span,
|
|
67
|
+
parse_call_args,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
MAX_FORMS = 4
|
|
71
|
+
MAX_OPTIONAL_NESTING = 16
|
|
72
|
+
MAX_ROUTE_DEPTH = 32
|
|
73
|
+
MAX_ROUTES_PER_FILE = 5_000
|
|
74
|
+
|
|
75
|
+
_HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT"})
|
|
76
|
+
_PATH_KINDS = frozenset({"literal", "segment", None})
|
|
77
|
+
# `:param{-/}` delimiter spec → `:param`; translated `{literal}` → `literal`.
|
|
78
|
+
_SEGMENT_DELIMS = re.compile(r"(:[A-Za-z0-9_-]++)\{[^{}]*\}")
|
|
79
|
+
_SEGMENT_TRANSLATE = re.compile(r"\{([^{}:]*)\}")
|
|
80
|
+
_REGEX_SPEC_PARAM = re.compile(r"%([A-Za-z0-9_-]++)%")
|
|
81
|
+
_MULTI_SLASH = re.compile(r"//++")
|
|
82
|
+
_MEZZIO_VERBS = {"get": "GET", "post": "POST", "put": "PUT", "patch": "PATCH", "delete": "DELETE", "any": "ANY"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class LaminasRoute:
|
|
87
|
+
path: str
|
|
88
|
+
method: str
|
|
89
|
+
offset: int
|
|
90
|
+
name: str
|
|
91
|
+
template: str
|
|
92
|
+
defaults: dict[str, PhpValue] = field(default_factory=dict)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Optional-part expansion
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_optional(template: str) -> list:
|
|
101
|
+
"""Parse ``template`` into a list of str and nested lists (optional groups)."""
|
|
102
|
+
root: list = []
|
|
103
|
+
stack: list[list] = [root]
|
|
104
|
+
buf: list[str] = []
|
|
105
|
+
for ch in template:
|
|
106
|
+
if ch == "[" and len(stack) <= MAX_OPTIONAL_NESTING:
|
|
107
|
+
stack[-1].append("".join(buf))
|
|
108
|
+
buf = []
|
|
109
|
+
group: list = []
|
|
110
|
+
stack[-1].append(group)
|
|
111
|
+
stack.append(group)
|
|
112
|
+
elif ch == "]" and len(stack) > 1:
|
|
113
|
+
stack[-1].append("".join(buf))
|
|
114
|
+
buf = []
|
|
115
|
+
stack.pop()
|
|
116
|
+
else:
|
|
117
|
+
buf.append(ch)
|
|
118
|
+
stack[-1].append("".join(buf))
|
|
119
|
+
return root
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _groups_preorder(node: list, out: list[int]) -> None:
|
|
123
|
+
for part in node:
|
|
124
|
+
if isinstance(part, list):
|
|
125
|
+
out.append(id(part))
|
|
126
|
+
_groups_preorder(part, out)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _render(node: list, opened: set[int]) -> str:
|
|
130
|
+
out: list[str] = []
|
|
131
|
+
for part in node:
|
|
132
|
+
if isinstance(part, list):
|
|
133
|
+
if id(part) in opened:
|
|
134
|
+
out.append(_render(part, opened))
|
|
135
|
+
else:
|
|
136
|
+
out.append(part)
|
|
137
|
+
return "".join(out)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def expand_optional(template: str, max_forms: int = MAX_FORMS) -> list[str]:
|
|
141
|
+
"""Minimal form first, then one more optional group opened at a time.
|
|
142
|
+
|
|
143
|
+
``/:controller[/:action[/:id]]`` → ``/:controller``, ``/:controller/:action``,
|
|
144
|
+
``/:controller/:action/:id``. With more than ``max_forms - 1`` groups the
|
|
145
|
+
last form is the maximal one. Unbalanced brackets never raise: a stray `]`
|
|
146
|
+
is literal and an unclosed `[` closes at the end.
|
|
147
|
+
"""
|
|
148
|
+
if "[" not in template:
|
|
149
|
+
return [template]
|
|
150
|
+
tree = _parse_optional(template)
|
|
151
|
+
order: list[int] = []
|
|
152
|
+
_groups_preorder(tree, order)
|
|
153
|
+
counts = list(range(len(order) + 1))
|
|
154
|
+
if len(counts) > max_forms:
|
|
155
|
+
counts = counts[: max_forms - 1] + [len(order)]
|
|
156
|
+
forms: list[str] = []
|
|
157
|
+
for k in counts:
|
|
158
|
+
form = _render(tree, set(order[:k]))
|
|
159
|
+
if form not in forms:
|
|
160
|
+
forms.append(form)
|
|
161
|
+
return forms
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def maximal_form(template: str) -> str:
|
|
165
|
+
"""``template`` with every optional group filled in."""
|
|
166
|
+
if "[" not in template:
|
|
167
|
+
return template
|
|
168
|
+
tree = _parse_optional(template)
|
|
169
|
+
order: list[int] = []
|
|
170
|
+
_groups_preorder(tree, order)
|
|
171
|
+
return _render(tree, set(order))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def join_child(parent_template: str, piece: str) -> str:
|
|
175
|
+
"""The template a child route's ``piece`` makes under its parent.
|
|
176
|
+
|
|
177
|
+
Laminas' `Part::match` lets the parent consume greedily and never backs
|
|
178
|
+
off, so a child is reached only after the parent's optional parts are
|
|
179
|
+
filled: `/h[/:action]` + `/x` is `/h/:action/x` (`/h/x` sets action=x and
|
|
180
|
+
the child never matches). A child that can itself match nothing
|
|
181
|
+
(`[/:id]`) also leaves every parent form reachable, so it extends the
|
|
182
|
+
parent template as is: `/h[/:action][/:id]`."""
|
|
183
|
+
if minimal_form(piece) == "":
|
|
184
|
+
return parent_template + piece
|
|
185
|
+
return maximal_form(parent_template) + piece
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def minimal_form(template: str) -> str:
|
|
189
|
+
"""``template`` with every optional group left out."""
|
|
190
|
+
return _render(_parse_optional(template), set()) if "[" in template else template
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def normalize_path(path: str) -> str:
|
|
194
|
+
"""Collapse `//`, drop a trailing `/` (except the root) and ensure a leading `/`."""
|
|
195
|
+
path = _MULTI_SLASH.sub("/", path)
|
|
196
|
+
if not path.startswith("/"):
|
|
197
|
+
path = "/" + path
|
|
198
|
+
if len(path) > 1 and path.endswith("/"):
|
|
199
|
+
path = path.rstrip("/") or "/"
|
|
200
|
+
return path
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _segment_text(piece: str) -> str:
|
|
204
|
+
piece = _SEGMENT_DELIMS.sub(r"\1", piece)
|
|
205
|
+
return _SEGMENT_TRANSLATE.sub(r"\1", piece)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Laminas router config
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def route_type(value: PhpValue | None) -> str | None:
|
|
214
|
+
"""`Segment::class`, `'Laminas\\Router\\Http\\Literal'`, `'segment'` → `segment`."""
|
|
215
|
+
if isinstance(value, PhpString):
|
|
216
|
+
text = value.value
|
|
217
|
+
elif isinstance(value, PhpExpr):
|
|
218
|
+
text = value.text
|
|
219
|
+
else:
|
|
220
|
+
return None
|
|
221
|
+
text = text.strip()
|
|
222
|
+
if text.endswith("::class"):
|
|
223
|
+
text = text[: -len("::class")]
|
|
224
|
+
short = text.rsplit("\\", 1)[-1].strip().lower()
|
|
225
|
+
return short or None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class RouteNode:
|
|
230
|
+
"""What a named route passes to its children: its template (optional
|
|
231
|
+
parts included; see :func:`join_child`), the merged defaults and an
|
|
232
|
+
inherited Method verb. ``template`` None means ``prefix``."""
|
|
233
|
+
|
|
234
|
+
prefix: str
|
|
235
|
+
defaults: dict[str, PhpValue] = field(default_factory=dict)
|
|
236
|
+
method: str | None = None
|
|
237
|
+
template: str | None = None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def route_trees(tokens: list[Token]) -> list[PhpArray]:
|
|
241
|
+
"""The route maps in a file: every `'router' => ['routes' => [...]]`, or
|
|
242
|
+
else a file that just `return`s a route map.
|
|
243
|
+
|
|
244
|
+
`'router' => $router` and `'routes' => $routes` follow a same-file
|
|
245
|
+
`$routes = [...]` assignment (the last one before the use, else the first
|
|
246
|
+
after it)."""
|
|
247
|
+
trees: list[PhpArray] = []
|
|
248
|
+
variables = _VariableArrays(tokens)
|
|
249
|
+
seen: set[int] = set() # the same `$router` used twice is walked once
|
|
250
|
+
alive: list[PhpArray] = []
|
|
251
|
+
i = 0
|
|
252
|
+
n = len(tokens)
|
|
253
|
+
while i < n - 2:
|
|
254
|
+
tok = tokens[i]
|
|
255
|
+
if tok.kind != STR or tok.text != "router" or tokens[i + 1].kind != ARROW:
|
|
256
|
+
i += 1
|
|
257
|
+
continue
|
|
258
|
+
router, end = variables.value_at(i + 2)
|
|
259
|
+
# Continue after a literal router array: a nested `'router' => [`
|
|
260
|
+
# was parsed with it, and re-parsing would be quadratic.
|
|
261
|
+
i = max(end, i + 1)
|
|
262
|
+
if router is None or id(router) in seen:
|
|
263
|
+
continue
|
|
264
|
+
seen.add(id(router))
|
|
265
|
+
alive.append(router) # keep it alive so its id() isn't reused
|
|
266
|
+
routes = router.get("routes")
|
|
267
|
+
if isinstance(routes, PhpExpr):
|
|
268
|
+
routes = variables.resolve(routes)
|
|
269
|
+
if isinstance(routes, PhpArray) and id(routes) not in seen:
|
|
270
|
+
seen.add(id(routes))
|
|
271
|
+
trees.append(routes)
|
|
272
|
+
return trees or _returned_route_maps(tokens)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class _VariableArrays:
|
|
276
|
+
"""`$name = [...]` assignments in a file, parsed on demand."""
|
|
277
|
+
|
|
278
|
+
MAX_PARSES = 64
|
|
279
|
+
|
|
280
|
+
def __init__(self, tokens: list[Token]) -> None:
|
|
281
|
+
self.tokens = tokens
|
|
282
|
+
self.assignments: dict[str, list[int]] = {}
|
|
283
|
+
self.cache: dict[int, PhpArray | None] = {}
|
|
284
|
+
self.parses = 0
|
|
285
|
+
n = len(tokens)
|
|
286
|
+
for i in range(n - 2):
|
|
287
|
+
tok = tokens[i]
|
|
288
|
+
if (
|
|
289
|
+
tok.kind == WORD
|
|
290
|
+
and tok.text.startswith("$")
|
|
291
|
+
and tokens[i + 1].kind == OP
|
|
292
|
+
and tokens[i + 1].text == "="
|
|
293
|
+
and tokens[i + 2].kind == OPEN
|
|
294
|
+
):
|
|
295
|
+
self.assignments.setdefault(tok.text, []).append(i + 2)
|
|
296
|
+
|
|
297
|
+
def _array(self, index: int) -> PhpArray | None:
|
|
298
|
+
# Each assignment is parsed at most once, and at most MAX_PARSES are,
|
|
299
|
+
# so hostile files can't make variable lookups quadratic.
|
|
300
|
+
if index not in self.cache:
|
|
301
|
+
if self.parses >= self.MAX_PARSES:
|
|
302
|
+
return None
|
|
303
|
+
self.parses += 1
|
|
304
|
+
array, _ = parse_array_span(self.tokens, index)
|
|
305
|
+
self.cache[index] = array
|
|
306
|
+
return self.cache[index]
|
|
307
|
+
|
|
308
|
+
def value_at(self, index: int) -> tuple[PhpArray | None, int]:
|
|
309
|
+
"""The array literal at token ``index`` (and the index after it), or
|
|
310
|
+
the array a `$var` there holds (and ``index + 1``)."""
|
|
311
|
+
if index >= len(self.tokens):
|
|
312
|
+
return None, index
|
|
313
|
+
tok = self.tokens[index]
|
|
314
|
+
if tok.kind == OPEN:
|
|
315
|
+
return parse_array_span(self.tokens, index)
|
|
316
|
+
if tok.kind == WORD and tok.text.startswith("$"):
|
|
317
|
+
return self._lookup(tok.text, tok.offset), index + 1
|
|
318
|
+
return None, index + 1
|
|
319
|
+
|
|
320
|
+
def resolve(self, value: PhpExpr) -> PhpArray | None:
|
|
321
|
+
name = value.text.strip()
|
|
322
|
+
if not name.startswith("$") or " " in name:
|
|
323
|
+
return None
|
|
324
|
+
return self._lookup(name, value.offset)
|
|
325
|
+
|
|
326
|
+
def _lookup(self, name: str, offset: int) -> PhpArray | None:
|
|
327
|
+
positions = self.assignments.get(name)
|
|
328
|
+
if not positions:
|
|
329
|
+
return None
|
|
330
|
+
# The last assignment before the use, else the first one: token
|
|
331
|
+
# indices ascend with offsets, so bisect on both.
|
|
332
|
+
use = bisect.bisect_left(self.tokens, offset, key=lambda tok: tok.offset)
|
|
333
|
+
at = bisect.bisect_left(positions, use)
|
|
334
|
+
return self._array(positions[at - 1] if at > 0 else positions[0])
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def router_routes(
|
|
338
|
+
trees: list[PhpArray],
|
|
339
|
+
known: dict[str, RouteNode] | None = None,
|
|
340
|
+
index: dict[str, RouteNode] | None = None,
|
|
341
|
+
consumed: set[int] | None = None,
|
|
342
|
+
) -> list[LaminasRoute]:
|
|
343
|
+
"""Routes from a file's route maps (see :func:`route_trees`).
|
|
344
|
+
|
|
345
|
+
Laminas merges every module's config, so a module can add children to a
|
|
346
|
+
route defined elsewhere (`'admin' => ['child_routes' => [...]]` with no
|
|
347
|
+
type or route of its own). Such a stub is placed using ``known`` (full
|
|
348
|
+
route name → :class:`RouteNode`); without an entry its children are
|
|
349
|
+
skipped rather than reported as top-level paths. ``index``, when given,
|
|
350
|
+
collects a :class:`RouteNode` for every named route walked, to build
|
|
351
|
+
``known`` across files. ``consumed``, when given, collects the source
|
|
352
|
+
offsets of the `'route'`/`'spec'`/`'verb'` strings the walk used, so a
|
|
353
|
+
caller can tell which literals it never reached.
|
|
354
|
+
"""
|
|
355
|
+
out: list[LaminasRoute] = []
|
|
356
|
+
ctx = _WalkContext(out, known or {}, index, consumed)
|
|
357
|
+
for routes in trees:
|
|
358
|
+
_walk(routes, "", "", {}, None, 0, ctx)
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass
|
|
363
|
+
class _WalkContext:
|
|
364
|
+
out: list[LaminasRoute]
|
|
365
|
+
known: dict[str, RouteNode]
|
|
366
|
+
index: dict[str, RouteNode] | None
|
|
367
|
+
consumed: set[int] | None = None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _returned_route_maps(tokens: list[Token]) -> list[PhpArray]:
|
|
371
|
+
"""`return ['home' => ['type' => ..., 'options' => ['route' => ...]]];` in a
|
|
372
|
+
file `require`d as a router's routes."""
|
|
373
|
+
maps: list[PhpArray] = []
|
|
374
|
+
i = 0
|
|
375
|
+
while i < len(tokens) - 1:
|
|
376
|
+
if tokens[i].kind == WORD and tokens[i].text.lower() == "return" and tokens[i + 1].kind == OPEN:
|
|
377
|
+
array, end = parse_array_span(tokens, i + 1)
|
|
378
|
+
if array is not None and any(_looks_like_route_spec(v) for _, v in array.entries()):
|
|
379
|
+
maps.append(array)
|
|
380
|
+
# Skip the parsed array: re-parsing a nested `return [` would be quadratic.
|
|
381
|
+
i = max(end, i + 1)
|
|
382
|
+
continue
|
|
383
|
+
i += 1
|
|
384
|
+
return maps
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _looks_like_route_spec(value: PhpValue) -> bool:
|
|
388
|
+
if not isinstance(value, PhpArray):
|
|
389
|
+
return False
|
|
390
|
+
options = value.get("options")
|
|
391
|
+
return (
|
|
392
|
+
value.get("type") is not None
|
|
393
|
+
and isinstance(options, PhpArray)
|
|
394
|
+
and isinstance(options.get("route"), PhpString)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _walk(
|
|
399
|
+
routes: PhpArray,
|
|
400
|
+
parent_template: str,
|
|
401
|
+
prefix_name: str,
|
|
402
|
+
defaults: dict[str, PhpValue],
|
|
403
|
+
method: str | None,
|
|
404
|
+
depth: int,
|
|
405
|
+
ctx: _WalkContext,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""``parent_template`` keeps the parent's optional parts: child paths
|
|
408
|
+
join it with :func:`join_child`, Method children expand all its forms."""
|
|
409
|
+
if depth > MAX_ROUTE_DEPTH:
|
|
410
|
+
return
|
|
411
|
+
out = ctx.out
|
|
412
|
+
for name, spec in routes.entries():
|
|
413
|
+
if len(out) >= MAX_ROUTES_PER_FILE:
|
|
414
|
+
return
|
|
415
|
+
if not isinstance(spec, PhpArray):
|
|
416
|
+
continue
|
|
417
|
+
kind = route_type(spec.get("type"))
|
|
418
|
+
options = spec.get("options")
|
|
419
|
+
if not isinstance(options, PhpArray):
|
|
420
|
+
options = PhpArray(offset=spec.offset)
|
|
421
|
+
own_defaults = options.get("defaults")
|
|
422
|
+
full_name = f"{prefix_name}/{name}" if prefix_name else name
|
|
423
|
+
children = spec.get("child_routes")
|
|
424
|
+
terminates = not isinstance(children, PhpArray) or is_true(spec.get("may_terminate"))
|
|
425
|
+
|
|
426
|
+
if kind is None and options.get("route") is None and isinstance(children, PhpArray):
|
|
427
|
+
# A stub extending a route defined in another module's config.
|
|
428
|
+
base = ctx.known.get(full_name)
|
|
429
|
+
if base is None:
|
|
430
|
+
continue
|
|
431
|
+
merged = dict(base.defaults)
|
|
432
|
+
if isinstance(own_defaults, PhpArray):
|
|
433
|
+
merged.update(own_defaults.entries())
|
|
434
|
+
base_template = base.template if base.template is not None else base.prefix
|
|
435
|
+
_walk(children, base_template, full_name, merged, base.method, depth + 1, ctx)
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
merged = dict(defaults)
|
|
439
|
+
if isinstance(own_defaults, PhpArray):
|
|
440
|
+
merged.update(own_defaults.entries())
|
|
441
|
+
|
|
442
|
+
methods: list[str] | None = [method or "ANY"]
|
|
443
|
+
template = parent_template
|
|
444
|
+
offset = spec.offset
|
|
445
|
+
literal: PhpValue | None = None
|
|
446
|
+
if kind == "method":
|
|
447
|
+
literal = options.get("verb")
|
|
448
|
+
verbs = _verbs(literal)
|
|
449
|
+
if not verbs:
|
|
450
|
+
continue
|
|
451
|
+
# The verb applies to every form of the parent: `/user[/:id]`
|
|
452
|
+
# with a `delete` child is `DELETE /user` and `DELETE /user/:id`.
|
|
453
|
+
methods = verbs
|
|
454
|
+
offset = literal.offset if literal is not None else offset
|
|
455
|
+
elif kind in ("hostname", "scheme"):
|
|
456
|
+
methods = None # matches the host/scheme, not a path
|
|
457
|
+
elif kind == "regex":
|
|
458
|
+
literal = options.get("spec")
|
|
459
|
+
if not isinstance(literal, PhpString) or not literal.value.startswith("/"):
|
|
460
|
+
continue
|
|
461
|
+
template = join_child(parent_template, _REGEX_SPEC_PARAM.sub(r":\1", literal.value))
|
|
462
|
+
offset = literal.offset
|
|
463
|
+
elif kind in _PATH_KINDS:
|
|
464
|
+
literal = options.get("route")
|
|
465
|
+
if not isinstance(literal, PhpString) or not literal.value.startswith(("/", "[")):
|
|
466
|
+
continue
|
|
467
|
+
template = join_child(parent_template, _segment_text(literal.value))
|
|
468
|
+
offset = literal.offset
|
|
469
|
+
else:
|
|
470
|
+
continue # Part, Wildcard, Query, console `simple`, custom types
|
|
471
|
+
if ctx.consumed is not None and literal is not None:
|
|
472
|
+
ctx.consumed.add(literal.offset)
|
|
473
|
+
|
|
474
|
+
if terminates and methods is not None:
|
|
475
|
+
for form in expand_optional(template):
|
|
476
|
+
path = normalize_path(form)
|
|
477
|
+
for verb in methods:
|
|
478
|
+
out.append(LaminasRoute(path, verb, offset, full_name, template, merged))
|
|
479
|
+
child_method = methods[0] if kind == "method" and methods and len(methods) == 1 else method
|
|
480
|
+
if ctx.index is not None:
|
|
481
|
+
ctx.index.setdefault(full_name, RouteNode(maximal_form(template), merged, child_method, template))
|
|
482
|
+
if isinstance(children, PhpArray):
|
|
483
|
+
_walk(children, template, full_name, merged, child_method, depth + 1, ctx)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def fallback_route_literals(
|
|
487
|
+
content: str, tokens: TokenList, consumed: set[int], pattern: re.Pattern[str]
|
|
488
|
+
) -> list[tuple[str, int]]:
|
|
489
|
+
"""Flat `'route' => '/path'` matches (``pattern`` group 1 is the path) that
|
|
490
|
+
the structural walk didn't consume: routes built in ways it can't follow,
|
|
491
|
+
or past the tokenizer's cap (``tokens.truncated``). A literal inside a
|
|
492
|
+
tokenized `child_routes` array is only a fragment of a path and is left
|
|
493
|
+
out; past a truncation the arrays are unknown, so every literal there is
|
|
494
|
+
kept. Returns ``(path, match offset)`` pairs."""
|
|
495
|
+
spans = keyed_array_spans(tokens, "child_routes")
|
|
496
|
+
starts = [start for start, _ in spans]
|
|
497
|
+
found: list[tuple[str, int]] = []
|
|
498
|
+
for match in pattern.finditer(content):
|
|
499
|
+
literal = match.start(1) - 1
|
|
500
|
+
if literal in consumed:
|
|
501
|
+
continue
|
|
502
|
+
at = bisect.bisect_right(starts, literal) - 1
|
|
503
|
+
if at >= 0 and spans[at][0] <= literal < spans[at][1]:
|
|
504
|
+
continue
|
|
505
|
+
found.append((match.group(1), match.start()))
|
|
506
|
+
return found
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _verbs(value: PhpValue | None) -> list[str]:
|
|
510
|
+
if not isinstance(value, PhpString):
|
|
511
|
+
return []
|
|
512
|
+
verbs = [part.strip().upper() for part in value.value.split(",")]
|
|
513
|
+
return [verb for verb in verbs if verb in _HTTP_METHODS]
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
# Mezzio
|
|
518
|
+
# ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@dataclass
|
|
522
|
+
class MezzioRoute:
|
|
523
|
+
path: str
|
|
524
|
+
method: str
|
|
525
|
+
offset: int
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _strip_fastroute_regex(path: str) -> str:
|
|
529
|
+
"""`/a/{id:\\d{1,3}}[/{slug}]` → `/a/{id}[/{slug}]`."""
|
|
530
|
+
out: list[str] = []
|
|
531
|
+
i = 0
|
|
532
|
+
n = len(path)
|
|
533
|
+
while i < n:
|
|
534
|
+
ch = path[i]
|
|
535
|
+
if ch != "{":
|
|
536
|
+
out.append(ch)
|
|
537
|
+
i += 1
|
|
538
|
+
continue
|
|
539
|
+
j = i + 1
|
|
540
|
+
while j < n and path[j] not in ":}":
|
|
541
|
+
j += 1
|
|
542
|
+
out.append("{" + path[i + 1 : j] + "}")
|
|
543
|
+
if j < n and path[j] == ":":
|
|
544
|
+
depth = 1
|
|
545
|
+
j += 1
|
|
546
|
+
while j < n and depth:
|
|
547
|
+
if path[j] == "{":
|
|
548
|
+
depth += 1
|
|
549
|
+
elif path[j] == "}":
|
|
550
|
+
depth -= 1
|
|
551
|
+
j += 1
|
|
552
|
+
i = j
|
|
553
|
+
else:
|
|
554
|
+
i = j + 1
|
|
555
|
+
return "".join(out)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def mezzio_routes(tokens: list[Token]) -> list[MezzioRoute]:
|
|
559
|
+
"""`$app->get('/path', ...)` and friends, and `$app->route('/path', $mw, [methods])`."""
|
|
560
|
+
out: list[MezzioRoute] = []
|
|
561
|
+
n = len(tokens)
|
|
562
|
+
i = -1
|
|
563
|
+
while i + 1 < n - 4:
|
|
564
|
+
i += 1
|
|
565
|
+
if len(out) >= MAX_ROUTES_PER_FILE:
|
|
566
|
+
break
|
|
567
|
+
if not (
|
|
568
|
+
tokens[i].kind == WORD
|
|
569
|
+
and tokens[i].text == "$app"
|
|
570
|
+
and tokens[i + 1].kind == OP
|
|
571
|
+
and tokens[i + 1].text == "-"
|
|
572
|
+
and tokens[i + 2].kind == OP
|
|
573
|
+
and tokens[i + 2].text == ">"
|
|
574
|
+
and tokens[i + 3].kind == WORD
|
|
575
|
+
and tokens[i + 4].kind == LPAREN
|
|
576
|
+
):
|
|
577
|
+
continue
|
|
578
|
+
verb = tokens[i + 3].text.lower()
|
|
579
|
+
if verb != "route" and verb not in _MEZZIO_VERBS:
|
|
580
|
+
continue
|
|
581
|
+
args, end = parse_call_args(tokens, i + 4)
|
|
582
|
+
# Continue after the call: routes aren't registered inside another
|
|
583
|
+
# route's arguments, and re-parsing nested calls would be quadratic.
|
|
584
|
+
i = max(end, i + 5) - 1
|
|
585
|
+
if not args or not isinstance(args[0], PhpString) or not args[0].value.startswith("/"):
|
|
586
|
+
continue
|
|
587
|
+
if verb == "route":
|
|
588
|
+
methods = _route_methods(args[2] if len(args) > 2 else None)
|
|
589
|
+
else:
|
|
590
|
+
methods = [_MEZZIO_VERBS[verb]]
|
|
591
|
+
template = _strip_fastroute_regex(args[0].value)
|
|
592
|
+
for form in expand_optional(template):
|
|
593
|
+
path = normalize_path(form)
|
|
594
|
+
for method in methods:
|
|
595
|
+
out.append(MezzioRoute(path, method, args[0].offset))
|
|
596
|
+
return out
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _route_methods(value: PhpValue | None) -> list[str]:
|
|
600
|
+
if isinstance(value, PhpArray):
|
|
601
|
+
methods = [
|
|
602
|
+
v.value.strip().upper() for _, v in value.items if isinstance(v, PhpString)
|
|
603
|
+
]
|
|
604
|
+
methods = [m for m in methods if m in _HTTP_METHODS]
|
|
605
|
+
return methods or ["ANY"]
|
|
606
|
+
return ["ANY"] # null, Route::HTTP_METHOD_ANY, a variable
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
__all__ = [
|
|
610
|
+
"LaminasRoute",
|
|
611
|
+
"fallback_route_literals",
|
|
612
|
+
"join_child",
|
|
613
|
+
"maximal_form",
|
|
614
|
+
"MezzioRoute",
|
|
615
|
+
"RouteNode",
|
|
616
|
+
"route_trees",
|
|
617
|
+
"expand_optional",
|
|
618
|
+
"mezzio_routes",
|
|
619
|
+
"minimal_form",
|
|
620
|
+
"normalize_path",
|
|
621
|
+
"route_type",
|
|
622
|
+
"router_routes",
|
|
623
|
+
]
|