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.
Files changed (118) hide show
  1. spring_runtime_mcp/__init__.py +3 -0
  2. spring_runtime_mcp/adapters/__init__.py +1 -0
  3. spring_runtime_mcp/adapters/beans.py +159 -0
  4. spring_runtime_mcp/adapters/conditions.py +333 -0
  5. spring_runtime_mcp/adapters/config.py +295 -0
  6. spring_runtime_mcp/adapters/drift.py +790 -0
  7. spring_runtime_mcp/adapters/health.py +164 -0
  8. spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
  9. spring_runtime_mcp/adapters/mappings.py +294 -0
  10. spring_runtime_mcp/adapters/migrations.py +373 -0
  11. spring_runtime_mcp/adapters/openapi.py +683 -0
  12. spring_runtime_mcp/adapters/source_catalog.py +119 -0
  13. spring_runtime_mcp/adapters/startup.py +202 -0
  14. spring_runtime_mcp/analyzers/__init__.py +1 -0
  15. spring_runtime_mcp/analyzers/bean_graph.py +172 -0
  16. spring_runtime_mcp/analyzers/condition_failures.py +359 -0
  17. spring_runtime_mcp/cli.py +248 -0
  18. spring_runtime_mcp/contracts/__init__.py +1 -0
  19. spring_runtime_mcp/contracts/definitions.py +425 -0
  20. spring_runtime_mcp/diagnostics.py +254 -0
  21. spring_runtime_mcp/models/__init__.py +1 -0
  22. spring_runtime_mcp/models/bean_graph.py +77 -0
  23. spring_runtime_mcp/models/common.py +99 -0
  24. spring_runtime_mcp/models/conditions.py +56 -0
  25. spring_runtime_mcp/models/queries.py +140 -0
  26. spring_runtime_mcp/models/source_catalog.py +35 -0
  27. spring_runtime_mcp/models/timestamps.py +71 -0
  28. spring_runtime_mcp/policy/__init__.py +1 -0
  29. spring_runtime_mcp/policy/configuration.py +32 -0
  30. spring_runtime_mcp/policy/output_guard.py +117 -0
  31. spring_runtime_mcp/policy/sanitization.py +616 -0
  32. spring_runtime_mcp/py.typed +0 -0
  33. spring_runtime_mcp/replay.py +220 -0
  34. spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
  35. spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
  36. spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
  37. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
  38. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
  39. spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
  40. spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
  41. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
  42. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
  43. spring_runtime_mcp/replay_data/conformance/README.md +41 -0
  44. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
  45. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
  46. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
  47. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
  48. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
  49. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
  50. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
  51. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
  52. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
  53. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
  54. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
  55. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
  56. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
  57. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
  58. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
  59. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
  60. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
  61. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
  62. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
  63. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
  64. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
  65. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
  66. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
  67. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
  68. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
  69. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
  70. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
  71. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
  72. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
  73. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
  74. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
  75. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
  76. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
  77. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
  78. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
  79. spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
  80. spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
  81. spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
  82. spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
  83. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
  84. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
  85. spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
  86. spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
  87. spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
  88. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
  89. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
  90. spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
  91. spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
  92. spring_runtime_mcp/schemas/tools-contract.json +1902 -0
  93. spring_runtime_mcp/server.py +322 -0
  94. spring_runtime_mcp/services.py +44 -0
  95. spring_runtime_mcp/snapshots.py +340 -0
  96. spring_runtime_mcp/tools/__init__.py +1 -0
  97. spring_runtime_mcp/tools/analyze_startup.py +79 -0
  98. spring_runtime_mcp/tools/check_migrations.py +99 -0
  99. spring_runtime_mcp/tools/detect_drift.py +323 -0
  100. spring_runtime_mcp/tools/diagnose_health.py +88 -0
  101. spring_runtime_mcp/tools/inspect_beans.py +479 -0
  102. spring_runtime_mcp/tools/inspect_config.py +102 -0
  103. spring_runtime_mcp/tools/registry.py +48 -0
  104. spring_runtime_mcp/tools/search_openapi.py +79 -0
  105. spring_runtime_mcp/tools/support.py +166 -0
  106. spring_runtime_mcp/tools/verify_mappings.py +62 -0
  107. spring_runtime_mcp/transport/__init__.py +1 -0
  108. spring_runtime_mcp/transport/actuator_http.py +459 -0
  109. spring_runtime_mcp/transport/bounded_json.py +144 -0
  110. spring_runtime_mcp/transport/replay_files.py +71 -0
  111. spring_runtime_mcp/transport/response_gate.py +186 -0
  112. spring_runtime_mcp/transport/responses.py +82 -0
  113. spring_runtime_mcp/transport/stdio.py +44 -0
  114. spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
  115. spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
  116. spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
  117. spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  118. 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)