redblack-python-sdk 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.
redblack/downloads.py ADDED
@@ -0,0 +1,410 @@
1
+ """Credential-free, validated downloads for short-lived export URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import os
7
+ import socket
8
+ import tempfile
9
+ from collections.abc import Callable, Iterable, Iterator, Mapping
10
+ from contextlib import ExitStack, contextmanager, suppress
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, BinaryIO, cast
14
+ from urllib.parse import urljoin, urlsplit, urlunsplit
15
+
16
+ import httpx
17
+
18
+ from .errors import RedBlackError
19
+
20
+ _REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
21
+ _SAFE_DOWNLOAD_HEADERS = {
22
+ "Accept": "text/csv, application/octet-stream;q=0.9",
23
+ # Pinned URLs use the IP address as the HTTPX connection-pool origin.
24
+ # Prevent a redirect to another hostname on that IP from reusing TLS with
25
+ # the first hostname's SNI identity.
26
+ "Connection": "close",
27
+ }
28
+
29
+
30
+ class DownloadError(RedBlackError):
31
+ """Base class for export-storage download failures."""
32
+
33
+
34
+ class DownloadSecurityError(DownloadError):
35
+ """The supplied storage URL violates the download security policy."""
36
+
37
+
38
+ class DownloadExistsError(DownloadError):
39
+ """The destination already exists and overwrite was not enabled."""
40
+
41
+
42
+ AddressResolver = Callable[[str, int], Iterable[str]]
43
+
44
+
45
+ def _default_resolver(host: str, port: int) -> Iterable[str]:
46
+ addresses: list[str] = []
47
+ seen: set[str] = set()
48
+ for item in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM):
49
+ address = cast(str, item[4][0])
50
+ if address not in seen:
51
+ seen.add(address)
52
+ addresses.append(address)
53
+ return addresses
54
+
55
+
56
+ def _unsafe_address(address: str) -> bool:
57
+ parsed = ipaddress.ip_address(address)
58
+ return any(
59
+ (
60
+ parsed.is_private,
61
+ parsed.is_loopback,
62
+ parsed.is_link_local,
63
+ parsed.is_multicast,
64
+ parsed.is_reserved,
65
+ parsed.is_unspecified,
66
+ )
67
+ )
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class StorageUrlPolicy:
72
+ """Controls which unauthenticated storage URLs may be fetched."""
73
+
74
+ allowed_hosts: frozenset[str] | None = None
75
+ max_redirects: int = 3
76
+ resolve_hosts: bool = True
77
+
78
+ def __post_init__(self) -> None:
79
+ if self.max_redirects < 0:
80
+ raise ValueError("max_redirects must be greater than or equal to zero")
81
+ if self.allowed_hosts is not None:
82
+ normalized = frozenset(host.lower().rstrip(".") for host in self.allowed_hosts)
83
+ if not normalized or any(not host for host in normalized):
84
+ raise ValueError("allowed_hosts must contain valid host names")
85
+ object.__setattr__(self, "allowed_hosts", normalized)
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class _ValidatedStorageTarget:
90
+ host: str
91
+ port: int
92
+ path: str
93
+ query: str
94
+ addresses: tuple[str, ...]
95
+
96
+ @property
97
+ def host_header(self) -> str:
98
+ host = f"[{self.host}]" if ":" in self.host else self.host
99
+ return f"{host}:{self.port}" if self.port != 443 else host
100
+
101
+ def pinned_url(self, address: str) -> str:
102
+ authority = f"[{address}]" if ":" in address else address
103
+ if self.port != 443:
104
+ authority = f"{authority}:{self.port}"
105
+ return urlunsplit(("https", authority, self.path, self.query, ""))
106
+
107
+
108
+ def _canonical_host(host: str) -> str:
109
+ try:
110
+ return str(ipaddress.ip_address(host))
111
+ except ValueError:
112
+ pass
113
+ try:
114
+ return host.encode("idna").decode("ascii")
115
+ except UnicodeError as exc:
116
+ raise DownloadSecurityError("storage URL contains an invalid host") from exc
117
+
118
+
119
+ def _validate_storage_target(
120
+ url: str,
121
+ *,
122
+ policy: StorageUrlPolicy,
123
+ resolver: AddressResolver | None,
124
+ ) -> _ValidatedStorageTarget:
125
+ if not isinstance(url, str) or not url:
126
+ raise DownloadSecurityError("storage URL is missing")
127
+ parsed = urlsplit(url)
128
+ if parsed.scheme.lower() != "https":
129
+ raise DownloadSecurityError("storage URL must use HTTPS")
130
+ if parsed.username is not None or parsed.password is not None:
131
+ raise DownloadSecurityError("storage URL must not contain user information")
132
+ if not parsed.hostname:
133
+ raise DownloadSecurityError("storage URL must include a host")
134
+ try:
135
+ parsed_port = parsed.port
136
+ except ValueError as exc:
137
+ raise DownloadSecurityError("storage URL contains an invalid port") from exc
138
+ port = 443 if parsed_port is None else parsed_port
139
+ if port == 0:
140
+ raise DownloadSecurityError("storage URL contains an invalid port")
141
+
142
+ source_host = parsed.hostname.lower().rstrip(".")
143
+ if not source_host:
144
+ raise DownloadSecurityError("storage URL must include a host")
145
+ host = _canonical_host(source_host)
146
+ if host == "localhost" or host.endswith(".localhost"):
147
+ raise DownloadSecurityError("local storage targets are not permitted")
148
+ if policy.allowed_hosts is not None and source_host not in policy.allowed_hosts:
149
+ raise DownloadSecurityError("storage host is not in the configured allowlist")
150
+
151
+ try:
152
+ literal = ipaddress.ip_address(host)
153
+ except ValueError:
154
+ literal = None
155
+ if literal is not None:
156
+ if _unsafe_address(str(literal)):
157
+ raise DownloadSecurityError("private or local storage targets are not permitted")
158
+ addresses = (str(literal),)
159
+ elif policy.resolve_hosts:
160
+ resolve = resolver or _default_resolver
161
+ try:
162
+ resolved = tuple(resolve(source_host, port))
163
+ except OSError as exc:
164
+ raise DownloadSecurityError("storage host could not be resolved") from exc
165
+ if not resolved:
166
+ raise DownloadSecurityError("storage host did not resolve to an address")
167
+
168
+ canonical_addresses: list[str] = []
169
+ seen_addresses: set[str] = set()
170
+ try:
171
+ for address in resolved:
172
+ canonical = str(ipaddress.ip_address(address))
173
+ if _unsafe_address(canonical):
174
+ raise DownloadSecurityError(
175
+ "storage host resolves to a private or local address"
176
+ )
177
+ if canonical not in seen_addresses:
178
+ seen_addresses.add(canonical)
179
+ canonical_addresses.append(canonical)
180
+ except ValueError as exc:
181
+ raise DownloadSecurityError("storage host resolved to an invalid address") from exc
182
+ addresses = tuple(canonical_addresses)
183
+ else:
184
+ addresses = ()
185
+
186
+ return _ValidatedStorageTarget(
187
+ host=host,
188
+ port=port,
189
+ path=parsed.path,
190
+ query=parsed.query,
191
+ addresses=addresses,
192
+ )
193
+
194
+
195
+ def validate_storage_url(
196
+ url: str,
197
+ *,
198
+ policy: StorageUrlPolicy | None = None,
199
+ resolver: AddressResolver | None = None,
200
+ ) -> str:
201
+ """Validate one storage URL and return it unchanged.
202
+
203
+ Every redirect target must be passed through this function again. This
204
+ function intentionally accepts only HTTPS URLs without embedded user info.
205
+ """
206
+
207
+ _validate_storage_target(
208
+ url,
209
+ policy=policy or StorageUrlPolicy(),
210
+ resolver=resolver,
211
+ )
212
+ return url
213
+
214
+
215
+ def _header(headers: Mapping[str, str], name: str) -> str | None:
216
+ target = name.casefold()
217
+ for key, value in headers.items():
218
+ if key.casefold() == target:
219
+ return value
220
+ return None
221
+
222
+
223
+ @dataclass(frozen=True, slots=True)
224
+ class DownloadResult:
225
+ path: Path
226
+ bytes_written: int
227
+ content_type: str | None
228
+
229
+
230
+ class Downloader:
231
+ """Use a separate HTTP client so RedBlack credentials cannot leak."""
232
+
233
+ def __init__(
234
+ self,
235
+ *,
236
+ policy: StorageUrlPolicy | None = None,
237
+ client: Any | None = None,
238
+ resolver: AddressResolver | None = None,
239
+ ) -> None:
240
+ self._policy = policy or StorageUrlPolicy()
241
+ if client is not None:
242
+ self._validate_injected_client(client)
243
+ self._client = client
244
+ self._resolver = resolver
245
+
246
+ @staticmethod
247
+ def _validate_injected_client(client: object) -> None:
248
+ """Reject defaults that could be merged into a cross-origin request."""
249
+
250
+ sensitive = {
251
+ "accept-version",
252
+ "authorization",
253
+ "client-id",
254
+ "cookie",
255
+ "proxy-authorization",
256
+ "x-api-key",
257
+ }
258
+ headers = getattr(client, "headers", None)
259
+ if isinstance(headers, Mapping):
260
+ present = {str(name).casefold() for name in headers}
261
+ leaked = sensitive.intersection(present)
262
+ if leaked:
263
+ raise DownloadSecurityError(
264
+ "injected download client has credential-bearing default headers"
265
+ )
266
+ cookies = getattr(client, "cookies", None)
267
+ if cookies is not None:
268
+ try:
269
+ has_cookies = len(cookies) > 0
270
+ except (TypeError, AttributeError):
271
+ has_cookies = bool(cookies)
272
+ if has_cookies:
273
+ raise DownloadSecurityError("injected download client must not contain cookies")
274
+ configured_auth = getattr(client, "_auth", None)
275
+ if configured_auth is not None:
276
+ raise DownloadSecurityError(
277
+ "injected download client must not have default authentication"
278
+ )
279
+
280
+ @contextmanager
281
+ def _pinned_response(
282
+ self,
283
+ client: Any,
284
+ target: _ValidatedStorageTarget,
285
+ ) -> Iterator[Any]:
286
+ if not target.addresses:
287
+ raise DownloadSecurityError(
288
+ "storage downloads require resolve_hosts=True for hostname pinning"
289
+ )
290
+
291
+ last_error: httpx.TransportError | None = None
292
+ for address in target.addresses:
293
+ headers = dict(_SAFE_DOWNLOAD_HEADERS)
294
+ headers["Host"] = target.host_header
295
+ with ExitStack() as stack:
296
+ try:
297
+ response = stack.enter_context(
298
+ client.stream(
299
+ "GET",
300
+ target.pinned_url(address),
301
+ headers=headers,
302
+ follow_redirects=False,
303
+ # HTTPcore uses this extension for both TLS SNI and
304
+ # certificate hostname verification while TCP is
305
+ # connected to the vetted IP address.
306
+ extensions={"sni_hostname": target.host},
307
+ )
308
+ )
309
+ except httpx.TransportError as exc:
310
+ last_error = exc
311
+ continue
312
+ yield response
313
+ return
314
+
315
+ raise DownloadError("storage download failed before receiving a response") from last_error
316
+
317
+ @contextmanager
318
+ def stream(
319
+ self, url: str, *, chunk_size: int = 64 * 1024
320
+ ) -> Iterator[tuple[Iterator[bytes], str | None]]:
321
+ """Yield a byte iterator and content type, closing all responses safely."""
322
+
323
+ if chunk_size <= 0:
324
+ raise ValueError("chunk_size must be greater than zero")
325
+ client = self._client or httpx.Client(
326
+ follow_redirects=False,
327
+ trust_env=False,
328
+ timeout=httpx.Timeout(30.0, connect=10.0),
329
+ )
330
+ owns_client = self._client is None
331
+ current = url
332
+ try:
333
+ for redirect_count in range(self._policy.max_redirects + 1):
334
+ target = _validate_storage_target(
335
+ current,
336
+ policy=self._policy,
337
+ resolver=self._resolver,
338
+ )
339
+ with self._pinned_response(client, target) as response:
340
+ if response.status_code in _REDIRECT_STATUSES:
341
+ if redirect_count >= self._policy.max_redirects:
342
+ raise DownloadError("storage redirect limit exceeded")
343
+ location = _header(response.headers, "Location")
344
+ if not location:
345
+ raise DownloadError("storage redirect omitted Location")
346
+ current = urljoin(current, location)
347
+ continue
348
+ if response.status_code < 200 or response.status_code >= 300:
349
+ raise DownloadError(
350
+ f"storage download failed with HTTP {response.status_code}"
351
+ )
352
+ yield response.iter_bytes(chunk_size), _header(response.headers, "Content-Type")
353
+ return
354
+ raise DownloadError("storage redirect limit exceeded")
355
+ finally:
356
+ if owns_client:
357
+ client.close()
358
+
359
+ def download_to(
360
+ self,
361
+ url: str,
362
+ destination: str | os.PathLike[str],
363
+ *,
364
+ overwrite: bool = False,
365
+ chunk_size: int = 64 * 1024,
366
+ ) -> DownloadResult:
367
+ """Stream to a same-directory temporary file, then publish atomically."""
368
+
369
+ if chunk_size <= 0:
370
+ raise ValueError("chunk_size must be greater than zero")
371
+ target = Path(destination)
372
+ parent = target.parent
373
+ if not parent.exists():
374
+ raise FileNotFoundError(f"destination directory does not exist: {parent}")
375
+ if target.exists() and not overwrite:
376
+ raise DownloadExistsError(f"destination already exists: {target}")
377
+
378
+ descriptor, temporary_name = tempfile.mkstemp(
379
+ dir=parent, prefix=f".{target.name}.", suffix=".part"
380
+ )
381
+ temporary = Path(temporary_name)
382
+ bytes_written = 0
383
+ content_type: str | None = None
384
+ try:
385
+ with os.fdopen(descriptor, "wb") as output:
386
+ output_file: BinaryIO = output
387
+ with self.stream(url, chunk_size=chunk_size) as (chunks, content_type):
388
+ for chunk in chunks:
389
+ if not chunk:
390
+ continue
391
+ output_file.write(chunk)
392
+ bytes_written += len(chunk)
393
+ output_file.flush()
394
+ os.fsync(output_file.fileno())
395
+
396
+ if overwrite:
397
+ os.replace(temporary, target)
398
+ else:
399
+ try:
400
+ os.link(temporary, target)
401
+ except FileExistsError as exc:
402
+ raise DownloadExistsError(f"destination already exists: {target}") from exc
403
+ temporary.unlink()
404
+ return DownloadResult(
405
+ path=target, bytes_written=bytes_written, content_type=content_type
406
+ )
407
+ except BaseException:
408
+ with suppress(OSError):
409
+ temporary.unlink(missing_ok=True)
410
+ raise