code-atlas-forge 1.1.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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
code_atlas/api/server.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Servidor HTTP local — stdlib only, loopback, hardening de segurança.
|
|
2
|
+
|
|
3
|
+
Decisão (ADR cycle-6): ``http.server`` + ``ThreadingHTTPServer`` em vez de
|
|
4
|
+
FastAPI/Starlette — zero dependências novas (regra do repo), distribuição
|
|
5
|
+
mínima, superfície pequena e auditável. Read-only por contrato: a conexão
|
|
6
|
+
SQLite abre com ``read_only=True`` e nenhum handler escreve.
|
|
7
|
+
|
|
8
|
+
Segurança implementada:
|
|
9
|
+
- bind somente em ``127.0.0.1`` (host configurável mas nunca ``0.0.0.0``
|
|
10
|
+
por default);
|
|
11
|
+
- allowlist de ``Host`` (127.0.0.1/localhost[:porta]) — anti DNS-rebinding;
|
|
12
|
+
- ``Origin`` estrangeiro → 403 (CORS restritivo; sem Origin → permitido,
|
|
13
|
+
clientes locais/curl);
|
|
14
|
+
- ``Content-Length`` limitado; corpo JSON limitado; resposta limitada;
|
|
15
|
+
- rate limit por cliente em memória (API somente; assets estáticos isentos)
|
|
16
|
+
— 429 + ``Retry-After`` acima do teto;
|
|
17
|
+
- timeout por conexão — keep-alive ocioso e leitura travada são encerrados;
|
|
18
|
+
- erros tipados (``atlas-error``), nunca traceback ao cliente;
|
|
19
|
+
- ``X-Content-Type-Options``, ``Cache-Control: no-store``, sem cookies;
|
|
20
|
+
- sem execução de código indexado, sem leitura de arquivos arbitrária.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import sqlite3
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from collections import deque
|
|
33
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
from urllib.parse import urlparse
|
|
37
|
+
|
|
38
|
+
from code_atlas.storage.db import connect
|
|
39
|
+
|
|
40
|
+
API_PREFIX = "/api/v1"
|
|
41
|
+
MAX_BODY = 64 * 1024 # 64 KiB — payloads de context/impact são pequenos
|
|
42
|
+
MAX_RESPONSE = 4 * 1024 * 1024 # 4 MiB
|
|
43
|
+
RATE_LIMIT = 300 # req/min por cliente, rotas /api/v1 apenas
|
|
44
|
+
RATE_WINDOW = 60.0
|
|
45
|
+
REQUEST_TIMEOUT = 30.0 # s — teto de ociosidade por conexão
|
|
46
|
+
_ALLOWED_HOSTS = re.compile(r"^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _content_length(raw: str | None) -> int:
|
|
50
|
+
"""``Content-Length`` validado — negativo/não-inteiro nunca chega
|
|
51
|
+
ao ``read()`` (``read(-1)`` bloquearia até o timeout e um header
|
|
52
|
+
malformado virava 500 ``internal`` em vez de 400 tipado)."""
|
|
53
|
+
try:
|
|
54
|
+
length = int(raw or 0)
|
|
55
|
+
except (TypeError, ValueError) as exc:
|
|
56
|
+
raise ApiError("bad-content-length",
|
|
57
|
+
"Content-Length inválido", 400) from exc
|
|
58
|
+
if length < 0 or length > MAX_BODY:
|
|
59
|
+
raise ApiError("payload-too-large",
|
|
60
|
+
"corpo fora do limite (0..64 KiB)", 413)
|
|
61
|
+
return length
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ApiError(Exception):
|
|
65
|
+
"""Erro tipado retornado ao cliente."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, code: str, message: str, status: int = 400,
|
|
68
|
+
headers: dict[str, str] | None = None) -> None:
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.code = code
|
|
71
|
+
self.message = message
|
|
72
|
+
self.status = status
|
|
73
|
+
self.headers = headers or {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _RateLimiter:
|
|
77
|
+
"""Janela deslizante em memória por chave de cliente (loopback)."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, limit: int, window: float = RATE_WINDOW) -> None:
|
|
80
|
+
self.limit = limit
|
|
81
|
+
self.window = window
|
|
82
|
+
self._hits: dict[str, deque[float]] = {}
|
|
83
|
+
self._lock = threading.Lock()
|
|
84
|
+
|
|
85
|
+
def allow(self, key: str) -> tuple[bool, float]:
|
|
86
|
+
"""True se o request passa; senão False + segundos de Retry-After."""
|
|
87
|
+
now = time.monotonic()
|
|
88
|
+
with self._lock:
|
|
89
|
+
q = self._hits.setdefault(key, deque())
|
|
90
|
+
while q and now - q[0] >= self.window:
|
|
91
|
+
q.popleft()
|
|
92
|
+
if len(q) >= self.limit:
|
|
93
|
+
return False, max(1.0, self.window - (now - q[0]))
|
|
94
|
+
q.append(now)
|
|
95
|
+
return True, 0.0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _security_headers(h: BaseHTTPRequestHandler, *, cors: bool) -> None:
|
|
99
|
+
h.send_header("X-Content-Type-Options", "nosniff")
|
|
100
|
+
h.send_header("Cache-Control", "no-store")
|
|
101
|
+
h.send_header("Referrer-Policy", "no-referrer")
|
|
102
|
+
h.send_header("X-Frame-Options", "DENY")
|
|
103
|
+
h.send_header(
|
|
104
|
+
"Content-Security-Policy",
|
|
105
|
+
"default-src 'self'; script-src 'self'; "
|
|
106
|
+
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
|
|
107
|
+
"connect-src 'self'; font-src 'self'; worker-src 'self'; "
|
|
108
|
+
"object-src 'none'; base-uri 'self'; form-action 'self'; "
|
|
109
|
+
"frame-ancestors 'none'")
|
|
110
|
+
if cors:
|
|
111
|
+
h.send_header("Access-Control-Allow-Origin", "http://127.0.0.1")
|
|
112
|
+
h.send_header("Vary", "Origin")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _AtlasHandler(BaseHTTPRequestHandler):
|
|
116
|
+
server: _AtlasHttpd
|
|
117
|
+
protocol_version = "HTTP/1.1"
|
|
118
|
+
# lf-0153: ``Server: BaseHTTP/0.6 Python/3.12.3`` fingerprintava a
|
|
119
|
+
# stack em toda resposta — versão do produto só, sem a do runtime.
|
|
120
|
+
server_version = "code-atlas-api"
|
|
121
|
+
sys_version = ""
|
|
122
|
+
|
|
123
|
+
def setup(self) -> None:
|
|
124
|
+
super().setup()
|
|
125
|
+
self.connection.settimeout(self.server.request_timeout)
|
|
126
|
+
|
|
127
|
+
def handle(self) -> None:
|
|
128
|
+
try:
|
|
129
|
+
super().handle()
|
|
130
|
+
except TimeoutError:
|
|
131
|
+
# keep-alive ocioso ou leitura travada — encerra sem ruído
|
|
132
|
+
self.close_connection = True
|
|
133
|
+
|
|
134
|
+
def log_message(self, fmt: str, *args: Any) -> None: # quiet
|
|
135
|
+
self.server.log(format(fmt % args)) if self.server.verbose else None
|
|
136
|
+
|
|
137
|
+
# --- plumbing -----------------------------------------------------
|
|
138
|
+
def _json(self, payload: Any, status: int = 200,
|
|
139
|
+
extra_headers: dict[str, str] | None = None) -> None:
|
|
140
|
+
body = json.dumps(payload, ensure_ascii=False, default=str).encode()
|
|
141
|
+
if len(body) > MAX_RESPONSE:
|
|
142
|
+
err = {"error": {"code": "response-too-large",
|
|
143
|
+
"message": "resposta excedeu o limite interno"}}
|
|
144
|
+
body = json.dumps(err).encode()
|
|
145
|
+
status = 500
|
|
146
|
+
self.send_response(status)
|
|
147
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
148
|
+
self.send_header("Content-Length", str(len(body)))
|
|
149
|
+
for k, v in (extra_headers or {}).items():
|
|
150
|
+
self.send_header(k, v)
|
|
151
|
+
_security_headers(self, cors=self._origin_ok())
|
|
152
|
+
self.end_headers()
|
|
153
|
+
self.wfile.write(body)
|
|
154
|
+
|
|
155
|
+
def _error(self, e: Exception) -> None:
|
|
156
|
+
if isinstance(e, ApiError):
|
|
157
|
+
self._json({"error": {"code": e.code, "message": e.message}},
|
|
158
|
+
e.status, e.headers)
|
|
159
|
+
return
|
|
160
|
+
self._json({"error": {"code": "internal", "message": str(e)[:200]}}, 500)
|
|
161
|
+
|
|
162
|
+
def _origin_ok(self) -> bool:
|
|
163
|
+
origin = self.headers.get("Origin")
|
|
164
|
+
if not origin:
|
|
165
|
+
return True
|
|
166
|
+
host = urlparse(origin).netloc
|
|
167
|
+
return bool(_ALLOWED_HOSTS.match(host))
|
|
168
|
+
|
|
169
|
+
def _guard(self) -> None:
|
|
170
|
+
host = self.headers.get("Host", "")
|
|
171
|
+
if not _ALLOWED_HOSTS.match(host):
|
|
172
|
+
raise ApiError("bad-host", "Host não permitido", 403)
|
|
173
|
+
if not self._origin_ok():
|
|
174
|
+
raise ApiError("bad-origin", "Origin não permitida", 403)
|
|
175
|
+
|
|
176
|
+
def _body(self) -> dict[str, Any]:
|
|
177
|
+
length = _content_length(self.headers.get("Content-Length"))
|
|
178
|
+
raw = self.rfile.read(length) if length else b"{}"
|
|
179
|
+
try:
|
|
180
|
+
data = json.loads(raw)
|
|
181
|
+
except json.JSONDecodeError as exc:
|
|
182
|
+
raise ApiError("bad-json", "corpo não é JSON válido") from exc
|
|
183
|
+
if not isinstance(data, dict):
|
|
184
|
+
raise ApiError("bad-json", "corpo deve ser um objeto JSON")
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
def _serve_static(self, path: str) -> None:
|
|
188
|
+
"""Assets compilados do Explorer — allowlist de tipos, sem traversal."""
|
|
189
|
+
import mimetypes
|
|
190
|
+
assert self.server.static_root is not None
|
|
191
|
+
root = self.server.static_root.resolve()
|
|
192
|
+
rel = "index.html" if path in ("", "/") else path.lstrip("/")
|
|
193
|
+
target = (root / rel).resolve()
|
|
194
|
+
# Contenção real via pathlib — ``startswith`` em string aceita
|
|
195
|
+
# irmãos com prefixo comum (``static-evil`` sob ``static``) e
|
|
196
|
+
# permitia servir arquivos fora do root via ``..`` (lf-c8 F1).
|
|
197
|
+
if not target.is_relative_to(root) or not target.is_file():
|
|
198
|
+
# SPA fallback — rotas do Angular caem no index.html
|
|
199
|
+
target = root / "index.html"
|
|
200
|
+
if not target.is_file():
|
|
201
|
+
raise ApiError("not-found", "asset inexistente", 404)
|
|
202
|
+
allowed = {".html", ".js", ".css", ".json", ".svg", ".png", ".ico",
|
|
203
|
+
".woff", ".woff2", ".txt", ".map"}
|
|
204
|
+
if target.suffix.lower() not in allowed:
|
|
205
|
+
raise ApiError("not-found", "tipo de asset não permitido", 404)
|
|
206
|
+
# lf-0153: stat ANTES do read — um asset gigante plantado na
|
|
207
|
+
# dist não pode ser carregado inteiro em memória só para ser
|
|
208
|
+
# rejeitado depois (o check era pós-leitura).
|
|
209
|
+
if target.stat().st_size > MAX_RESPONSE:
|
|
210
|
+
raise ApiError("response-too-large", "asset excede o limite", 500)
|
|
211
|
+
body = target.read_bytes()
|
|
212
|
+
if len(body) > MAX_RESPONSE:
|
|
213
|
+
raise ApiError("response-too-large", "asset excede o limite", 500)
|
|
214
|
+
ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
|
|
215
|
+
self.send_response(200)
|
|
216
|
+
self.send_header("Content-Type", ctype)
|
|
217
|
+
self.send_header("Content-Length", str(len(body)))
|
|
218
|
+
_security_headers(self, cors=False)
|
|
219
|
+
self.end_headers()
|
|
220
|
+
self.wfile.write(body)
|
|
221
|
+
|
|
222
|
+
# --- verbs ---------------------------------------------------------
|
|
223
|
+
def do_GET(self) -> None:
|
|
224
|
+
self._dispatch("GET")
|
|
225
|
+
|
|
226
|
+
def do_POST(self) -> None:
|
|
227
|
+
self._dispatch("POST")
|
|
228
|
+
|
|
229
|
+
def do_OPTIONS(self) -> None:
|
|
230
|
+
if not self._origin_ok():
|
|
231
|
+
self._json({"error": {"code": "bad-origin",
|
|
232
|
+
"message": "Origin não permitida"}}, 403)
|
|
233
|
+
return
|
|
234
|
+
self.send_response(204)
|
|
235
|
+
self.send_header("Access-Control-Allow-Origin", "http://127.0.0.1")
|
|
236
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
237
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
238
|
+
self.send_header("Access-Control-Max-Age", "300")
|
|
239
|
+
_security_headers(self, cors=True)
|
|
240
|
+
self.end_headers()
|
|
241
|
+
|
|
242
|
+
def _dispatch(self, method: str) -> None:
|
|
243
|
+
try:
|
|
244
|
+
self._guard()
|
|
245
|
+
# RFC 9110: o request-target nunca carrega controles C0/DEL.
|
|
246
|
+
# Rejeita cedo — depender do ``resolve()`` estourar em NUL é
|
|
247
|
+
# não-determinístico entre plataformas/versões (Win py3.13
|
|
248
|
+
# retorna 200 via SPA fallback onde POSIX/py3.12 dava 400).
|
|
249
|
+
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in self.path):
|
|
250
|
+
raise ApiError(
|
|
251
|
+
"bad-request", "request-target malformado", 400)
|
|
252
|
+
path = urlparse(self.path).path
|
|
253
|
+
if not path.startswith(API_PREFIX):
|
|
254
|
+
if method == "GET" and self.server.static_root:
|
|
255
|
+
self._serve_static(path)
|
|
256
|
+
return
|
|
257
|
+
raise ApiError("not-found", "rota inexistente", 404)
|
|
258
|
+
allowed, retry = self.server.rate_limiter.allow(
|
|
259
|
+
self.client_address[0])
|
|
260
|
+
if not allowed:
|
|
261
|
+
raise ApiError(
|
|
262
|
+
"rate-limited",
|
|
263
|
+
"limite de requisições por minuto excedido", 429,
|
|
264
|
+
headers={"Retry-After": str(int(retry + 0.5))})
|
|
265
|
+
route = path[len(API_PREFIX):]
|
|
266
|
+
from code_atlas.api.handlers import handle
|
|
267
|
+
self._json(handle(self.server, method, route, self))
|
|
268
|
+
except ApiError as e:
|
|
269
|
+
self._error(e)
|
|
270
|
+
except ValueError:
|
|
271
|
+
# lf-0153: request-target hostil que estoura no parse —
|
|
272
|
+
# NUL em ``resolve()`` (``GET /a\x00b``), IPv6 malformado
|
|
273
|
+
# em absolute-URI (``GET http://[``) — era 500 ``internal``
|
|
274
|
+
# para o que é erro do cliente; agora 400 tipado.
|
|
275
|
+
self._error(ApiError(
|
|
276
|
+
"bad-request", "request-target malformado", 400))
|
|
277
|
+
except TimeoutError:
|
|
278
|
+
with contextlib.suppress(OSError):
|
|
279
|
+
self._error(ApiError(
|
|
280
|
+
"request-timeout", "tempo de request excedido", 408))
|
|
281
|
+
except Exception as e: # nunca expor traceback
|
|
282
|
+
self._error(e)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class _AtlasHttpd(ThreadingHTTPServer):
|
|
286
|
+
daemon_threads = True
|
|
287
|
+
allow_reuse_address = True
|
|
288
|
+
|
|
289
|
+
def __init__(self, addr: tuple[str, int], db_path: str,
|
|
290
|
+
verbose: bool = False,
|
|
291
|
+
static_root: Path | None = None,
|
|
292
|
+
rate_limit: int = RATE_LIMIT,
|
|
293
|
+
request_timeout: float = REQUEST_TIMEOUT) -> None:
|
|
294
|
+
super().__init__(addr, _AtlasHandler)
|
|
295
|
+
self.db_path = db_path
|
|
296
|
+
self.verbose = verbose
|
|
297
|
+
# lf-0141: instante de subida para o uptime do ``/diagnostics``.
|
|
298
|
+
self.started_at = time.monotonic()
|
|
299
|
+
self.static_root = static_root
|
|
300
|
+
self.request_timeout = request_timeout
|
|
301
|
+
self.rate_limiter = _RateLimiter(rate_limit)
|
|
302
|
+
|
|
303
|
+
def log(self, msg: str) -> None:
|
|
304
|
+
# lf-0153: a request line crua carrega bytes de controle do
|
|
305
|
+
# cliente (``GET /\x1b[31m``) — escape antes de tocar o stderr,
|
|
306
|
+
# senão ``--verbose`` vira canal de ANSI/forged-line injection.
|
|
307
|
+
from code_atlas.observability.logging import escape_control
|
|
308
|
+
print(f"[atlas-api] {escape_control(msg)}", flush=True)
|
|
309
|
+
|
|
310
|
+
def handle_error(self, request: Any, client_address: Any) -> None:
|
|
311
|
+
# lf-0153: ``socketserver`` despejava um traceback por conexão
|
|
312
|
+
# RST — um cliente loopback hostil inundava stderr à vontade
|
|
313
|
+
# (log-flood). Erros de transporte são rotina e calam; bugs
|
|
314
|
+
# reais de handler continuam com o traceback default.
|
|
315
|
+
exc = sys.exc_info()[1]
|
|
316
|
+
if isinstance(exc, (ConnectionError, TimeoutError)):
|
|
317
|
+
return
|
|
318
|
+
super().handle_error(request, client_address)
|
|
319
|
+
|
|
320
|
+
def connect(self) -> sqlite3.Connection:
|
|
321
|
+
conn = connect(Path(self.db_path), read_only=True)
|
|
322
|
+
conn.execute("PRAGMA query_only = ON")
|
|
323
|
+
return conn
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class AtlasApiServer:
|
|
327
|
+
"""Wrapper de ciclo de vida — usado pela CLI e testes."""
|
|
328
|
+
|
|
329
|
+
def __init__(self, db_path: str, host: str = "127.0.0.1",
|
|
330
|
+
port: int = 8741, verbose: bool = False,
|
|
331
|
+
static_root: Path | None = None,
|
|
332
|
+
rate_limit: int = RATE_LIMIT,
|
|
333
|
+
request_timeout: float = REQUEST_TIMEOUT) -> None:
|
|
334
|
+
if host not in ("127.0.0.1", "localhost", "::1"):
|
|
335
|
+
raise ApiError("unsafe-bind",
|
|
336
|
+
"bind público proibido — use 127.0.0.1")
|
|
337
|
+
self._httpd = _AtlasHttpd(
|
|
338
|
+
(host, port), db_path, verbose, static_root=static_root,
|
|
339
|
+
rate_limit=rate_limit, request_timeout=request_timeout)
|
|
340
|
+
self.port = self._httpd.server_address[1]
|
|
341
|
+
self.host = host
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def url(self) -> str:
|
|
345
|
+
return f"http://{self.host}:{self.port}{API_PREFIX}"
|
|
346
|
+
|
|
347
|
+
def serve_forever(self) -> None:
|
|
348
|
+
self._httpd.serve_forever()
|
|
349
|
+
|
|
350
|
+
def shutdown(self) -> None:
|
|
351
|
+
self._httpd.shutdown()
|
|
352
|
+
self._httpd.server_close()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def explorer_static_root(repo_hint: Path | None = None) -> Path | None:
|
|
356
|
+
"""Resolve a dist compilada do Explorer para ``static_root``.
|
|
357
|
+
|
|
358
|
+
Ordem (lf-0148, cycle-8.1 Phase 3): ``code_atlas/explorer_dist``
|
|
359
|
+
empacotado no wheel via ``importlib.resources`` → checkout do repo
|
|
360
|
+
em modo dev (``explorer/dist/explorer/browser`` ou
|
|
361
|
+
``explorer/dist`` relativo a ``repo_hint``). Retorna ``None``
|
|
362
|
+
quando nenhum candidato tem ``index.html`` — o servidor sobe
|
|
363
|
+
API-only nesse caso.
|
|
364
|
+
|
|
365
|
+
Wheels são sempre instaladas descompactadas, então o traversable é
|
|
366
|
+
um ``Path`` real; loaders exóticos (zipimport) caem no fallback em
|
|
367
|
+
vez de quebrar.
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
from importlib.resources import files
|
|
371
|
+
packaged = files("code_atlas").joinpath("explorer_dist")
|
|
372
|
+
if (isinstance(packaged, Path)
|
|
373
|
+
and (packaged / "index.html").is_file()):
|
|
374
|
+
return packaged
|
|
375
|
+
except (ModuleNotFoundError, TypeError, OSError):
|
|
376
|
+
pass
|
|
377
|
+
if repo_hint is not None:
|
|
378
|
+
for candidate in (
|
|
379
|
+
repo_hint / "explorer" / "dist" / "explorer" / "browser",
|
|
380
|
+
repo_hint / "explorer" / "dist"):
|
|
381
|
+
if (candidate / "index.html").is_file():
|
|
382
|
+
return candidate
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def serve(db_path: str, host: str = "127.0.0.1", port: int = 8741,
|
|
387
|
+
verbose: bool = False,
|
|
388
|
+
static_root: Path | None = None,
|
|
389
|
+
rate_limit: int = RATE_LIMIT,
|
|
390
|
+
request_timeout: float = REQUEST_TIMEOUT) -> AtlasApiServer:
|
|
391
|
+
"""Sobe o servidor — retorna handle para shutdown limpo."""
|
|
392
|
+
return AtlasApiServer(db_path, host=host, port=port, verbose=verbose,
|
|
393
|
+
static_root=static_root, rate_limit=rate_limit,
|
|
394
|
+
request_timeout=request_timeout)
|