spring-runtime-mcp 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.
- spring_runtime_mcp/__init__.py +3 -0
- spring_runtime_mcp/adapters/__init__.py +1 -0
- spring_runtime_mcp/adapters/beans.py +159 -0
- spring_runtime_mcp/adapters/conditions.py +333 -0
- spring_runtime_mcp/adapters/config.py +295 -0
- spring_runtime_mcp/adapters/drift.py +790 -0
- spring_runtime_mcp/adapters/health.py +164 -0
- spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
- spring_runtime_mcp/adapters/mappings.py +294 -0
- spring_runtime_mcp/adapters/migrations.py +373 -0
- spring_runtime_mcp/adapters/openapi.py +683 -0
- spring_runtime_mcp/adapters/source_catalog.py +119 -0
- spring_runtime_mcp/adapters/startup.py +202 -0
- spring_runtime_mcp/analyzers/__init__.py +1 -0
- spring_runtime_mcp/analyzers/bean_graph.py +172 -0
- spring_runtime_mcp/analyzers/condition_failures.py +359 -0
- spring_runtime_mcp/cli.py +248 -0
- spring_runtime_mcp/contracts/__init__.py +1 -0
- spring_runtime_mcp/contracts/definitions.py +425 -0
- spring_runtime_mcp/diagnostics.py +254 -0
- spring_runtime_mcp/models/__init__.py +1 -0
- spring_runtime_mcp/models/bean_graph.py +77 -0
- spring_runtime_mcp/models/common.py +99 -0
- spring_runtime_mcp/models/conditions.py +56 -0
- spring_runtime_mcp/models/queries.py +140 -0
- spring_runtime_mcp/models/source_catalog.py +35 -0
- spring_runtime_mcp/models/timestamps.py +71 -0
- spring_runtime_mcp/policy/__init__.py +1 -0
- spring_runtime_mcp/policy/configuration.py +32 -0
- spring_runtime_mcp/policy/output_guard.py +117 -0
- spring_runtime_mcp/policy/sanitization.py +616 -0
- spring_runtime_mcp/py.typed +0 -0
- spring_runtime_mcp/replay.py +220 -0
- spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
- spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
- spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/conformance/README.md +41 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
- spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
- spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
- spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
- spring_runtime_mcp/schemas/tools-contract.json +1902 -0
- spring_runtime_mcp/server.py +322 -0
- spring_runtime_mcp/services.py +44 -0
- spring_runtime_mcp/snapshots.py +340 -0
- spring_runtime_mcp/tools/__init__.py +1 -0
- spring_runtime_mcp/tools/analyze_startup.py +79 -0
- spring_runtime_mcp/tools/check_migrations.py +99 -0
- spring_runtime_mcp/tools/detect_drift.py +323 -0
- spring_runtime_mcp/tools/diagnose_health.py +88 -0
- spring_runtime_mcp/tools/inspect_beans.py +479 -0
- spring_runtime_mcp/tools/inspect_config.py +102 -0
- spring_runtime_mcp/tools/registry.py +48 -0
- spring_runtime_mcp/tools/search_openapi.py +79 -0
- spring_runtime_mcp/tools/support.py +166 -0
- spring_runtime_mcp/tools/verify_mappings.py +62 -0
- spring_runtime_mcp/transport/__init__.py +1 -0
- spring_runtime_mcp/transport/actuator_http.py +459 -0
- spring_runtime_mcp/transport/bounded_json.py +144 -0
- spring_runtime_mcp/transport/replay_files.py +71 -0
- spring_runtime_mcp/transport/response_gate.py +186 -0
- spring_runtime_mcp/transport/responses.py +82 -0
- spring_runtime_mcp/transport/stdio.py +44 -0
- spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
- spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
- spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
- spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Normalize Boot 2.x details trees and 3.x/4.x component trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..models.common import AnalysisBudget
|
|
9
|
+
|
|
10
|
+
_ABNORMAL = {"DOWN", "OUT_OF_SERVICE", "UNKNOWN"}
|
|
11
|
+
_CLASS = re.compile(r"(?:[A-Za-z_$][A-Za-z0-9_$]*\.)+[A-Za-z_$][A-Za-z0-9_$]*\Z")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _children(node: dict[str, Any]) -> dict[str, Any]:
|
|
15
|
+
components = node.get("components")
|
|
16
|
+
if isinstance(components, dict):
|
|
17
|
+
return components
|
|
18
|
+
details = node.get("details")
|
|
19
|
+
if isinstance(details, dict):
|
|
20
|
+
return {
|
|
21
|
+
str(key): value
|
|
22
|
+
for key, value in details.items()
|
|
23
|
+
if isinstance(value, dict) and "status" in value
|
|
24
|
+
}
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _scalars(
|
|
29
|
+
node: dict[str, Any], gaps: set[str], *, report_limit: bool
|
|
30
|
+
) -> list[dict[str, Any]]:
|
|
31
|
+
details = node.get("details")
|
|
32
|
+
if not isinstance(details, dict):
|
|
33
|
+
return []
|
|
34
|
+
rows: list[dict[str, Any]] = []
|
|
35
|
+
for key, value in sorted(details.items(), key=lambda item: str(item[0])):
|
|
36
|
+
if isinstance(value, dict) and "status" in value:
|
|
37
|
+
continue
|
|
38
|
+
finite = (
|
|
39
|
+
isinstance(value, bool) or not isinstance(value, float) or value == value
|
|
40
|
+
)
|
|
41
|
+
if (isinstance(value, (str, int, float, bool)) or value is None) and finite:
|
|
42
|
+
rows.append({"name": str(key)[:128], "value": value})
|
|
43
|
+
if len(rows) > 10:
|
|
44
|
+
if report_limit:
|
|
45
|
+
gaps.add("HEALTH_DETAILS_LIMIT")
|
|
46
|
+
return rows[:10]
|
|
47
|
+
return rows
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _error_type(node: dict[str, Any]) -> str | None:
|
|
51
|
+
details = node.get("details")
|
|
52
|
+
raw = details.get("error") if isinstance(details, dict) else None
|
|
53
|
+
if not isinstance(raw, str) or not raw:
|
|
54
|
+
return None
|
|
55
|
+
tokens = raw.split(":", 1)[0].strip().split()
|
|
56
|
+
if not tokens:
|
|
57
|
+
return None
|
|
58
|
+
token = tokens[-1]
|
|
59
|
+
if len(token) <= 256 and _CLASS.fullmatch(token):
|
|
60
|
+
return token
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _codes(path: tuple[str, ...], hidden: bool) -> list[str]:
|
|
65
|
+
if hidden:
|
|
66
|
+
return ["REVIEW_HEALTH_DETAILS_VISIBILITY"]
|
|
67
|
+
names = " ".join(path).casefold()
|
|
68
|
+
if any(token in names for token in ("db", "database", "datasource", "sql")):
|
|
69
|
+
return ["REVIEW_DATABASE_CONNECTIVITY"]
|
|
70
|
+
if any(token in names for token in ("disk", "diskspace")):
|
|
71
|
+
return ["REVIEW_DISK_SPACE"]
|
|
72
|
+
if any(token in names for token in ("redis", "kafka", "rabbit", "mongo")):
|
|
73
|
+
return ["REVIEW_MESSAGE_DEPENDENCY"]
|
|
74
|
+
return ["REVIEW_HEALTH_COMPONENT"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalize_health(
|
|
78
|
+
body: Any,
|
|
79
|
+
*,
|
|
80
|
+
component_path: tuple[str, ...],
|
|
81
|
+
include_details: bool,
|
|
82
|
+
include_healthy: bool,
|
|
83
|
+
max_depth: int,
|
|
84
|
+
) -> tuple[str | None, list[dict[str, Any]], list[str]]:
|
|
85
|
+
if not isinstance(body, dict):
|
|
86
|
+
raise ValueError("INVALID_HEALTH_SHAPE")
|
|
87
|
+
overall = body.get("status") if isinstance(body.get("status"), str) else None
|
|
88
|
+
found: list[dict[str, Any]] = []
|
|
89
|
+
gaps: set[str] = set()
|
|
90
|
+
budget = AnalysisBudget()
|
|
91
|
+
|
|
92
|
+
def walk(node: Any, path: tuple[str, ...], depth: int) -> None:
|
|
93
|
+
budget.consume()
|
|
94
|
+
if not isinstance(node, dict):
|
|
95
|
+
gaps.add("HEALTH_COMPONENT_UNREADABLE")
|
|
96
|
+
return
|
|
97
|
+
children = _children(node)
|
|
98
|
+
if "components" in node and not isinstance(node["components"], dict):
|
|
99
|
+
gaps.add("HEALTH_COMPONENT_UNREADABLE")
|
|
100
|
+
raw_status = node.get("status")
|
|
101
|
+
status = raw_status if isinstance(raw_status, str) and raw_status else "UNKNOWN"
|
|
102
|
+
if not isinstance(raw_status, str) or not raw_status:
|
|
103
|
+
gaps.add("HEALTH_STATUS_UNOBSERVED")
|
|
104
|
+
scalars = _scalars(node, gaps, report_limit=include_details)
|
|
105
|
+
# Root UP is an observed aggregate status, not evidence that components
|
|
106
|
+
# are absent. Explicit components={} is a reported empty inventory.
|
|
107
|
+
hidden_root = (
|
|
108
|
+
depth == 0 and "components" not in node and not children and not scalars
|
|
109
|
+
)
|
|
110
|
+
hidden = hidden_root or (status != "UP" and not children and not scalars)
|
|
111
|
+
if hidden:
|
|
112
|
+
gaps.add("HEALTH_DETAILS_NOT_VISIBLE")
|
|
113
|
+
if depth > max_depth:
|
|
114
|
+
gaps.add("HEALTH_DEPTH_LIMIT")
|
|
115
|
+
return
|
|
116
|
+
selected = (
|
|
117
|
+
not component_path or path[: len(component_path)] == component_path
|
|
118
|
+
) and (depth > 0 or not children)
|
|
119
|
+
visible = not component_path or path[: len(component_path)] == component_path
|
|
120
|
+
if selected and visible and (include_healthy or status != "UP" or hidden):
|
|
121
|
+
found.append(
|
|
122
|
+
{
|
|
123
|
+
"path": path,
|
|
124
|
+
"status": status[:64],
|
|
125
|
+
"details": scalars if include_details else [],
|
|
126
|
+
"errorType": _error_type(node),
|
|
127
|
+
"remediationCodes": _codes(path, hidden),
|
|
128
|
+
"childrenComplete": not hidden and depth < max_depth,
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
if depth >= max_depth and children:
|
|
132
|
+
gaps.add("HEALTH_DEPTH_LIMIT")
|
|
133
|
+
return
|
|
134
|
+
for name in sorted(children):
|
|
135
|
+
if (
|
|
136
|
+
len(path) + 1 > 16
|
|
137
|
+
or len(name) > 128
|
|
138
|
+
or any(ord(char) < 32 for char in name)
|
|
139
|
+
):
|
|
140
|
+
gaps.add("HEALTH_COMPONENT_UNREADABLE")
|
|
141
|
+
continue
|
|
142
|
+
walk(children[name], path + (name,), depth + 1)
|
|
143
|
+
|
|
144
|
+
walk(body, (), 0)
|
|
145
|
+
items = []
|
|
146
|
+
previous: dict[tuple[str, ...], str] = {}
|
|
147
|
+
for index, row in enumerate(found, start=1):
|
|
148
|
+
identifier = f"health_{index:06d}"
|
|
149
|
+
path = row["path"]
|
|
150
|
+
previous[path] = identifier
|
|
151
|
+
parent = previous.get(path[:-1]) if path else None
|
|
152
|
+
items.append(
|
|
153
|
+
{
|
|
154
|
+
"id": identifier,
|
|
155
|
+
"componentPath": list(path),
|
|
156
|
+
"parentId": parent,
|
|
157
|
+
"status": row["status"],
|
|
158
|
+
"details": row["details"],
|
|
159
|
+
"errorType": row["errorType"],
|
|
160
|
+
"remediationCodes": row["remediationCodes"],
|
|
161
|
+
"childrenComplete": row["childrenComplete"],
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
return overall[:64] if isinstance(overall, str) else None, items, sorted(gaps)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Bounded projection of known positive WebFlux predicate conjunctions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..models.common import AnalysisBudget
|
|
9
|
+
|
|
10
|
+
HTTP_METHODS = frozenset(
|
|
11
|
+
{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"}
|
|
12
|
+
)
|
|
13
|
+
_MAX_LENGTH = 4096
|
|
14
|
+
_MAX_DEPTH = 16
|
|
15
|
+
_MAX_CLAUSES = 32
|
|
16
|
+
_MAX_CONDITIONS = 16
|
|
17
|
+
_LEGACY_CONDITION = re.compile(
|
|
18
|
+
r"(?P<kind>consumes|produces|headers|params)\s*\[(?P<value>.*)\]\Z"
|
|
19
|
+
)
|
|
20
|
+
_LEGACY_KIND = {
|
|
21
|
+
"consumes": "consumes",
|
|
22
|
+
"produces": "produces",
|
|
23
|
+
"headers": "header",
|
|
24
|
+
"params": "param",
|
|
25
|
+
}
|
|
26
|
+
# Quoted parameters and custom header predicates remain unsupported. Keeping
|
|
27
|
+
# alternatives in one header row preserves their set rather than inventing ANDs.
|
|
28
|
+
_TOKEN = r"[A-Za-z0-9!#$%&'*+.^_`|~-]+"
|
|
29
|
+
_MEDIA = re.compile(rf"{_TOKEN}/{_TOKEN}(?:;{_TOKEN}={_TOKEN})*\Z")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _UnsupportedPredicate(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Conjunction:
|
|
37
|
+
def __init__(self, text: str) -> None:
|
|
38
|
+
self.text = text
|
|
39
|
+
self.position = 0
|
|
40
|
+
self.nodes = 0
|
|
41
|
+
self.leaves: list[str] = []
|
|
42
|
+
self.budget = AnalysisBudget()
|
|
43
|
+
|
|
44
|
+
def _space(self) -> None:
|
|
45
|
+
while self.position < len(self.text) and self.text[self.position] == " ":
|
|
46
|
+
self.position += 1
|
|
47
|
+
|
|
48
|
+
def expression(self, depth: int = 0) -> None:
|
|
49
|
+
self.atom(depth)
|
|
50
|
+
while True:
|
|
51
|
+
self._space()
|
|
52
|
+
if self.text.startswith("&&", self.position):
|
|
53
|
+
self.position += 2
|
|
54
|
+
elif self.text.startswith(",", self.position):
|
|
55
|
+
# Retain the existing flat request-mapping spelling as well.
|
|
56
|
+
self.position += 1
|
|
57
|
+
else:
|
|
58
|
+
return
|
|
59
|
+
self.atom(depth)
|
|
60
|
+
|
|
61
|
+
def atom(self, depth: int) -> None:
|
|
62
|
+
self.budget.consume()
|
|
63
|
+
self.nodes += 1
|
|
64
|
+
if depth > _MAX_DEPTH or self.nodes > 2 * _MAX_CLAUSES:
|
|
65
|
+
raise _UnsupportedPredicate
|
|
66
|
+
self._space()
|
|
67
|
+
if self.position >= len(self.text):
|
|
68
|
+
raise _UnsupportedPredicate
|
|
69
|
+
if self.text[self.position] == "(":
|
|
70
|
+
if depth >= _MAX_DEPTH:
|
|
71
|
+
raise _UnsupportedPredicate
|
|
72
|
+
self.position += 1
|
|
73
|
+
self.expression(depth + 1)
|
|
74
|
+
self._space()
|
|
75
|
+
if not self.text.startswith(")", self.position):
|
|
76
|
+
raise _UnsupportedPredicate
|
|
77
|
+
self.position += 1
|
|
78
|
+
return
|
|
79
|
+
start = self.position
|
|
80
|
+
delimiters: list[str] = []
|
|
81
|
+
while self.position < len(self.text):
|
|
82
|
+
char = self.text[self.position]
|
|
83
|
+
if not delimiters and (
|
|
84
|
+
char in "()," or self.text.startswith(("&&", "||"), self.position)
|
|
85
|
+
):
|
|
86
|
+
break
|
|
87
|
+
if char in "[{" or (char == "(" and delimiters):
|
|
88
|
+
delimiters.append({"[": "]", "{": "}", "(": ")"}[char])
|
|
89
|
+
if depth + len(delimiters) > _MAX_DEPTH:
|
|
90
|
+
raise _UnsupportedPredicate
|
|
91
|
+
elif char in "]})":
|
|
92
|
+
if not delimiters or delimiters.pop() != char:
|
|
93
|
+
raise _UnsupportedPredicate
|
|
94
|
+
self.position += 1
|
|
95
|
+
leaf = self.text[start : self.position].strip()
|
|
96
|
+
if not leaf or delimiters or len(self.leaves) >= _MAX_CLAUSES:
|
|
97
|
+
raise _UnsupportedPredicate
|
|
98
|
+
self.leaves.append(leaf)
|
|
99
|
+
|
|
100
|
+
def parse(self) -> list[str]:
|
|
101
|
+
self.expression()
|
|
102
|
+
self._space()
|
|
103
|
+
if self.position != len(self.text):
|
|
104
|
+
raise _UnsupportedPredicate
|
|
105
|
+
return self.leaves
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _media_condition(leaf: str) -> dict[str, Any] | None:
|
|
109
|
+
name, separator, value = leaf.partition(":")
|
|
110
|
+
if name not in {"Accept", "Content-Type"} or not separator:
|
|
111
|
+
return None
|
|
112
|
+
value = value.strip()
|
|
113
|
+
if value.startswith("[") and value.endswith("]"):
|
|
114
|
+
value = value[1:-1]
|
|
115
|
+
media = [part.strip() for part in value.split(",")]
|
|
116
|
+
value = ",".join(media)
|
|
117
|
+
if (
|
|
118
|
+
not media
|
|
119
|
+
or len(value) > 256
|
|
120
|
+
or any(not _MEDIA.fullmatch(part) for part in media)
|
|
121
|
+
):
|
|
122
|
+
raise _UnsupportedPredicate
|
|
123
|
+
return {"kind": "header", "name": name, "value": value, "negated": False}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_predicate(
|
|
127
|
+
raw: Any,
|
|
128
|
+
) -> tuple[list[str], list[str], list[dict[str, Any]], bool]:
|
|
129
|
+
"""Return no asserted facts unless every positive AND clause is understood.
|
|
130
|
+
|
|
131
|
+
Different path predicates in an AND may describe intersections and cannot be
|
|
132
|
+
emitted as a list of alternative paths. OR/NOT/custom clauses likewise stay
|
|
133
|
+
unknown instead of turning branches into nonexistent method/path pairs.
|
|
134
|
+
"""
|
|
135
|
+
if (
|
|
136
|
+
not isinstance(raw, str)
|
|
137
|
+
or not raw
|
|
138
|
+
or len(raw) > _MAX_LENGTH
|
|
139
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in raw)
|
|
140
|
+
):
|
|
141
|
+
return [], [], [], False
|
|
142
|
+
text = raw.strip()
|
|
143
|
+
if text.startswith("{") and text.endswith("}"):
|
|
144
|
+
text = text[1:-1]
|
|
145
|
+
paths: list[str] = []
|
|
146
|
+
methods: set[str] | None = None
|
|
147
|
+
conditions: list[dict[str, Any]] = []
|
|
148
|
+
try:
|
|
149
|
+
for leaf in _Conjunction(text).parse():
|
|
150
|
+
token, _, rest = leaf.partition(" ")
|
|
151
|
+
method_set: set[str] | None = None
|
|
152
|
+
if leaf.startswith("[") and leaf.endswith("]"):
|
|
153
|
+
values = [part.strip() for part in leaf[1:-1].split(",")]
|
|
154
|
+
if values and all(value in HTTP_METHODS for value in values):
|
|
155
|
+
method_set = set(values)
|
|
156
|
+
rest = ""
|
|
157
|
+
elif token.upper() in HTTP_METHODS:
|
|
158
|
+
method_set = {token.upper()}
|
|
159
|
+
rest = rest.strip()
|
|
160
|
+
if method_set is not None:
|
|
161
|
+
methods = method_set if methods is None else methods & method_set
|
|
162
|
+
if not methods:
|
|
163
|
+
raise _UnsupportedPredicate
|
|
164
|
+
if not rest:
|
|
165
|
+
continue
|
|
166
|
+
leaf = (
|
|
167
|
+
rest[1:-1].strip()
|
|
168
|
+
if rest.startswith("[") and rest.endswith("]")
|
|
169
|
+
else rest
|
|
170
|
+
)
|
|
171
|
+
if not leaf.startswith("/"):
|
|
172
|
+
raise _UnsupportedPredicate
|
|
173
|
+
if leaf.startswith("/"):
|
|
174
|
+
if len(leaf) > 1024 or any(char.isspace() for char in leaf):
|
|
175
|
+
raise _UnsupportedPredicate
|
|
176
|
+
if paths and leaf not in paths:
|
|
177
|
+
raise _UnsupportedPredicate
|
|
178
|
+
if not paths:
|
|
179
|
+
paths.append(leaf)
|
|
180
|
+
continue
|
|
181
|
+
condition = _media_condition(leaf)
|
|
182
|
+
if condition is None:
|
|
183
|
+
matched = _LEGACY_CONDITION.fullmatch(leaf)
|
|
184
|
+
if matched is None or len(matched.group("value")) > 256:
|
|
185
|
+
raise _UnsupportedPredicate
|
|
186
|
+
condition = {
|
|
187
|
+
"kind": _LEGACY_KIND[matched.group("kind")],
|
|
188
|
+
"name": None,
|
|
189
|
+
"value": matched.group("value").strip() or None,
|
|
190
|
+
"negated": False,
|
|
191
|
+
}
|
|
192
|
+
if len(conditions) >= _MAX_CONDITIONS:
|
|
193
|
+
raise _UnsupportedPredicate
|
|
194
|
+
conditions.append(condition)
|
|
195
|
+
except _UnsupportedPredicate:
|
|
196
|
+
return [], [], [], False
|
|
197
|
+
if not paths:
|
|
198
|
+
# A method/header-only predicate leaves the path unrestricted. The
|
|
199
|
+
# current route projection cannot express that for exact path queries.
|
|
200
|
+
return [], [], [], False
|
|
201
|
+
return paths, sorted(methods or ()), conditions, True
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Normalize MVC dispatcherServlets and WebFlux dispatcherHandlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..models.common import AnalysisBudget
|
|
8
|
+
|
|
9
|
+
from ..contracts.definitions import glob_matches
|
|
10
|
+
|
|
11
|
+
from .mapping_predicates import (
|
|
12
|
+
HTTP_METHODS as _METHODS,
|
|
13
|
+
parse_predicate as _parse_predicate,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _servlet_may_cover(query: str | None, mapping: str) -> bool:
|
|
18
|
+
if query is None or mapping in {"/", "/*", "*"}:
|
|
19
|
+
return True
|
|
20
|
+
if not mapping.startswith("/"):
|
|
21
|
+
return False
|
|
22
|
+
mapping_prefix = mapping.split("*", 1)[0]
|
|
23
|
+
query_prefix = query.split("*", 1)[0]
|
|
24
|
+
if mapping_prefix.startswith(query_prefix) or query_prefix.startswith(
|
|
25
|
+
mapping_prefix
|
|
26
|
+
):
|
|
27
|
+
return True
|
|
28
|
+
return glob_matches(mapping, query) or glob_matches(query, mapping)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _non_dispatcher_routes_unobserved(
|
|
32
|
+
mappings: dict[str, Any], path_pattern: str | None
|
|
33
|
+
) -> bool:
|
|
34
|
+
"""Actuator mappings only enumerate DispatcherServlet routes.
|
|
35
|
+
|
|
36
|
+
A Jersey/other ServletContainer registered under a matching URL space means
|
|
37
|
+
the absence of a DispatcherServlet record is not evidence of absence.
|
|
38
|
+
"""
|
|
39
|
+
servlets = mappings.get("servlets")
|
|
40
|
+
if servlets is None:
|
|
41
|
+
return False
|
|
42
|
+
if not isinstance(servlets, list):
|
|
43
|
+
return True
|
|
44
|
+
for servlet in servlets:
|
|
45
|
+
if not isinstance(servlet, dict):
|
|
46
|
+
continue
|
|
47
|
+
if (
|
|
48
|
+
servlet.get("className")
|
|
49
|
+
== "org.springframework.web.servlet.DispatcherServlet"
|
|
50
|
+
):
|
|
51
|
+
continue
|
|
52
|
+
raw = servlet.get("mappings")
|
|
53
|
+
if not isinstance(raw, list):
|
|
54
|
+
return True
|
|
55
|
+
for mapping in raw:
|
|
56
|
+
if isinstance(mapping, str) and _servlet_may_cover(path_pattern, mapping):
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _conditions(raw: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
|
|
62
|
+
rows: list[dict[str, Any]] = []
|
|
63
|
+
complete = True
|
|
64
|
+
for kind, key in (
|
|
65
|
+
("consumes", "consumes"),
|
|
66
|
+
("produces", "produces"),
|
|
67
|
+
("header", "headers"),
|
|
68
|
+
("param", "params"),
|
|
69
|
+
):
|
|
70
|
+
values = raw.get(key, [])
|
|
71
|
+
if not isinstance(values, list):
|
|
72
|
+
complete = False
|
|
73
|
+
continue
|
|
74
|
+
for value in values:
|
|
75
|
+
if len(rows) >= 16:
|
|
76
|
+
complete = False
|
|
77
|
+
break
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
media = value.get("mediaType", value.get("value"))
|
|
80
|
+
rows.append(
|
|
81
|
+
{
|
|
82
|
+
"kind": kind,
|
|
83
|
+
"name": value.get("name")
|
|
84
|
+
if isinstance(value.get("name"), str)
|
|
85
|
+
else None,
|
|
86
|
+
"value": media
|
|
87
|
+
if isinstance(media, str) and len(media) <= 256
|
|
88
|
+
else None,
|
|
89
|
+
"negated": bool(value.get("negated", False)),
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
elif isinstance(value, str) and len(value) <= 256:
|
|
93
|
+
rows.append(
|
|
94
|
+
{"kind": kind, "name": None, "value": value, "negated": False}
|
|
95
|
+
)
|
|
96
|
+
return rows, complete
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_mappings(
|
|
100
|
+
body: Any,
|
|
101
|
+
*,
|
|
102
|
+
path_pattern: str | None,
|
|
103
|
+
http_method: str | None,
|
|
104
|
+
controller_pattern: str | None,
|
|
105
|
+
context_id: str | None,
|
|
106
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
107
|
+
if not isinstance(body, dict) or not isinstance(body.get("contexts"), dict):
|
|
108
|
+
raise ValueError("INVALID_MAPPINGS_SHAPE")
|
|
109
|
+
gaps: set[str] = set()
|
|
110
|
+
budget = AnalysisBudget()
|
|
111
|
+
found: list[dict[str, Any]] = []
|
|
112
|
+
for context_name in sorted(body["contexts"]):
|
|
113
|
+
if not isinstance(context_name, str):
|
|
114
|
+
gaps.add("INVALID_CONTEXT_NAME")
|
|
115
|
+
continue
|
|
116
|
+
if context_id and context_name != context_id:
|
|
117
|
+
continue
|
|
118
|
+
context = body["contexts"][context_name]
|
|
119
|
+
if not isinstance(context, dict):
|
|
120
|
+
gaps.add("INVALID_CONTEXT")
|
|
121
|
+
continue
|
|
122
|
+
parent = context.get("parentId")
|
|
123
|
+
mappings = context.get("mappings")
|
|
124
|
+
if not isinstance(mappings, dict):
|
|
125
|
+
gaps.add("MAPPINGS_UNOBSERVED")
|
|
126
|
+
continue
|
|
127
|
+
if _non_dispatcher_routes_unobserved(mappings, path_pattern):
|
|
128
|
+
gaps.add("NON_DISPATCHER_SERVLET_ROUTES_UNOBSERVED")
|
|
129
|
+
trees = (
|
|
130
|
+
("mvc", "dispatcherServlets"),
|
|
131
|
+
("webflux", "dispatcherHandlers"),
|
|
132
|
+
)
|
|
133
|
+
for stack, tree_name in trees:
|
|
134
|
+
tree = mappings.get(tree_name)
|
|
135
|
+
if tree is None:
|
|
136
|
+
continue
|
|
137
|
+
if not isinstance(tree, dict):
|
|
138
|
+
gaps.add("MAPPINGS_UNOBSERVED")
|
|
139
|
+
continue
|
|
140
|
+
for dispatcher in sorted(tree):
|
|
141
|
+
records = tree[dispatcher]
|
|
142
|
+
if not isinstance(records, list):
|
|
143
|
+
gaps.add("MAPPINGS_UNOBSERVED")
|
|
144
|
+
continue
|
|
145
|
+
for record in records:
|
|
146
|
+
budget.consume()
|
|
147
|
+
if not isinstance(record, dict):
|
|
148
|
+
gaps.add("MAPPING_RECORD_UNREADABLE")
|
|
149
|
+
continue
|
|
150
|
+
details = record.get("details")
|
|
151
|
+
method = (
|
|
152
|
+
details.get("handlerMethod")
|
|
153
|
+
if isinstance(details, dict)
|
|
154
|
+
else None
|
|
155
|
+
)
|
|
156
|
+
conditions_raw = (
|
|
157
|
+
details.get("requestMappingConditions")
|
|
158
|
+
if isinstance(details, dict)
|
|
159
|
+
else None
|
|
160
|
+
)
|
|
161
|
+
handler_function = (
|
|
162
|
+
details.get("handlerFunction")
|
|
163
|
+
if isinstance(details, dict)
|
|
164
|
+
else None
|
|
165
|
+
)
|
|
166
|
+
if not isinstance(handler_function, str):
|
|
167
|
+
nested = record.get("handlerFunction")
|
|
168
|
+
handler_function = nested if isinstance(nested, str) else None
|
|
169
|
+
class_name = (
|
|
170
|
+
method.get("className") if isinstance(method, dict) else None
|
|
171
|
+
)
|
|
172
|
+
method_name = (
|
|
173
|
+
method.get("name") if isinstance(method, dict) else None
|
|
174
|
+
)
|
|
175
|
+
descriptor = (
|
|
176
|
+
method.get("descriptor") if isinstance(method, dict) else None
|
|
177
|
+
)
|
|
178
|
+
handler = record.get("handler")
|
|
179
|
+
if (
|
|
180
|
+
not isinstance(class_name, str)
|
|
181
|
+
and isinstance(handler, str)
|
|
182
|
+
and "#" in handler
|
|
183
|
+
):
|
|
184
|
+
class_name, _, method_name = handler.partition("#")
|
|
185
|
+
patterns = []
|
|
186
|
+
methods: list[str] = []
|
|
187
|
+
condition_rows: list[dict[str, Any]] = []
|
|
188
|
+
complete = isinstance(conditions_raw, dict)
|
|
189
|
+
methods_complete = isinstance(conditions_raw, dict)
|
|
190
|
+
predicate_parsed = False
|
|
191
|
+
if isinstance(conditions_raw, dict):
|
|
192
|
+
raw_patterns = conditions_raw.get("patterns")
|
|
193
|
+
raw_methods = conditions_raw.get("methods")
|
|
194
|
+
if isinstance(raw_patterns, list):
|
|
195
|
+
for item in raw_patterns:
|
|
196
|
+
if isinstance(item, str) and 0 < len(item) <= 1024:
|
|
197
|
+
patterns.append(item)
|
|
198
|
+
else:
|
|
199
|
+
complete = False
|
|
200
|
+
gaps.add("MAPPING_PATTERN_UNREADABLE")
|
|
201
|
+
if len(patterns) > 16:
|
|
202
|
+
patterns = patterns[:16]
|
|
203
|
+
complete = False
|
|
204
|
+
gaps.add("MAPPING_PATH_LIMIT")
|
|
205
|
+
else:
|
|
206
|
+
complete = False
|
|
207
|
+
gaps.add("MAPPING_PATTERN_UNREADABLE")
|
|
208
|
+
if isinstance(raw_methods, list):
|
|
209
|
+
for item in raw_methods:
|
|
210
|
+
if isinstance(item, str) and item in _METHODS:
|
|
211
|
+
methods.append(item)
|
|
212
|
+
else:
|
|
213
|
+
complete = False
|
|
214
|
+
methods_complete = False
|
|
215
|
+
gaps.add("MAPPING_METHOD_UNREADABLE")
|
|
216
|
+
if len(methods) > 9:
|
|
217
|
+
methods = methods[:9]
|
|
218
|
+
complete = False
|
|
219
|
+
gaps.add("MAPPING_METHOD_LIMIT")
|
|
220
|
+
else:
|
|
221
|
+
complete = False
|
|
222
|
+
methods_complete = False
|
|
223
|
+
gaps.add("MAPPING_METHOD_UNREADABLE")
|
|
224
|
+
condition_rows, condition_complete = _conditions(conditions_raw)
|
|
225
|
+
complete = complete and condition_complete
|
|
226
|
+
elif isinstance(record.get("predicate"), str):
|
|
227
|
+
(
|
|
228
|
+
patterns,
|
|
229
|
+
methods,
|
|
230
|
+
condition_rows,
|
|
231
|
+
predicate_parsed,
|
|
232
|
+
) = _parse_predicate(record["predicate"])
|
|
233
|
+
if predicate_parsed:
|
|
234
|
+
complete = True
|
|
235
|
+
methods_complete = True
|
|
236
|
+
else:
|
|
237
|
+
patterns = []
|
|
238
|
+
methods = []
|
|
239
|
+
condition_rows = []
|
|
240
|
+
complete = False
|
|
241
|
+
methods_complete = False
|
|
242
|
+
gaps.add("MAPPING_PREDICATE_UNPARSED")
|
|
243
|
+
else:
|
|
244
|
+
complete = False
|
|
245
|
+
methods_complete = False
|
|
246
|
+
gaps.add("MAPPING_CONDITIONS_UNOBSERVED")
|
|
247
|
+
if path_pattern and not any(
|
|
248
|
+
glob_matches(path_pattern, pattern) for pattern in patterns
|
|
249
|
+
):
|
|
250
|
+
continue
|
|
251
|
+
unrestricted = not methods and methods_complete
|
|
252
|
+
if http_method and not unrestricted and http_method not in methods:
|
|
253
|
+
continue
|
|
254
|
+
if controller_pattern and not (
|
|
255
|
+
isinstance(class_name, str)
|
|
256
|
+
and glob_matches(controller_pattern, class_name)
|
|
257
|
+
):
|
|
258
|
+
continue
|
|
259
|
+
found.append(
|
|
260
|
+
{
|
|
261
|
+
"contextId": context_name,
|
|
262
|
+
"parentContextId": parent
|
|
263
|
+
if isinstance(parent, str)
|
|
264
|
+
else None,
|
|
265
|
+
"stack": stack,
|
|
266
|
+
"dispatcherName": dispatcher
|
|
267
|
+
if isinstance(dispatcher, str)
|
|
268
|
+
else None,
|
|
269
|
+
"paths": patterns,
|
|
270
|
+
"httpMethods": methods,
|
|
271
|
+
"methodsUnrestricted": unrestricted,
|
|
272
|
+
"controllerClass": class_name
|
|
273
|
+
if isinstance(class_name, str)
|
|
274
|
+
else None,
|
|
275
|
+
"handlerMethod": method_name
|
|
276
|
+
if isinstance(method_name, str)
|
|
277
|
+
else None,
|
|
278
|
+
"descriptor": descriptor
|
|
279
|
+
if isinstance(descriptor, str)
|
|
280
|
+
else None,
|
|
281
|
+
"handlerKind": "method"
|
|
282
|
+
if isinstance(method, dict)
|
|
283
|
+
else "function"
|
|
284
|
+
if predicate_parsed or handler_function
|
|
285
|
+
else "unknown",
|
|
286
|
+
"conditions": condition_rows,
|
|
287
|
+
"matchingComplete": complete,
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
items = []
|
|
291
|
+
for index, row in enumerate(found, start=1):
|
|
292
|
+
row["id"] = f"mapping_{index:06d}"
|
|
293
|
+
items.append(row)
|
|
294
|
+
return items, sorted(gaps)
|