aics-cli 0.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.
aics_cli/net.py ADDED
@@ -0,0 +1,117 @@
1
+ """HTTP GET with the stdlib only.
2
+
3
+ * HTTPS is required, except plain HTTP to a loopback host (local testing).
4
+ * Redirects are not followed: a 3xx comes back as a status (tlog-tiles
5
+ resources and checkpoints must not redirect, AICS-1 §6.1, §12.2).
6
+ * Responses are size-capped. HTTP error statuses are returned, not raised;
7
+ only transport failures raise :class:`FetchError`.
8
+ * Nothing fetched here is trusted: every caller verifies what it gets
9
+ against keys pinned out of band.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ipaddress
15
+ import socket
16
+ import ssl
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from dataclasses import dataclass, field
21
+ from typing import Protocol
22
+
23
+ from . import __version__
24
+ from .errors import FetchError
25
+
26
+ DEFAULT_TIMEOUT = 15.0
27
+ DEFAULT_MAX_BYTES = 4 * 1024 * 1024
28
+
29
+
30
+ @dataclass
31
+ class Response:
32
+ status: int
33
+ body: bytes
34
+ url: str
35
+ headers: dict[str, str] = field(default_factory=dict)
36
+
37
+ @property
38
+ def content_type(self) -> str:
39
+ return self.headers.get("content-type", "").split(";")[0].strip().lower()
40
+
41
+
42
+ class Fetcher(Protocol):
43
+ def get(self, url: str, *, max_bytes: int = DEFAULT_MAX_BYTES, accept: str | None = None) -> Response: ...
44
+
45
+
46
+ def _is_loopback(host: str | None) -> bool:
47
+ if not host:
48
+ return False
49
+ if host == "localhost" or host.endswith(".localhost"):
50
+ return True
51
+ try:
52
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
53
+ except ValueError:
54
+ return False
55
+
56
+
57
+ def check_url(url: str) -> str:
58
+ """Raise FetchError unless ``url`` is https, or http to a loopback host."""
59
+ try:
60
+ parts = urllib.parse.urlsplit(url)
61
+ except ValueError as exc:
62
+ raise FetchError(f"bad URL {url!r}: {exc}") from None
63
+ if parts.scheme == "https" and parts.hostname:
64
+ return url
65
+ if parts.scheme == "http" and _is_loopback(parts.hostname):
66
+ return url
67
+ raise FetchError(f"refusing {url!r}: only https:// (or http:// to localhost) is allowed")
68
+
69
+
70
+ class _NoRedirects(urllib.request.HTTPRedirectHandler):
71
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
72
+ return None # urllib then raises HTTPError, returned to the caller as a 3xx status
73
+
74
+
75
+ class HttpFetcher:
76
+ """urllib-based fetcher with a timeout, a size cap and no redirects."""
77
+
78
+ def __init__(self, timeout: float = DEFAULT_TIMEOUT, user_agent: str | None = None) -> None:
79
+ self.timeout = timeout
80
+ self.user_agent = user_agent or f"aics/{__version__} (+https://wesupport-ai.com/card)"
81
+ self._opener = urllib.request.build_opener(
82
+ _NoRedirects(), urllib.request.HTTPSHandler(context=ssl.create_default_context())
83
+ )
84
+
85
+ def get(self, url: str, *, max_bytes: int = DEFAULT_MAX_BYTES, accept: str | None = None) -> Response:
86
+ check_url(url)
87
+ headers = {"User-Agent": self.user_agent}
88
+ if accept:
89
+ headers["Accept"] = accept
90
+ req = urllib.request.Request(url, headers=headers, method="GET")
91
+ try:
92
+ with self._opener.open(req, timeout=self.timeout) as resp:
93
+ body = resp.read(max_bytes + 1)
94
+ status = resp.status
95
+ final_url = resp.geturl()
96
+ hdrs = {k.lower(): v for k, v in resp.headers.items()}
97
+ except urllib.error.HTTPError as exc:
98
+ try:
99
+ body = exc.read(min(max_bytes, 65536))
100
+ except Exception:
101
+ body = b""
102
+ return Response(
103
+ status=exc.code, body=body, url=url, headers={k.lower(): v for k, v in (exc.headers or {}).items()}
104
+ )
105
+ except FetchError:
106
+ raise
107
+ except (urllib.error.URLError, TimeoutError, ssl.SSLError, ConnectionError, OSError) as exc:
108
+ reason = getattr(exc, "reason", exc)
109
+ raise FetchError(f"cannot fetch {url}: {reason}") from None
110
+ if len(body) > max_bytes:
111
+ raise FetchError(f"response from {url} exceeds {max_bytes} bytes")
112
+ return Response(status=status, body=body, url=final_url, headers=hdrs)
113
+
114
+
115
+ def join_url(base: str, path: str) -> str:
116
+ """``base`` + ``path`` with exactly one slash between them."""
117
+ return base.rstrip("/") + "/" + path.lstrip("/")