attackmap-analyzer-java-spring 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_java_spring/__init__.py +3 -0
- attackmap_analyzer_java_spring/analyzer.py +1187 -0
- attackmap_analyzer_java_spring/annotations.py +464 -0
- attackmap_analyzer_java_spring/constants.py +278 -0
- attackmap_analyzer_java_spring/functional.py +171 -0
- attackmap_analyzer_java_spring/security_dsl.py +1119 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/METADATA +118 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/RECORD +12 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Same-repo string constants for Spring Security arguments (#11).
|
|
2
|
+
|
|
3
|
+
Real configurations name roles and paths through constants:
|
|
4
|
+
`hasAuthority(AuthoritiesConstants.ADMIN)`,
|
|
5
|
+
`@PreAuthorize("hasAuthority(\\"" + AuthoritiesConstants.ADMIN + "\\")")`,
|
|
6
|
+
`@Secured(Roles.ADMIN)`. This module records every member declaration of every
|
|
7
|
+
class, interface, enum, record and Kotlin object (companion objects count as
|
|
8
|
+
their class), keyed by `ClassName.NAME`. A key resolves only when every
|
|
9
|
+
declaration of it in the repo is a string constant with the same plain-literal
|
|
10
|
+
value:
|
|
11
|
+
|
|
12
|
+
- Java: a `static final String` field (implicitly static final in an
|
|
13
|
+
interface) initialised with string literals joined by `+`;
|
|
14
|
+
- Kotlin: `const val NAME[: String] = ` literals joined by `+`, on one line.
|
|
15
|
+
|
|
16
|
+
Any other declaration of the same `Class.NAME` poisons the key: a non-final or
|
|
17
|
+
non-String field, a field without a literal initialiser, a text block or raw
|
|
18
|
+
string, a `$template`, an enum constant, a `val`/`var`, or a class with the same
|
|
19
|
+
simple name in another package or nested elsewhere that declares the name
|
|
20
|
+
differently. So does hitting a size cap, which makes every lookup fail.
|
|
21
|
+
Unresolved references leave the caller's decision `unknown`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
from .annotations import BracketIndex, _find_body_open, split_top_level
|
|
29
|
+
|
|
30
|
+
MAX_CONSTANTS = 50_000
|
|
31
|
+
MAX_DECLARATIONS_PER_FILE = 5_000
|
|
32
|
+
MAX_TYPES_PER_FILE = 1_000
|
|
33
|
+
MAX_VALUE_CHARS = 256
|
|
34
|
+
MAX_EXPRESSION_CHARS = 2_000
|
|
35
|
+
MAX_PIECES = 32
|
|
36
|
+
|
|
37
|
+
_TYPE_DECL_RE = re.compile(r"(?<![\w.$@])(class|interface|enum|record|object)\s+([A-Za-z_]\w*)")
|
|
38
|
+
_ENUM_BEFORE_RE = re.compile(r"\benum\s*\Z")
|
|
39
|
+
_COMPANION_RE = re.compile(r"\bcompanion\s+object\b[^;{}]*\Z")
|
|
40
|
+
_ANNOTATION_USE_RE = re.compile(r'@[A-Za-z_][\w.]*(?:\s*\((?:[^()"]|"(?:[^"\\\n]|\\.)*")*\))?')
|
|
41
|
+
_IDENT_RE = re.compile(r"[A-Za-z_]\w*")
|
|
42
|
+
_KOTLIN_PROPERTY_RE = re.compile(r"\b(val|var)\s+([A-Za-z_]\w*)")
|
|
43
|
+
_KOTLIN_CONST_RE = re.compile(r"(?:^|\s)const\s+val\s+[A-Za-z_]\w*\s*(?::\s*(?:kotlin\.)?String\s*)?=(.*)\Z", re.DOTALL)
|
|
44
|
+
_STRING_LITERAL_RE = re.compile(r'"((?:[^"\\\n]|\\.)*)"')
|
|
45
|
+
# `AuthoritiesConstants.ADMIN` or `com.example.AuthoritiesConstants.ADMIN`.
|
|
46
|
+
_QUALIFIED_REF_RE = re.compile(r"(?:[A-Za-z_]\w*\.){0,16}([A-Za-z_]\w*)\.([A-Za-z_]\w*)")
|
|
47
|
+
_ESCAPE_RE = re.compile(r"\\(.)")
|
|
48
|
+
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r"}
|
|
49
|
+
_JAVA_MODIFIERS = frozenset(
|
|
50
|
+
{"public", "private", "protected", "static", "final", "transient", "volatile", "abstract", "default"}
|
|
51
|
+
)
|
|
52
|
+
_POISON = object()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unescape(value: str) -> str:
|
|
56
|
+
return _ESCAPE_RE.sub(lambda m: _ESCAPES.get(m.group(1), m.group(1)), value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConstantTable:
|
|
60
|
+
"""`ClassName.NAME` -> every value declared for it (or a poison marker)."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._values: dict[str, set[object]] = {}
|
|
64
|
+
self._count = 0
|
|
65
|
+
# A cap was hit: declarations were dropped and could conflict.
|
|
66
|
+
self.overflow = False
|
|
67
|
+
|
|
68
|
+
def add(self, key: str, value: object) -> None:
|
|
69
|
+
if self._count >= MAX_CONSTANTS:
|
|
70
|
+
self.overflow = True
|
|
71
|
+
return
|
|
72
|
+
if isinstance(value, str) and len(value) > MAX_VALUE_CHARS:
|
|
73
|
+
value = _POISON
|
|
74
|
+
self._count += 1
|
|
75
|
+
self._values.setdefault(key, set()).add(value)
|
|
76
|
+
|
|
77
|
+
def poison(self, key: str) -> None:
|
|
78
|
+
self.add(key, _POISON)
|
|
79
|
+
|
|
80
|
+
def lookup(self, key: str) -> str | None:
|
|
81
|
+
"""The value of ``key``, or None when it is unknown, ambiguous or poisoned."""
|
|
82
|
+
if self.overflow:
|
|
83
|
+
return None
|
|
84
|
+
values = self._values.get(key)
|
|
85
|
+
if values is None or len(values) != 1:
|
|
86
|
+
return None
|
|
87
|
+
value = next(iter(values))
|
|
88
|
+
return value if isinstance(value, str) else None
|
|
89
|
+
|
|
90
|
+
def collect(self, masked: str, *, kotlin: bool = False) -> None:
|
|
91
|
+
"""Record the member declarations of every type in one comment-masked file."""
|
|
92
|
+
brackets = BracketIndex(masked)
|
|
93
|
+
budget = [MAX_DECLARATIONS_PER_FILE]
|
|
94
|
+
for count, m in enumerate(_TYPE_DECL_RE.finditer(masked)):
|
|
95
|
+
if count >= MAX_TYPES_PER_FILE:
|
|
96
|
+
self.overflow = True
|
|
97
|
+
return
|
|
98
|
+
open_at = _find_body_open(masked, m.end())
|
|
99
|
+
if open_at is None:
|
|
100
|
+
continue
|
|
101
|
+
close = brackets.close(open_at)
|
|
102
|
+
if close is None:
|
|
103
|
+
self.overflow = True # unbalanced: we can't tell what's declared where
|
|
104
|
+
return
|
|
105
|
+
kind = m.group(1)
|
|
106
|
+
is_enum = kind == "enum" or (kotlin and _ENUM_BEFORE_RE.search(masked, max(0, m.start() - 40), m.start()))
|
|
107
|
+
statements = _member_statements(masked, open_at + 1, close - 1, brackets, kotlin)
|
|
108
|
+
self._record(m.group(2), statements, kotlin=kotlin, interface=kind == "interface" and not kotlin,
|
|
109
|
+
enum=bool(is_enum), budget=budget)
|
|
110
|
+
if budget[0] <= 0:
|
|
111
|
+
self.overflow = True
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
def _record(
|
|
115
|
+
self, cls: str, statements: list[str], *, kotlin: bool, interface: bool, enum: bool, budget: list[int]
|
|
116
|
+
) -> None:
|
|
117
|
+
for index, statement in enumerate(statements):
|
|
118
|
+
budget[0] -= 1
|
|
119
|
+
if budget[0] <= 0:
|
|
120
|
+
return
|
|
121
|
+
if enum and index == 0:
|
|
122
|
+
# Enum constants: `ADMIN, USER("x")` are members named like constants.
|
|
123
|
+
for element in split_top_level(statement):
|
|
124
|
+
ident = _IDENT_RE.match(element.strip())
|
|
125
|
+
if ident is not None:
|
|
126
|
+
self.poison(f"{cls}.{ident.group(0)}")
|
|
127
|
+
continue
|
|
128
|
+
if kotlin:
|
|
129
|
+
self._record_kotlin(cls, statement)
|
|
130
|
+
else:
|
|
131
|
+
self._record_java(cls, statement, interface)
|
|
132
|
+
|
|
133
|
+
def _record_kotlin(self, cls: str, statement: str) -> None:
|
|
134
|
+
prop = _KOTLIN_PROPERTY_RE.search(statement)
|
|
135
|
+
if prop is None:
|
|
136
|
+
return
|
|
137
|
+
key = f"{cls}.{prop.group(2)}"
|
|
138
|
+
const = _KOTLIN_CONST_RE.search(statement)
|
|
139
|
+
value = _literal_value(const.group(1)) if const is not None and prop.group(1) == "val" else None
|
|
140
|
+
if value is None:
|
|
141
|
+
self.poison(key)
|
|
142
|
+
else:
|
|
143
|
+
self.add(key, value)
|
|
144
|
+
|
|
145
|
+
def _record_java(self, cls: str, statement: str, interface: bool) -> None:
|
|
146
|
+
text = _ANNOTATION_USE_RE.sub(" ", statement).strip()
|
|
147
|
+
if not text or "(" in text.split("=", 1)[0]:
|
|
148
|
+
return # a method, constructor or initializer, not a field
|
|
149
|
+
declarators = split_top_level(text)
|
|
150
|
+
if not declarators:
|
|
151
|
+
return
|
|
152
|
+
head = declarators[0]
|
|
153
|
+
left, sep, right = _split_assignment(head)
|
|
154
|
+
tokens = _IDENT_RE.findall(left)
|
|
155
|
+
if len(tokens) < 2:
|
|
156
|
+
return # not `Type name` (a statement fragment)
|
|
157
|
+
name = tokens[-1]
|
|
158
|
+
modifiers = set(tokens[:-2])
|
|
159
|
+
type_name = tokens[-2]
|
|
160
|
+
is_constant = (interface or {"static", "final"} <= modifiers) and type_name == "String"
|
|
161
|
+
is_constant = is_constant and modifiers <= _JAVA_MODIFIERS and "[" not in left and "<" not in left
|
|
162
|
+
value = _literal_value(right) if sep and is_constant and len(declarators) == 1 else None
|
|
163
|
+
if value is None:
|
|
164
|
+
self.poison(f"{cls}.{name}")
|
|
165
|
+
else:
|
|
166
|
+
self.add(f"{cls}.{name}", value)
|
|
167
|
+
for other in declarators[1:]:
|
|
168
|
+
# `String A = "x", B = "y"`: every further declarator is unread.
|
|
169
|
+
other_left, _, _ = _split_assignment(other)
|
|
170
|
+
ident = _IDENT_RE.findall(other_left)
|
|
171
|
+
if ident:
|
|
172
|
+
self.poison(f"{cls}.{ident[-1]}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _split_assignment(text: str) -> tuple[str, str, str]:
|
|
176
|
+
"""Split at the first `=` that isn't part of `==`, `!=`, `<=`, `>=`."""
|
|
177
|
+
for i, c in enumerate(text):
|
|
178
|
+
if c == "=" and (i + 1 >= len(text) or text[i + 1] != "=") and (i == 0 or text[i - 1] not in "=!<>"):
|
|
179
|
+
return text[:i], "=", text[i + 1 :]
|
|
180
|
+
return text, "", ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _literal_value(expr: str) -> str | None:
|
|
184
|
+
"""String literals joined by `+`, or None (constants, calls, raw strings...)."""
|
|
185
|
+
return resolve_expression(expr, None)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _member_statements(masked: str, start: int, end: int, brackets: BracketIndex, kotlin: bool) -> list[str]:
|
|
189
|
+
"""The member-level statements of a type body.
|
|
190
|
+
|
|
191
|
+
Nested `{...}` blocks (method bodies, nested types, initialisers) end a
|
|
192
|
+
statement and are skipped, except a Kotlin `companion object`, whose
|
|
193
|
+
members belong to the enclosing class. Parentheses and brackets are
|
|
194
|
+
skipped whole, so lambdas in initialisers don't split statements.
|
|
195
|
+
Statements end at `;`, and in Kotlin also at a newline outside brackets.
|
|
196
|
+
"""
|
|
197
|
+
statements: list[str] = []
|
|
198
|
+
current: list[str] = []
|
|
199
|
+
i = start
|
|
200
|
+
while i < end:
|
|
201
|
+
c = masked[i]
|
|
202
|
+
if c in "([":
|
|
203
|
+
close = brackets.close(i)
|
|
204
|
+
if close is None or close > end:
|
|
205
|
+
current.append(masked[i:end])
|
|
206
|
+
break
|
|
207
|
+
current.append(masked[i:close])
|
|
208
|
+
i = close
|
|
209
|
+
continue
|
|
210
|
+
if c == '"' or c == "'":
|
|
211
|
+
j = i + 1
|
|
212
|
+
while j < end and masked[j] != c and masked[j] != "\n":
|
|
213
|
+
j += 2 if masked[j] == "\\" else 1
|
|
214
|
+
current.append(masked[i : j + 1])
|
|
215
|
+
i = j + 1
|
|
216
|
+
continue
|
|
217
|
+
if c == "{":
|
|
218
|
+
close = brackets.close(i)
|
|
219
|
+
if close is None or close > end:
|
|
220
|
+
break
|
|
221
|
+
prefix = "".join(current)
|
|
222
|
+
if _COMPANION_RE.search(prefix):
|
|
223
|
+
statements.extend(_member_statements(masked, i + 1, close - 1, brackets, kotlin))
|
|
224
|
+
elif _split_assignment(prefix)[1] == "=":
|
|
225
|
+
current.append(masked[i:close]) # `= { ... }` initializer: keep it in the statement
|
|
226
|
+
i = close
|
|
227
|
+
continue
|
|
228
|
+
current = []
|
|
229
|
+
i = close
|
|
230
|
+
continue
|
|
231
|
+
if c == ";" or (kotlin and c == "\n"):
|
|
232
|
+
text = "".join(current).strip()
|
|
233
|
+
if text:
|
|
234
|
+
statements.append(text)
|
|
235
|
+
current = []
|
|
236
|
+
else:
|
|
237
|
+
current.append(c)
|
|
238
|
+
i += 1
|
|
239
|
+
text = "".join(current).strip()
|
|
240
|
+
if text:
|
|
241
|
+
statements.append(text)
|
|
242
|
+
return statements
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
EMPTY = ConstantTable()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def resolve_expression(expr: str, table: ConstantTable | None) -> str | None:
|
|
249
|
+
"""The string an argument expression evaluates to, or None if it can't be read.
|
|
250
|
+
|
|
251
|
+
The expression is string literals and class-qualified constants from ``table``,
|
|
252
|
+
joined by `+` (`"hasAuthority(\\"" + AuthoritiesConstants.ADMIN + "\\")"`).
|
|
253
|
+
"""
|
|
254
|
+
expr = expr.strip()
|
|
255
|
+
if not expr or len(expr) > MAX_EXPRESSION_CHARS:
|
|
256
|
+
return None
|
|
257
|
+
pieces = split_top_level(expr, "+")
|
|
258
|
+
if not pieces or len(pieces) > MAX_PIECES:
|
|
259
|
+
return None
|
|
260
|
+
out: list[str] = []
|
|
261
|
+
for piece in pieces:
|
|
262
|
+
literal = _STRING_LITERAL_RE.fullmatch(piece)
|
|
263
|
+
if literal is not None:
|
|
264
|
+
if "$" in literal.group(1):
|
|
265
|
+
return None # a Kotlin string template
|
|
266
|
+
out.append(unescape(literal.group(1)))
|
|
267
|
+
continue
|
|
268
|
+
ref = _QUALIFIED_REF_RE.fullmatch(piece)
|
|
269
|
+
if ref is None or table is None:
|
|
270
|
+
return None
|
|
271
|
+
value = table.lookup(f"{ref.group(1)}.{ref.group(2)}")
|
|
272
|
+
if value is None:
|
|
273
|
+
return None
|
|
274
|
+
out.append(value)
|
|
275
|
+
return "".join(out)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
__all__ = ["EMPTY", "ConstantTable", "resolve_expression", "unescape"]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Spring WebFlux / WebMvc.fn functional endpoints (#3).
|
|
2
|
+
|
|
3
|
+
Shapes read:
|
|
4
|
+
|
|
5
|
+
- `RouterFunctions.route(GET("/x"), h).andRoute(POST("/y"), h)` and
|
|
6
|
+
`RequestPredicates.GET("/x").and(accept(JSON))`;
|
|
7
|
+
- `RouterFunctions.nest(path("/api"), route(...))` and `.andNest(path("/api"), ...)`;
|
|
8
|
+
- the builder: `route().GET("/x", h).POST("/y", accept(JSON), h)`,
|
|
9
|
+
`.path("/api", b -> b.GET(...))` and `.nest(path("/api"), b -> ...)`;
|
|
10
|
+
- Kotlin `router { }` / `coRouter { }` with `GET("/x", h)`, `"/api".nest { }`,
|
|
11
|
+
`path("/api").nest { }` and `accept(JSON).nest { }`.
|
|
12
|
+
|
|
13
|
+
A verb call whose path isn't a string literal (a constant, a Kotlin string
|
|
14
|
+
template) is skipped, and so is everything inside a nest whose prefix can't be
|
|
15
|
+
read, so a route is never reported under the wrong path. The caller gates on
|
|
16
|
+
the functional API being imported.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from .annotations import BracketIndex, bracket_end, split_top_level
|
|
25
|
+
|
|
26
|
+
# Imports of the functional API (WebFlux and WebMvc.fn), or its entry points.
|
|
27
|
+
FUNCTIONAL_GATE_RE = re.compile(
|
|
28
|
+
r"\borg\.springframework\.web\.(?:reactive\.function\.server|servlet\.function)\b"
|
|
29
|
+
r"|\bRouterFunctions\b|\bRequestPredicates\b"
|
|
30
|
+
)
|
|
31
|
+
# `GET("/x"`, `.GET("/x"`, `RequestPredicates.GET("/x"` but not `@GET("/x")`.
|
|
32
|
+
_VERB_CALL_RE = re.compile(r"(?<![\w@$])(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(")
|
|
33
|
+
# Java nest calls: `nest(pred, ...)`, `.andNest(pred, ...)`, builder `.nest(pred, b -> ...)`.
|
|
34
|
+
_NEST_CALL_RE = re.compile(r"(?<![\w$])(?:andNest|nest)\s*\(")
|
|
35
|
+
# Builder `.path("/api", b -> ...)`.
|
|
36
|
+
_BUILDER_PATH_RE = re.compile(r"\.\s*path\s*\(")
|
|
37
|
+
# Kotlin DSL `<receiver>.nest {` or infix `<receiver> nest {`.
|
|
38
|
+
_KOTLIN_NEST_RE = re.compile(r"(?<![\w$])nest\s*\{")
|
|
39
|
+
_PATH_PREDICATE_RE = re.compile(r"(?<![\w$])path\s*\(")
|
|
40
|
+
_STRING_LITERAL_RE = re.compile(r'"((?:[^"\\\n]|\\.)*)"')
|
|
41
|
+
_KOTLIN_RECEIVER_PATH_RE = re.compile(r'(?:path\s*\(\s*)?"([^"\\\n$]*)"\s*\)?')
|
|
42
|
+
|
|
43
|
+
MAX_SCOPES = 500
|
|
44
|
+
MAX_ROUTES = 2000
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FunctionalRoute:
|
|
49
|
+
method: str
|
|
50
|
+
path: str
|
|
51
|
+
offset: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _Scope:
|
|
56
|
+
start: int
|
|
57
|
+
end: int
|
|
58
|
+
# None when the prefix isn't a readable literal: routes inside are skipped.
|
|
59
|
+
prefix: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _literal(text: str) -> str | None:
|
|
63
|
+
m = _STRING_LITERAL_RE.fullmatch(text.strip())
|
|
64
|
+
if m is None or "$" in m.group(1):
|
|
65
|
+
return None
|
|
66
|
+
return m.group(1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _predicate_prefix(predicate: str) -> str | None:
|
|
70
|
+
"""Path prefix of a nest predicate: `path("/api")`, `path("/api").and(...)`,
|
|
71
|
+
`accept(JSON)` (no prefix). None when a `path(...)` isn't a literal."""
|
|
72
|
+
m = _PATH_PREDICATE_RE.search(predicate)
|
|
73
|
+
if m is None:
|
|
74
|
+
# A predicate that names a path some other way can't be read.
|
|
75
|
+
return None if _STRING_LITERAL_RE.search(predicate) else ""
|
|
76
|
+
close = bracket_end(predicate, m.end() - 1)
|
|
77
|
+
if close is None:
|
|
78
|
+
return None
|
|
79
|
+
return _literal(predicate[m.end() : close - 1])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _join(prefix: str, suffix: str) -> str:
|
|
83
|
+
if not prefix:
|
|
84
|
+
path = suffix
|
|
85
|
+
elif not suffix:
|
|
86
|
+
path = prefix
|
|
87
|
+
else:
|
|
88
|
+
path = prefix.rstrip("/") + "/" + suffix.lstrip("/")
|
|
89
|
+
path = path or "/"
|
|
90
|
+
return path if path.startswith("/") else "/" + path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _scopes(masked: str, brackets: BracketIndex) -> list[_Scope]:
|
|
94
|
+
scopes: list[_Scope] = []
|
|
95
|
+
for count, m in enumerate(_NEST_CALL_RE.finditer(masked)):
|
|
96
|
+
if count >= MAX_SCOPES:
|
|
97
|
+
break
|
|
98
|
+
close = brackets.close(m.end() - 1)
|
|
99
|
+
if close is None:
|
|
100
|
+
continue
|
|
101
|
+
parts = split_top_level(masked[m.end() : close - 1])
|
|
102
|
+
if len(parts) < 2:
|
|
103
|
+
continue
|
|
104
|
+
scopes.append(_Scope(m.end(), close - 1, _predicate_prefix(parts[0])))
|
|
105
|
+
for count, m in enumerate(_BUILDER_PATH_RE.finditer(masked)):
|
|
106
|
+
if count >= MAX_SCOPES:
|
|
107
|
+
break
|
|
108
|
+
close = brackets.close(m.end() - 1)
|
|
109
|
+
if close is None:
|
|
110
|
+
continue
|
|
111
|
+
parts = split_top_level(masked[m.end() : close - 1])
|
|
112
|
+
if len(parts) < 2:
|
|
113
|
+
# `RequestPredicates.path("/x")` is a predicate, not a scope.
|
|
114
|
+
continue
|
|
115
|
+
scopes.append(_Scope(m.end(), close - 1, _literal(parts[0])))
|
|
116
|
+
for count, m in enumerate(_KOTLIN_NEST_RE.finditer(masked)):
|
|
117
|
+
if count >= MAX_SCOPES:
|
|
118
|
+
break
|
|
119
|
+
brace = m.end() - 1
|
|
120
|
+
close = brackets.close(brace)
|
|
121
|
+
if close is None:
|
|
122
|
+
continue
|
|
123
|
+
line_start = masked.rfind("\n", 0, m.start()) + 1
|
|
124
|
+
receiver = masked[line_start : m.start()].strip().rstrip(".").strip()
|
|
125
|
+
scopes.append(_Scope(brace + 1, close - 1, _kotlin_receiver_prefix(receiver)))
|
|
126
|
+
return scopes
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _kotlin_receiver_prefix(receiver: str) -> str | None:
|
|
130
|
+
"""`"/api"` or `path("/api")` -> "/api"; a predicate call such as
|
|
131
|
+
`accept(JSON)` -> ""; anything else (a variable that may hold a path
|
|
132
|
+
String, a `"/x" and accept(...)` combination) -> None."""
|
|
133
|
+
m = _KOTLIN_RECEIVER_PATH_RE.fullmatch(receiver)
|
|
134
|
+
if m is not None:
|
|
135
|
+
return m.group(1)
|
|
136
|
+
if '"' in receiver or _PATH_PREDICATE_RE.search(receiver) or not receiver.endswith(")"):
|
|
137
|
+
return None
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def functional_routes(masked: str) -> list[FunctionalRoute]:
|
|
142
|
+
"""Functional routes in a comment-masked file, with nest prefixes applied."""
|
|
143
|
+
brackets = BracketIndex(masked)
|
|
144
|
+
scopes = _scopes(masked, brackets)
|
|
145
|
+
routes: list[FunctionalRoute] = []
|
|
146
|
+
for count, m in enumerate(_VERB_CALL_RE.finditer(masked)):
|
|
147
|
+
if count >= MAX_ROUTES:
|
|
148
|
+
break
|
|
149
|
+
close = brackets.close(m.end() - 1)
|
|
150
|
+
if close is None:
|
|
151
|
+
continue
|
|
152
|
+
parts = split_top_level(masked[m.end() : close - 1])
|
|
153
|
+
if not parts:
|
|
154
|
+
continue
|
|
155
|
+
suffix = _literal(parts[0])
|
|
156
|
+
if suffix is None:
|
|
157
|
+
continue
|
|
158
|
+
enclosing = sorted(
|
|
159
|
+
(s for s in scopes if s.start <= m.start() < s.end),
|
|
160
|
+
key=lambda s: (s.start, -s.end),
|
|
161
|
+
)
|
|
162
|
+
if any(s.prefix is None for s in enclosing):
|
|
163
|
+
continue
|
|
164
|
+
path = ""
|
|
165
|
+
for scope in enclosing:
|
|
166
|
+
path = _join(path, scope.prefix or "")
|
|
167
|
+
routes.append(FunctionalRoute(method=m.group(1), path=_join(path, suffix), offset=m.start()))
|
|
168
|
+
return routes
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ["FUNCTIONAL_GATE_RE", "FunctionalRoute", "functional_routes"]
|