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.
@@ -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