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,459 @@
|
|
|
1
|
+
"""Stdlib GET connector for one registered actuator origin.
|
|
2
|
+
|
|
3
|
+
This is the only production module allowed to import http.client. It never
|
|
4
|
+
follows redirects and never reads more than max_bytes + 1 bytes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import http.client
|
|
12
|
+
import ipaddress
|
|
13
|
+
import re
|
|
14
|
+
import socket
|
|
15
|
+
import ssl
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import zlib
|
|
19
|
+
from typing import Any, cast
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from .response_gate import (
|
|
23
|
+
HEALTH_ENDPOINTS,
|
|
24
|
+
MAX_UPSTREAM_BYTES,
|
|
25
|
+
EndpointReadError,
|
|
26
|
+
HttpResponse,
|
|
27
|
+
_json_media,
|
|
28
|
+
)
|
|
29
|
+
from ..models.common import NormalizationError
|
|
30
|
+
|
|
31
|
+
_PATHS = {
|
|
32
|
+
"beans": "/actuator/beans",
|
|
33
|
+
"conditions": "/actuator/conditions",
|
|
34
|
+
"health": "/actuator/health",
|
|
35
|
+
"health-liveness": "/actuator/health/liveness",
|
|
36
|
+
"health-readiness": "/actuator/health/readiness",
|
|
37
|
+
"mappings": "/actuator/mappings",
|
|
38
|
+
"flyway": "/actuator/flyway",
|
|
39
|
+
"liquibase": "/actuator/liquibase",
|
|
40
|
+
"startup": "/actuator/startup",
|
|
41
|
+
"openapi": "/actuator/openapi",
|
|
42
|
+
"env": "/actuator/env",
|
|
43
|
+
"configprops": "/actuator/configprops",
|
|
44
|
+
}
|
|
45
|
+
_MAX_HEADER_BYTES = 32 * 1024
|
|
46
|
+
_CONNECT_TIMEOUT = 2.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _HeaderReader:
|
|
50
|
+
"""Limit status and header bytes before http.client accumulates them."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, stream: Any) -> None:
|
|
53
|
+
self.stream = stream
|
|
54
|
+
self.remaining = _MAX_HEADER_BYTES
|
|
55
|
+
|
|
56
|
+
def readline(self, size: int = -1) -> bytes:
|
|
57
|
+
limit = self.remaining + 1
|
|
58
|
+
if size >= 0:
|
|
59
|
+
limit = min(limit, size)
|
|
60
|
+
line: bytes = self.stream.readline(limit)
|
|
61
|
+
if len(line) > self.remaining:
|
|
62
|
+
raise NormalizationError("RESPONSE_HEADERS_TOO_LARGE")
|
|
63
|
+
self.remaining -= len(line)
|
|
64
|
+
return line
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self.stream.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _HeaderBoundedResponse(http.client.HTTPResponse):
|
|
71
|
+
def begin(self) -> None:
|
|
72
|
+
original = self.fp
|
|
73
|
+
if original is None:
|
|
74
|
+
raise http.client.ResponseNotReady()
|
|
75
|
+
bounded = _HeaderReader(original)
|
|
76
|
+
self.fp = bounded # type: ignore[assignment]
|
|
77
|
+
try:
|
|
78
|
+
super().begin()
|
|
79
|
+
finally:
|
|
80
|
+
if cast(Any, self.fp) is bounded:
|
|
81
|
+
self.fp = original
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_base(url: str) -> tuple[str, str, int]:
|
|
85
|
+
if not isinstance(url, str) or not url or len(url) > 2048:
|
|
86
|
+
raise ValueError("INVALID_BASE_URL")
|
|
87
|
+
if any(ord(char) <= 32 or ord(char) == 127 for char in url) or "\\" in url:
|
|
88
|
+
raise ValueError("INVALID_BASE_URL")
|
|
89
|
+
parts = urlsplit(url)
|
|
90
|
+
host = parts.hostname
|
|
91
|
+
if (
|
|
92
|
+
parts.scheme not in {"http", "https"}
|
|
93
|
+
or host is None
|
|
94
|
+
or parts.username
|
|
95
|
+
or parts.password
|
|
96
|
+
or parts.path not in {"", "/"}
|
|
97
|
+
or parts.query
|
|
98
|
+
or parts.fragment
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("INVALID_BASE_URL")
|
|
101
|
+
port = parts.port
|
|
102
|
+
if port is None:
|
|
103
|
+
port = 443 if parts.scheme == "https" else 80
|
|
104
|
+
if not 1 <= port <= 65535 or "%" in host:
|
|
105
|
+
raise ValueError("INVALID_BASE_URL")
|
|
106
|
+
try:
|
|
107
|
+
address = ipaddress.ip_address(host)
|
|
108
|
+
except ValueError:
|
|
109
|
+
if re.fullmatch(
|
|
110
|
+
r"(?:0[xX][0-9a-fA-F]+|[0-9]+)(?:\.(?:0[xX][0-9a-fA-F]+|[0-9]+))*", host
|
|
111
|
+
):
|
|
112
|
+
raise ValueError("INVALID_BASE_URL") from None
|
|
113
|
+
host = host.removesuffix(".").encode("idna").decode("ascii").lower()
|
|
114
|
+
if len(host) > 253 or any(
|
|
115
|
+
re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) is None
|
|
116
|
+
for label in host.split(".")
|
|
117
|
+
):
|
|
118
|
+
raise ValueError("INVALID_BASE_URL")
|
|
119
|
+
else:
|
|
120
|
+
_loopback(str(address))
|
|
121
|
+
host = str(address)
|
|
122
|
+
return parts.scheme, host, port
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _loopback(host: str) -> str:
|
|
126
|
+
address = ipaddress.ip_address(host)
|
|
127
|
+
effective = (
|
|
128
|
+
address.ipv4_mapped
|
|
129
|
+
if isinstance(address, ipaddress.IPv6Address)
|
|
130
|
+
and address.ipv4_mapped is not None
|
|
131
|
+
else address
|
|
132
|
+
)
|
|
133
|
+
if not (
|
|
134
|
+
effective in ipaddress.ip_network("127.0.0.0/8")
|
|
135
|
+
if effective.version == 4
|
|
136
|
+
else effective == ipaddress.ip_address("::1")
|
|
137
|
+
):
|
|
138
|
+
raise NormalizationError("ADDRESS_DENIED")
|
|
139
|
+
return str(effective)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _ExchangeState:
|
|
143
|
+
"""Own the real socket even when HTTPResponse detaches it from the client."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, timeout: float) -> None:
|
|
146
|
+
self.deadline = time.monotonic() + timeout
|
|
147
|
+
self.cancelled = threading.Event()
|
|
148
|
+
self.lock = threading.Lock()
|
|
149
|
+
self.connection: http.client.HTTPConnection | None = None
|
|
150
|
+
self.sock: socket.socket | None = None
|
|
151
|
+
|
|
152
|
+
def remaining(self) -> float:
|
|
153
|
+
remaining = self.deadline - time.monotonic()
|
|
154
|
+
if self.cancelled.is_set() or remaining <= 0:
|
|
155
|
+
raise TimeoutError()
|
|
156
|
+
return remaining
|
|
157
|
+
|
|
158
|
+
def bind(self, connection: http.client.HTTPConnection) -> None:
|
|
159
|
+
with self.lock:
|
|
160
|
+
self.connection = connection
|
|
161
|
+
if self.cancelled.is_set():
|
|
162
|
+
connection.close()
|
|
163
|
+
raise TimeoutError()
|
|
164
|
+
|
|
165
|
+
def bind_socket(self, sock: socket.socket) -> None:
|
|
166
|
+
with self.lock:
|
|
167
|
+
self.sock = sock
|
|
168
|
+
if self.cancelled.is_set():
|
|
169
|
+
sock.close()
|
|
170
|
+
raise TimeoutError()
|
|
171
|
+
|
|
172
|
+
def abort(self) -> None:
|
|
173
|
+
self.cancelled.set()
|
|
174
|
+
with self.lock:
|
|
175
|
+
connection, sock = self.connection, self.sock
|
|
176
|
+
if sock is not None:
|
|
177
|
+
with contextlib.suppress(OSError):
|
|
178
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
179
|
+
sock.close()
|
|
180
|
+
if connection is not None:
|
|
181
|
+
connection.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _VerifiedHTTPSConnection(http.client.HTTPSConnection):
|
|
185
|
+
def __init__(
|
|
186
|
+
self, host: str, port: int, timeout: float, state: _ExchangeState
|
|
187
|
+
) -> None:
|
|
188
|
+
self._tls_context = ssl.create_default_context()
|
|
189
|
+
self._state = state
|
|
190
|
+
super().__init__(host, port, timeout=timeout, context=self._tls_context)
|
|
191
|
+
|
|
192
|
+
def connect(self) -> None:
|
|
193
|
+
# The injected connector pins and verifies the raw peer before TLS.
|
|
194
|
+
http.client.HTTPConnection.connect(self)
|
|
195
|
+
assert self.sock is not None
|
|
196
|
+
wrapped = self._tls_context.wrap_socket(
|
|
197
|
+
self.sock, server_hostname=self.host, do_handshake_on_connect=False
|
|
198
|
+
)
|
|
199
|
+
self.sock = wrapped
|
|
200
|
+
self._state.bind_socket(wrapped)
|
|
201
|
+
wrapped.settimeout(self._state.remaining())
|
|
202
|
+
wrapped.do_handshake()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _read_bounded(
|
|
206
|
+
response: http.client.HTTPResponse, max_bytes: int, state: _ExchangeState
|
|
207
|
+
) -> bytes:
|
|
208
|
+
remaining = max_bytes + 1
|
|
209
|
+
chunks: list[bytes] = []
|
|
210
|
+
while remaining:
|
|
211
|
+
state.remaining()
|
|
212
|
+
chunk = response.read(min(65536, remaining))
|
|
213
|
+
if not chunk:
|
|
214
|
+
if response.length is not None and response.length > 0:
|
|
215
|
+
raise NormalizationError("INCOMPLETE_RESPONSE")
|
|
216
|
+
break
|
|
217
|
+
chunks.append(chunk)
|
|
218
|
+
remaining -= len(chunk)
|
|
219
|
+
return b"".join(chunks)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _read_gzip_bounded(
|
|
223
|
+
response: http.client.HTTPResponse, max_bytes: int, state: _ExchangeState
|
|
224
|
+
) -> bytes:
|
|
225
|
+
decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
|
|
226
|
+
encoded = decoded = 0
|
|
227
|
+
chunks: list[bytes] = []
|
|
228
|
+
try:
|
|
229
|
+
while True:
|
|
230
|
+
state.remaining()
|
|
231
|
+
chunk = response.read(min(65536, max_bytes - encoded + 1))
|
|
232
|
+
if not chunk:
|
|
233
|
+
if response.length is not None and response.length > 0:
|
|
234
|
+
raise NormalizationError("INCOMPLETE_RESPONSE")
|
|
235
|
+
break
|
|
236
|
+
encoded += len(chunk)
|
|
237
|
+
if encoded > max_bytes:
|
|
238
|
+
raise NormalizationError("RESPONSE_TOO_LARGE")
|
|
239
|
+
output = decoder.decompress(chunk, max_bytes - decoded + 1)
|
|
240
|
+
decoded += len(output)
|
|
241
|
+
if decoded > max_bytes or decoder.unconsumed_tail:
|
|
242
|
+
raise NormalizationError("RESPONSE_TOO_LARGE")
|
|
243
|
+
if decoder.unused_data:
|
|
244
|
+
raise NormalizationError("INVALID_COMPRESSED_DATA")
|
|
245
|
+
chunks.append(output)
|
|
246
|
+
except zlib.error:
|
|
247
|
+
raise NormalizationError("INVALID_COMPRESSED_DATA") from None
|
|
248
|
+
if not decoder.eof:
|
|
249
|
+
raise NormalizationError("INVALID_COMPRESSED_DATA")
|
|
250
|
+
return b"".join(chunks)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ActuatorHttpTransport:
|
|
254
|
+
"""Map a logical endpoint id onto one configured origin. No caller URLs."""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self, base_url: str, *, targets: frozenset[str], bearer_token: str | None = None
|
|
258
|
+
) -> None:
|
|
259
|
+
if not targets or any(not isinstance(target, str) for target in targets):
|
|
260
|
+
raise ValueError("INVALID_TARGET_REGISTRATION")
|
|
261
|
+
scheme, host, port = _parse_base(base_url)
|
|
262
|
+
self._scheme = scheme
|
|
263
|
+
self._host = host
|
|
264
|
+
self._port = port
|
|
265
|
+
self._targets = targets
|
|
266
|
+
if bearer_token is not None and (
|
|
267
|
+
not isinstance(bearer_token, str)
|
|
268
|
+
or not 1 <= len(bearer_token) <= 8192
|
|
269
|
+
or re.fullmatch(r"[A-Za-z0-9._~+/-]+=*", bearer_token) is None
|
|
270
|
+
):
|
|
271
|
+
raise ValueError("INVALID_CREDENTIAL_REGISTRATION")
|
|
272
|
+
self._bearer_token = bearer_token
|
|
273
|
+
|
|
274
|
+
async def request(
|
|
275
|
+
self,
|
|
276
|
+
target_id: str,
|
|
277
|
+
endpoint_id: str,
|
|
278
|
+
*,
|
|
279
|
+
method: str,
|
|
280
|
+
follow_redirects: bool,
|
|
281
|
+
timeout: float,
|
|
282
|
+
max_bytes: int,
|
|
283
|
+
) -> HttpResponse:
|
|
284
|
+
state = _ExchangeState(float(timeout))
|
|
285
|
+
worker = asyncio.create_task(
|
|
286
|
+
asyncio.to_thread(
|
|
287
|
+
self._exchange,
|
|
288
|
+
target_id,
|
|
289
|
+
endpoint_id,
|
|
290
|
+
method,
|
|
291
|
+
follow_redirects,
|
|
292
|
+
timeout,
|
|
293
|
+
max_bytes,
|
|
294
|
+
state,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
try:
|
|
298
|
+
return await asyncio.wait_for(
|
|
299
|
+
asyncio.shield(worker), timeout=float(timeout)
|
|
300
|
+
)
|
|
301
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
302
|
+
state.abort()
|
|
303
|
+
# Socket shutdown wakes a blocked body read; always collect worker
|
|
304
|
+
# exceptions. A resolver syscall itself is owned by the OS.
|
|
305
|
+
with contextlib.suppress(Exception, asyncio.CancelledError):
|
|
306
|
+
await asyncio.wait_for(asyncio.shield(worker), timeout=0.25)
|
|
307
|
+
if not worker.done():
|
|
308
|
+
worker.add_done_callback(
|
|
309
|
+
lambda task: task.exception() if not task.cancelled() else None
|
|
310
|
+
)
|
|
311
|
+
raise
|
|
312
|
+
except ssl.SSLError:
|
|
313
|
+
raise NormalizationError("TLS_VERIFICATION_FAILED") from None
|
|
314
|
+
|
|
315
|
+
def _open(
|
|
316
|
+
self, timeout: float, state: _ExchangeState
|
|
317
|
+
) -> http.client.HTTPConnection:
|
|
318
|
+
if self._scheme == "https":
|
|
319
|
+
connection: http.client.HTTPConnection = _VerifiedHTTPSConnection(
|
|
320
|
+
self._host, self._port, timeout, state
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
connection = http.client.HTTPConnection(
|
|
324
|
+
self._host, self._port, timeout=timeout
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def connect(
|
|
328
|
+
address: Any, timeout: Any = None, source_address: Any = None
|
|
329
|
+
) -> socket.socket:
|
|
330
|
+
if source_address is not None or address != (self._host, self._port):
|
|
331
|
+
raise NormalizationError("ADDRESS_DENIED")
|
|
332
|
+
records: list[tuple[socket.AddressFamily, socket.SocketKind, int, str, Any]]
|
|
333
|
+
try:
|
|
334
|
+
literal = ipaddress.ip_address(self._host)
|
|
335
|
+
except ValueError:
|
|
336
|
+
records = socket.getaddrinfo(
|
|
337
|
+
self._host, self._port, type=socket.SOCK_STREAM
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
family = socket.AF_INET if literal.version == 4 else socket.AF_INET6
|
|
341
|
+
records = [
|
|
342
|
+
(family, socket.SOCK_STREAM, 6, "", (str(literal), self._port))
|
|
343
|
+
]
|
|
344
|
+
if not records or len(records) > 16:
|
|
345
|
+
raise NormalizationError("ADDRESS_DENIED")
|
|
346
|
+
for family, kind, _, _, endpoint in records:
|
|
347
|
+
resolved_host = endpoint[0]
|
|
348
|
+
if (
|
|
349
|
+
family not in (socket.AF_INET, socket.AF_INET6)
|
|
350
|
+
or kind != socket.SOCK_STREAM
|
|
351
|
+
or endpoint[1] != self._port
|
|
352
|
+
or not isinstance(resolved_host, str)
|
|
353
|
+
or "%" in resolved_host
|
|
354
|
+
):
|
|
355
|
+
raise NormalizationError("ADDRESS_DENIED")
|
|
356
|
+
_loopback(resolved_host)
|
|
357
|
+
connect_deadline = min(state.deadline, time.monotonic() + _CONNECT_TIMEOUT)
|
|
358
|
+
for index, (family, _, _, _, endpoint) in enumerate(records):
|
|
359
|
+
sock = socket.socket(family, socket.SOCK_STREAM)
|
|
360
|
+
state.bind_socket(sock)
|
|
361
|
+
try:
|
|
362
|
+
remaining_connect = connect_deadline - time.monotonic()
|
|
363
|
+
if remaining_connect <= 0:
|
|
364
|
+
raise TimeoutError()
|
|
365
|
+
sock.settimeout(min(remaining_connect, state.remaining()))
|
|
366
|
+
sock.connect(endpoint)
|
|
367
|
+
peer = sock.getpeername()
|
|
368
|
+
if (
|
|
369
|
+
_loopback(peer[0]) != _loopback(endpoint[0])
|
|
370
|
+
or peer[1] != self._port
|
|
371
|
+
):
|
|
372
|
+
raise NormalizationError("PEER_MISMATCH")
|
|
373
|
+
if connect_deadline <= time.monotonic():
|
|
374
|
+
raise TimeoutError()
|
|
375
|
+
sock.settimeout(state.remaining())
|
|
376
|
+
return sock
|
|
377
|
+
except OSError:
|
|
378
|
+
sock.close()
|
|
379
|
+
if index == len(records) - 1:
|
|
380
|
+
raise
|
|
381
|
+
except BaseException:
|
|
382
|
+
sock.close()
|
|
383
|
+
raise
|
|
384
|
+
raise NormalizationError("ADDRESS_DENIED")
|
|
385
|
+
|
|
386
|
+
# http.client's socket factory is the narrow seam that preserves its
|
|
387
|
+
# framing while preventing a second hostname lookup or proxy handling.
|
|
388
|
+
setattr(connection, "_create_connection", connect)
|
|
389
|
+
connection.response_class = _HeaderBoundedResponse
|
|
390
|
+
connection.set_debuglevel(0)
|
|
391
|
+
return connection
|
|
392
|
+
|
|
393
|
+
def _exchange(
|
|
394
|
+
self,
|
|
395
|
+
target_id: str,
|
|
396
|
+
endpoint_id: str,
|
|
397
|
+
method: str,
|
|
398
|
+
follow_redirects: bool,
|
|
399
|
+
timeout: float,
|
|
400
|
+
max_bytes: int,
|
|
401
|
+
state: _ExchangeState,
|
|
402
|
+
) -> HttpResponse:
|
|
403
|
+
if method != "GET" or follow_redirects:
|
|
404
|
+
raise AssertionError("READ_ONLY_TRANSPORT")
|
|
405
|
+
if target_id not in self._targets or endpoint_id not in _PATHS:
|
|
406
|
+
raise AssertionError("UNREGISTERED_ENDPOINT")
|
|
407
|
+
if (
|
|
408
|
+
isinstance(timeout, bool)
|
|
409
|
+
or not isinstance(timeout, (int, float))
|
|
410
|
+
or not 0 < float(timeout) <= 60
|
|
411
|
+
):
|
|
412
|
+
raise AssertionError("INVALID_TIMEOUT")
|
|
413
|
+
if (
|
|
414
|
+
isinstance(max_bytes, bool)
|
|
415
|
+
or not isinstance(max_bytes, int)
|
|
416
|
+
or not 0 <= max_bytes <= MAX_UPSTREAM_BYTES
|
|
417
|
+
):
|
|
418
|
+
raise AssertionError("INVALID_MAX_BYTES")
|
|
419
|
+
path = _PATHS[endpoint_id]
|
|
420
|
+
connection = self._open(float(timeout), state)
|
|
421
|
+
state.bind(connection)
|
|
422
|
+
try:
|
|
423
|
+
state.remaining()
|
|
424
|
+
headers = {"Accept": "application/json", "Connection": "close"}
|
|
425
|
+
if self._bearer_token is not None:
|
|
426
|
+
headers["Authorization"] = "Bearer " + self._bearer_token
|
|
427
|
+
connection.request(
|
|
428
|
+
method="GET",
|
|
429
|
+
url=path,
|
|
430
|
+
headers=headers,
|
|
431
|
+
)
|
|
432
|
+
upstream = connection.getresponse()
|
|
433
|
+
if upstream.status != 200 and not (
|
|
434
|
+
upstream.status == 503 and endpoint_id in HEALTH_ENDPOINTS
|
|
435
|
+
):
|
|
436
|
+
# The response gate maps rejected statuses without inspecting
|
|
437
|
+
# their bodies. Avoid spending the read budget on error pages.
|
|
438
|
+
return HttpResponse(upstream.status, "", b"")
|
|
439
|
+
content_type = upstream.getheader("Content-Type") or ""
|
|
440
|
+
if _json_media(content_type) is None:
|
|
441
|
+
return HttpResponse(upstream.status, content_type, b"")
|
|
442
|
+
content_encoding = upstream.getheader("Content-Encoding")
|
|
443
|
+
encoding = (
|
|
444
|
+
content_encoding.strip().lower()
|
|
445
|
+
if content_encoding is not None
|
|
446
|
+
else "identity"
|
|
447
|
+
)
|
|
448
|
+
if encoding not in {"identity", "gzip"}:
|
|
449
|
+
raise EndpointReadError(
|
|
450
|
+
"UNSUPPORTED_CONTENT_ENCODING", endpoint_id, upstream.status
|
|
451
|
+
)
|
|
452
|
+
body = (
|
|
453
|
+
_read_gzip_bounded(upstream, max_bytes, state)
|
|
454
|
+
if encoding == "gzip"
|
|
455
|
+
else _read_bounded(upstream, max_bytes, state)
|
|
456
|
+
)
|
|
457
|
+
return HttpResponse(upstream.status, content_type, body)
|
|
458
|
+
finally:
|
|
459
|
+
connection.close()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Strict bounded parsing for offline observations and future safe readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..models.common import NormalizationError, remaining_seconds
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
13
|
+
result: dict[str, Any] = {}
|
|
14
|
+
for key, value in pairs:
|
|
15
|
+
if key in result:
|
|
16
|
+
raise NormalizationError("DUPLICATE_JSON_KEY")
|
|
17
|
+
result[key] = value
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _constant(value: str) -> None:
|
|
22
|
+
raise NormalizationError("INVALID_JSON_NUMBER")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _integer(value: str) -> int:
|
|
26
|
+
# No diagnostic identifier or checksum needs unbounded arbitrary precision.
|
|
27
|
+
# Limit before int() allocates a huge integer or hits interpreter limits.
|
|
28
|
+
if len(value.lstrip("-")) > 128:
|
|
29
|
+
raise NormalizationError("INVALID_JSON_NUMBER")
|
|
30
|
+
return int(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def loads_bounded(
|
|
34
|
+
data: bytes,
|
|
35
|
+
*,
|
|
36
|
+
max_bytes: int = 8 * 1024 * 1024,
|
|
37
|
+
max_depth: int = 64,
|
|
38
|
+
max_nodes: int = 100_000,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
if len(data) > max_bytes:
|
|
41
|
+
raise NormalizationError("RESPONSE_TOO_LARGE")
|
|
42
|
+
try:
|
|
43
|
+
text = data.decode("utf-8")
|
|
44
|
+
depth = nodes = index = 0
|
|
45
|
+
while index < len(text):
|
|
46
|
+
if index % 2048 == 0:
|
|
47
|
+
remaining_seconds()
|
|
48
|
+
char = text[index]
|
|
49
|
+
if char == '"':
|
|
50
|
+
nodes += 1
|
|
51
|
+
index += 1
|
|
52
|
+
while index < len(text):
|
|
53
|
+
if text[index] == "\\":
|
|
54
|
+
index += 2
|
|
55
|
+
elif text[index] == '"':
|
|
56
|
+
index += 1
|
|
57
|
+
break
|
|
58
|
+
else:
|
|
59
|
+
index += 1
|
|
60
|
+
elif char in "{[":
|
|
61
|
+
depth += 1
|
|
62
|
+
nodes += 1
|
|
63
|
+
index += 1
|
|
64
|
+
elif char in "}]":
|
|
65
|
+
depth -= 1
|
|
66
|
+
index += 1
|
|
67
|
+
elif char in " \t\n\r,:":
|
|
68
|
+
index += 1
|
|
69
|
+
else:
|
|
70
|
+
nodes += 1
|
|
71
|
+
while index < len(text) and text[index] not in " \t\r\n,]}:":
|
|
72
|
+
index += 1
|
|
73
|
+
if depth > max_depth or nodes > max_nodes:
|
|
74
|
+
raise NormalizationError("NORMALIZATION_LIMIT")
|
|
75
|
+
value = json.loads(
|
|
76
|
+
text, object_pairs_hook=_pairs, parse_constant=_constant, parse_int=_integer
|
|
77
|
+
)
|
|
78
|
+
if not isinstance(value, dict):
|
|
79
|
+
raise NormalizationError("UNSUPPORTED_SHAPE")
|
|
80
|
+
validate_tree(
|
|
81
|
+
value, max_bytes=max_bytes, max_depth=max_depth, max_nodes=max_nodes
|
|
82
|
+
)
|
|
83
|
+
return value
|
|
84
|
+
except (UnicodeError, ValueError, RecursionError) as exc:
|
|
85
|
+
if isinstance(exc, NormalizationError):
|
|
86
|
+
raise
|
|
87
|
+
raise NormalizationError("INVALID_JSON") from None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_tree(
|
|
91
|
+
value: Any,
|
|
92
|
+
*,
|
|
93
|
+
max_bytes: int = 8 * 1024 * 1024,
|
|
94
|
+
max_depth: int = 64,
|
|
95
|
+
max_nodes: int = 100_000,
|
|
96
|
+
) -> None:
|
|
97
|
+
stack = [(value, 0)]
|
|
98
|
+
seen: set[int] = set()
|
|
99
|
+
nodes = size = 0
|
|
100
|
+
while stack:
|
|
101
|
+
if nodes % 256 == 0:
|
|
102
|
+
remaining_seconds()
|
|
103
|
+
item, depth = stack.pop()
|
|
104
|
+
nodes += 1
|
|
105
|
+
if depth > max_depth or nodes > max_nodes:
|
|
106
|
+
raise NormalizationError("NORMALIZATION_LIMIT")
|
|
107
|
+
if isinstance(item, (dict, list)):
|
|
108
|
+
if id(item) in seen:
|
|
109
|
+
raise NormalizationError("NON_TREE_JSON")
|
|
110
|
+
seen.add(id(item))
|
|
111
|
+
if nodes + len(item) > max_nodes:
|
|
112
|
+
raise NormalizationError("NORMALIZATION_LIMIT")
|
|
113
|
+
if isinstance(item, dict):
|
|
114
|
+
if any(not isinstance(k, str) for k in item):
|
|
115
|
+
raise NormalizationError("INVALID_JSON")
|
|
116
|
+
stack.extend((k, depth + 1) for k in item)
|
|
117
|
+
stack.extend((v, depth + 1) for v in item.values())
|
|
118
|
+
else:
|
|
119
|
+
stack.extend((v, depth + 1) for v in item)
|
|
120
|
+
size += len(item) * 2 + 2
|
|
121
|
+
elif isinstance(item, str):
|
|
122
|
+
if len(item) > max_bytes:
|
|
123
|
+
raise NormalizationError("RESPONSE_TOO_LARGE")
|
|
124
|
+
try:
|
|
125
|
+
size += len(json.dumps(item, ensure_ascii=False).encode("utf-8"))
|
|
126
|
+
except UnicodeError:
|
|
127
|
+
raise NormalizationError("INVALID_JSON") from None
|
|
128
|
+
elif item is None:
|
|
129
|
+
size += 4
|
|
130
|
+
elif type(item) is bool:
|
|
131
|
+
size += 4 if item else 5
|
|
132
|
+
elif type(item) is int:
|
|
133
|
+
if item.bit_length() > 426:
|
|
134
|
+
raise NormalizationError("INVALID_JSON_NUMBER")
|
|
135
|
+
integer_text = str(item)
|
|
136
|
+
if len(integer_text.lstrip("-")) > 128:
|
|
137
|
+
raise NormalizationError("INVALID_JSON_NUMBER")
|
|
138
|
+
size += len(integer_text)
|
|
139
|
+
elif isinstance(item, float) and math.isfinite(item):
|
|
140
|
+
size += len(json.dumps(item, allow_nan=False))
|
|
141
|
+
else:
|
|
142
|
+
raise NormalizationError("INVALID_JSON")
|
|
143
|
+
if size > max_bytes:
|
|
144
|
+
raise NormalizationError("RESPONSE_TOO_LARGE")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Bounded, no-symlink replay file reads; not an arbitrary ingestion tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..models.common import NormalizationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_replay_file(
|
|
13
|
+
root: Path, path: Path, *, max_bytes: int = 8 * 1024 * 1024
|
|
14
|
+
) -> bytes:
|
|
15
|
+
descriptors: list[int] = []
|
|
16
|
+
try:
|
|
17
|
+
relative = path.relative_to(root)
|
|
18
|
+
parts = relative.parts
|
|
19
|
+
if (
|
|
20
|
+
not parts
|
|
21
|
+
or any(p in ("", ".", "..") for p in parts)
|
|
22
|
+
or not hasattr(os, "O_NOFOLLOW")
|
|
23
|
+
):
|
|
24
|
+
raise NormalizationError("FIXTURE_PATH_DENIED")
|
|
25
|
+
if type(max_bytes) is not int or not 0 < max_bytes <= 8 * 1024 * 1024:
|
|
26
|
+
raise NormalizationError("FIXTURE_PATH_DENIED")
|
|
27
|
+
absolute_root = root.absolute()
|
|
28
|
+
descriptors.append(
|
|
29
|
+
os.open(absolute_root.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
|
|
30
|
+
)
|
|
31
|
+
for part in absolute_root.parts[1:]:
|
|
32
|
+
if part in (".", ".."):
|
|
33
|
+
raise NormalizationError("FIXTURE_PATH_DENIED")
|
|
34
|
+
descriptors.append(
|
|
35
|
+
os.open(
|
|
36
|
+
part,
|
|
37
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
38
|
+
dir_fd=descriptors[-1],
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
for part in parts[:-1]:
|
|
42
|
+
descriptors.append(
|
|
43
|
+
os.open(
|
|
44
|
+
part,
|
|
45
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
46
|
+
dir_fd=descriptors[-1],
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
descriptor = os.open(
|
|
50
|
+
parts[-1],
|
|
51
|
+
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
|
|
52
|
+
dir_fd=descriptors[-1],
|
|
53
|
+
)
|
|
54
|
+
descriptors.append(descriptor)
|
|
55
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
56
|
+
raise NormalizationError("FIXTURE_PATH_DENIED")
|
|
57
|
+
result = bytearray()
|
|
58
|
+
while True:
|
|
59
|
+
chunk = os.read(descriptor, min(65536, max_bytes + 1 - len(result)))
|
|
60
|
+
if not chunk:
|
|
61
|
+
return bytes(result)
|
|
62
|
+
result.extend(chunk)
|
|
63
|
+
if len(result) > max_bytes:
|
|
64
|
+
raise NormalizationError("RESPONSE_TOO_LARGE")
|
|
65
|
+
except NormalizationError:
|
|
66
|
+
raise
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
raise NormalizationError("FIXTURE_PATH_DENIED") from None
|
|
69
|
+
finally:
|
|
70
|
+
for descriptor in reversed(descriptors):
|
|
71
|
+
os.close(descriptor)
|