feat: add secure contract import CLI
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""Command-line interface for contract imports and inspection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.contracts.importer import RemoteSourceImporter
|
||||
from app.contracts.normalize import normalize_snapshot
|
||||
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceImporter
|
||||
from app.contracts.store import RevisionStore
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
root = argparse.ArgumentParser(prog="proxmox-api-contract")
|
||||
root.add_argument("--store", type=Path, default=Path("contracts"))
|
||||
commands = root.add_subparsers(dest="command", required=True)
|
||||
import_command = commands.add_parser("import")
|
||||
source = import_command.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--file", type=Path)
|
||||
source.add_argument("--url")
|
||||
import_command.add_argument("--version", required=True)
|
||||
validate = commands.add_parser("validate")
|
||||
validate.add_argument("file", type=Path)
|
||||
commands.add_parser("list")
|
||||
show = commands.add_parser("show")
|
||||
show.add_argument("revision")
|
||||
return root
|
||||
|
||||
|
||||
async def run(arguments: argparse.Namespace) -> int:
|
||||
store = RevisionStore(arguments.store)
|
||||
if arguments.command == "list":
|
||||
for revision in store.list():
|
||||
print(revision)
|
||||
return 0
|
||||
if arguments.command == "show":
|
||||
print(json.dumps(store.manifest(arguments.revision).model_dump(mode="json"), indent=2))
|
||||
return 0
|
||||
if arguments.command == "validate":
|
||||
parsed = ApiViewerParser().parse(arguments.file.read_bytes())
|
||||
print(json.dumps({"nodes": len(parsed.nodes), "warnings": len(parsed.warnings)}))
|
||||
return 0
|
||||
importer: SourceImporter
|
||||
if arguments.file is not None:
|
||||
importer = LocalFileImporter(arguments.file)
|
||||
else:
|
||||
importer = RemoteSourceImporter(arguments.url)
|
||||
raw = await importer.load()
|
||||
parsed = ApiViewerParser().parse(raw)
|
||||
snapshot, manifest = normalize_snapshot(
|
||||
parsed, raw=raw, source_version=arguments.version, retrieved_at=datetime.now(UTC)
|
||||
)
|
||||
print(store.save(raw, snapshot, manifest))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit(asyncio.run(run(parser().parse_args())))
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Network-constrained remote contract retrieval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from app.contracts.source import SourceError
|
||||
|
||||
Resolver = Callable[[str], Awaitable[tuple[str, ...]]]
|
||||
|
||||
|
||||
async def resolve_host(host: str) -> tuple[str, ...]:
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
|
||||
return tuple(sorted({str(result[4][0]) for result in results}))
|
||||
|
||||
|
||||
def validate_remote_url(url: str, allowed_hosts: frozenset[str]) -> str:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != "https":
|
||||
raise SourceError("remote imports require HTTPS")
|
||||
if parsed.username or parsed.password or parsed.port not in (None, 443):
|
||||
raise SourceError("remote URL contains forbidden authority components")
|
||||
host = (parsed.hostname or "").rstrip(".").lower()
|
||||
if host not in allowed_hosts:
|
||||
raise SourceError("remote host is not in the official-domain allowlist")
|
||||
if parsed.fragment:
|
||||
raise SourceError("remote URL fragments are not allowed")
|
||||
return host
|
||||
|
||||
|
||||
def validate_public_addresses(addresses: tuple[str, ...]) -> None:
|
||||
if not addresses:
|
||||
raise SourceError("remote host did not resolve")
|
||||
for value in addresses:
|
||||
address = ipaddress.ip_address(value)
|
||||
if not address.is_global:
|
||||
raise SourceError(f"remote host resolved to a non-public address: {value}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteSourceImporter:
|
||||
url: str
|
||||
allowed_hosts: frozenset[str] = frozenset({"pve.proxmox.com"})
|
||||
max_bytes: int = 16 * 1024 * 1024
|
||||
max_redirects: int = 3
|
||||
retries: int = 2
|
||||
timeout_seconds: float = 20.0
|
||||
resolver: Resolver = resolve_host
|
||||
transport: httpx.AsyncBaseTransport | None = None
|
||||
|
||||
async def load(self) -> bytes:
|
||||
current = self.url
|
||||
timeout = httpx.Timeout(self.timeout_seconds)
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False, timeout=timeout, transport=self.transport
|
||||
) as client:
|
||||
for redirect_count in range(self.max_redirects + 1):
|
||||
host = validate_remote_url(current, self.allowed_hosts)
|
||||
validate_public_addresses(await self.resolver(host))
|
||||
response = await self._request(client, current)
|
||||
if response.is_redirect:
|
||||
if redirect_count == self.max_redirects:
|
||||
raise SourceError("remote import exceeded redirect limit")
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
raise SourceError("remote redirect has no location")
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
content_length = response.headers.get("content-length")
|
||||
if content_length and int(content_length) > self.max_bytes:
|
||||
raise SourceError("remote artifact exceeds size limit")
|
||||
content = response.content
|
||||
if len(content) > self.max_bytes:
|
||||
raise SourceError("remote artifact exceeds size limit")
|
||||
return content
|
||||
raise SourceError("remote import failed")
|
||||
|
||||
async def _request(self, client: httpx.AsyncClient, url: str) -> httpx.Response:
|
||||
for attempt in range(self.retries + 1):
|
||||
try:
|
||||
return await client.get(url)
|
||||
except (httpx.TimeoutException, httpx.NetworkError):
|
||||
if attempt == self.retries:
|
||||
raise
|
||||
await asyncio.sleep(0.1 * (2**attempt))
|
||||
raise SourceError("remote import retry loop exhausted")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Immutable filesystem storage for imported contract revisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from app.contracts.model import Manifest, Snapshot, canonical_json
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RevisionStore:
|
||||
root: Path
|
||||
|
||||
def save(self, raw: bytes, snapshot: Snapshot, manifest: Manifest) -> Path:
|
||||
revision = self.root / manifest.snapshot_sha256
|
||||
if revision.exists():
|
||||
return revision
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
temporary = Path(tempfile.mkdtemp(prefix=".import-", dir=self.root))
|
||||
try:
|
||||
self._write(temporary / "raw.js", raw)
|
||||
self._write(temporary / "snapshot.json", snapshot.canonical_bytes())
|
||||
self._write(temporary / "manifest.json", canonical_json(manifest))
|
||||
os.replace(temporary, revision)
|
||||
except BaseException:
|
||||
for child in temporary.iterdir():
|
||||
child.unlink()
|
||||
temporary.rmdir()
|
||||
raise
|
||||
return revision
|
||||
|
||||
def list(self) -> tuple[str, ...]:
|
||||
if not self.root.exists():
|
||||
return ()
|
||||
return tuple(sorted(path.name for path in self.root.iterdir() if path.is_dir()))
|
||||
|
||||
def manifest(self, revision: str) -> Manifest:
|
||||
return Manifest.model_validate_json((self.root / revision / "manifest.json").read_bytes())
|
||||
|
||||
@staticmethod
|
||||
def _write(path: Path, content: bytes) -> None:
|
||||
with path.open("xb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
Reference in New Issue
Block a user