dex-agents-cli-core 0.5.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.
@@ -0,0 +1,345 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ """The Dex server a command uses: a URL, a running ``dexcli dev``, or one it starts."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import secrets
17
+ import subprocess
18
+ import tempfile
19
+ from collections.abc import Callable
20
+ from dataclasses import asdict, dataclass, replace
21
+ from enum import Enum
22
+ from pathlib import Path
23
+ from urllib.parse import urlsplit
24
+
25
+ import httpx2
26
+ from dex import (
27
+ AsyncClient,
28
+ BlobCacheConfig,
29
+ ClientOptions,
30
+ FlowNotFoundError,
31
+ Registry,
32
+ open_blob_cache,
33
+ )
34
+
35
+ from dex_agents_cli_core.config import AUTO
36
+ from dex_agents_cli_core.dexcli_install import DexcliInstallError, download_dexcli, locate_dexcli
37
+ from dex_agents_cli_core.environment import CommandEnvironment
38
+
39
+ #: The line ``dexcli dev`` prints once Dex Server and Dex Web are ready.
40
+ DEXCLI_DEV_READY_LINE = "Dex development environment is ready"
41
+ _READY_BLOCK_END_LINE = "Press Ctrl+C to stop."
42
+ _READY_FIELD_PREFIXES = {
43
+ "web_url": "Dex Web:",
44
+ "dex_address": "Dex Server:",
45
+ "connectors_mcp_url": "Connectors MCP:",
46
+ }
47
+ _START_TIMEOUT_SECONDS = 120.0
48
+ _READINESS_WAIT_SECONDS = 60.0
49
+ _PROBE_TIMEOUT_SECONDS = 2.0
50
+ _FLOW_SERVICE_PROBE_TIMEOUT_SECONDS = 5.0
51
+ _POLL_INTERVAL_SECONDS = 0.2
52
+ _LOG_TAIL_LINES = 20
53
+ _PROBE_BLOB_CACHE_BYTES = 1024 * 1024
54
+
55
+
56
+ class DexServerError(Exception):
57
+ """Report that no Dex server is available, with what to do."""
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class DexServerEndpoint:
62
+ """The Dex server one command run talks to.
63
+
64
+ Attributes:
65
+ address: The gRPC target, ``host:port``.
66
+ is_tls: Whether the target uses TLS (an ``https`` URL).
67
+ web_url: Dex Web's URL, or ``None`` when unknown.
68
+ connectors_mcp_url: The connectors MCP endpoint ``dexcli dev``
69
+ serves, or ``None`` when unknown.
70
+ started_pid: The ``dexcli dev`` process this run started, or ``None``.
71
+ """
72
+
73
+ address: str
74
+ is_tls: bool = False
75
+ web_url: str | None = None
76
+ connectors_mcp_url: str | None = None
77
+ started_pid: int | None = None
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class _DevServerState:
82
+ pid: int
83
+ dex_address: str
84
+ web_url: str
85
+ connectors_mcp_url: str | None
86
+
87
+
88
+ class _Readiness(Enum):
89
+ ABSENT = "absent"
90
+ READY = "ready"
91
+ STARTING = "starting"
92
+ UNKNOWN_SERVICE = "unknown_service"
93
+
94
+
95
+ async def resolve_dex_server(
96
+ server: str,
97
+ environment: CommandEnvironment,
98
+ *,
99
+ report: Callable[[str], None],
100
+ ) -> DexServerEndpoint:
101
+ """Find the Dex server of a ``dex.server`` value, starting ``dexcli dev`` when needed.
102
+
103
+ ``auto`` reuses a ``dexcli dev`` on the configured ports only when it
104
+ identifies itself as one, even when ``~/.dex/agents/dexcli-dev.json``
105
+ records it: Dex Web's ``/readyz`` answers with its readiness JSON and,
106
+ within five seconds, the Dex port answers as FlowService, describing an
107
+ unknown Flow as not found. Something else on those ports is an error, so
108
+ no other process can stand in for Dex or the connectors MCP server. With nothing there, it
109
+ starts ``dexcli dev`` in the background, which keeps running after the
110
+ command exits.
111
+
112
+ Args:
113
+ server: ``auto`` or a Dex server URL.
114
+ environment: The machine settings.
115
+ report: Prints one progress line for the user.
116
+
117
+ Returns:
118
+ The endpoint.
119
+
120
+ Raises:
121
+ DexServerError: If another service holds the ports, or ``dexcli dev``
122
+ cannot be found, downloaded, or started.
123
+ """
124
+ if server != AUTO:
125
+ parts = urlsplit(server)
126
+ port = parts.port or (443 if parts.scheme == "https" else 80)
127
+ return DexServerEndpoint(address=f"{parts.hostname}:{port}", is_tls=parts.scheme == "https")
128
+ web_url = f"http://127.0.0.1:{environment.dev_web_port}"
129
+ address = f"127.0.0.1:{environment.dev_dex_port}"
130
+ readiness = await _read_readiness(web_url)
131
+ if readiness is _Readiness.ABSENT:
132
+ return await _start_dev_server(environment, report)
133
+ if readiness is _Readiness.STARTING:
134
+ report(f"Waiting for the dexcli dev at {web_url} to become ready.")
135
+ readiness = await _wait_for_readiness(web_url)
136
+ started = _read_started_server(environment, web_url, address)
137
+ is_identified = readiness is _Readiness.READY and await _is_dex_flow_service(address)
138
+ if not is_identified:
139
+ raise DexServerError(
140
+ f"{web_url} answers, but it is not a dexcli dev with Dex at {address}. Stop that "
141
+ "service, or choose other ports with DEX_AGENTS_DEX_PORT and DEX_AGENTS_WEB_PORT, "
142
+ "or set dex.server to a Dex server URL."
143
+ )
144
+ report(f"Using the running dexcli dev at {address} (Dex Web {web_url}).")
145
+ mcp_url = started.connectors_mcp_url if started is not None else None
146
+ return DexServerEndpoint(
147
+ address=address, web_url=web_url, connectors_mcp_url=mcp_url or f"{web_url}/mcp"
148
+ )
149
+
150
+
151
+ def _read_started_server(
152
+ environment: CommandEnvironment, web_url: str, address: str
153
+ ) -> _DevServerState | None:
154
+ """Return the recorded ``dexcli dev`` when it still runs on exactly these ports."""
155
+ try:
156
+ recorded = json.loads(environment.dev_server_state_path.read_text(encoding="utf-8"))
157
+ state = _DevServerState(**recorded)
158
+ except (OSError, ValueError, TypeError):
159
+ return None
160
+ if state.web_url != web_url or state.dex_address != address:
161
+ return None
162
+ if not _is_http_url(state.connectors_mcp_url):
163
+ # Earlier releases could record a later log line here instead of the URL.
164
+ state = replace(state, connectors_mcp_url=None)
165
+ return state if _is_process_alive(state.pid) else None
166
+
167
+
168
+ async def _start_dev_server(
169
+ environment: CommandEnvironment, report: Callable[[str], None]
170
+ ) -> DexServerEndpoint:
171
+ try:
172
+ dexcli = locate_dexcli(environment)
173
+ if dexcli is None:
174
+ report(f"dexcli is not installed; downloading dexcli {environment.dexcli_version}.")
175
+ dexcli = download_dexcli(environment)
176
+ except DexcliInstallError as error:
177
+ raise DexServerError(str(error)) from error
178
+ arguments = [
179
+ str(dexcli),
180
+ "dev",
181
+ "-open=false",
182
+ f"-dex-port={environment.dev_dex_port}",
183
+ f"-web-port={environment.dev_web_port}",
184
+ ]
185
+ environment.agents_directory.mkdir(parents=True, exist_ok=True)
186
+ log_path = environment.agents_directory / "dexcli-dev.log"
187
+ report(f"Starting dexcli dev in the background (log: {log_path}).")
188
+ with log_path.open("w", encoding="utf-8") as log:
189
+ try:
190
+ process = subprocess.Popen(
191
+ arguments,
192
+ stdin=subprocess.DEVNULL,
193
+ stdout=log,
194
+ stderr=subprocess.STDOUT,
195
+ # Its own session keeps it running, and out of Ctrl-C, after the command exits.
196
+ start_new_session=True,
197
+ )
198
+ except OSError as error:
199
+ raise DexServerError(f"cannot run {dexcli}: {error.strerror}") from error
200
+ ready = await _wait_for_ready_output(process, log_path)
201
+ state = _DevServerState(
202
+ pid=process.pid,
203
+ dex_address=ready["dex_address"],
204
+ web_url=ready["web_url"],
205
+ connectors_mcp_url=ready.get("connectors_mcp_url"),
206
+ )
207
+ environment.dev_server_state_path.write_text(json.dumps(asdict(state)), encoding="utf-8")
208
+ report(
209
+ f"dexcli dev is ready (pid {process.pid}); it keeps running after this command exits. "
210
+ f"Stop it with: kill {process.pid}"
211
+ )
212
+ return DexServerEndpoint(
213
+ address=state.dex_address,
214
+ web_url=state.web_url,
215
+ connectors_mcp_url=state.connectors_mcp_url or f"{state.web_url}/mcp",
216
+ started_pid=process.pid,
217
+ )
218
+
219
+
220
+ async def _wait_for_ready_output(
221
+ process: subprocess.Popen[bytes], log_path: Path
222
+ ) -> dict[str, str]:
223
+ loop = asyncio.get_running_loop()
224
+ deadline = loop.time() + _START_TIMEOUT_SECONDS
225
+ while loop.time() < deadline:
226
+ text = log_path.read_text(encoding="utf-8", errors="replace")
227
+ if DEXCLI_DEV_READY_LINE in text:
228
+ fields = _parse_ready_fields(text)
229
+ if "web_url" in fields and "dex_address" in fields:
230
+ return fields
231
+ if process.poll() is not None:
232
+ raise DexServerError(
233
+ f"dexcli dev exited with code {process.returncode}:\n{_tail(log_path)}"
234
+ )
235
+ await asyncio.sleep(_POLL_INTERVAL_SECONDS)
236
+ process.kill()
237
+ raise DexServerError(
238
+ f"dexcli dev was not ready in {_START_TIMEOUT_SECONDS:.0f}s:\n{_tail(log_path)}"
239
+ )
240
+
241
+
242
+ def _parse_ready_fields(text: str) -> dict[str, str]:
243
+ # Later log lines, such as "Connectors MCP: started server ...", reuse these prefixes.
244
+ _, _, ready_block = text.rpartition(DEXCLI_DEV_READY_LINE)
245
+ fields: dict[str, str] = {}
246
+ for line in ready_block.splitlines():
247
+ stripped = line.strip()
248
+ if stripped == _READY_BLOCK_END_LINE:
249
+ break
250
+ for name, prefix in _READY_FIELD_PREFIXES.items():
251
+ if name not in fields and stripped.startswith(prefix):
252
+ fields[name] = stripped.removeprefix(prefix).strip()
253
+ if not _is_http_url(fields.get("connectors_mcp_url")):
254
+ fields.pop("connectors_mcp_url", None)
255
+ return fields
256
+
257
+
258
+ def _is_http_url(value: str | None) -> bool:
259
+ if not value or any(character.isspace() for character in value):
260
+ return False
261
+ try:
262
+ parts = urlsplit(value)
263
+ except ValueError:
264
+ return False
265
+ return parts.scheme in ("http", "https") and bool(parts.netloc)
266
+
267
+
268
+ async def _read_readiness(web_url: str) -> _Readiness:
269
+ try:
270
+ async with httpx2.AsyncClient(timeout=_PROBE_TIMEOUT_SECONDS) as http:
271
+ response = await http.get(f"{web_url}/readyz")
272
+ except httpx2.HTTPError:
273
+ return _Readiness.ABSENT
274
+ try:
275
+ body = response.json()
276
+ except ValueError:
277
+ return _Readiness.UNKNOWN_SERVICE
278
+ if response.status_code == 200:
279
+ return _Readiness.READY if _is_dex_web_readiness(body) else _Readiness.UNKNOWN_SERVICE
280
+ # Dex Web answers 503 with a coded error while Dex is starting.
281
+ is_starting = response.status_code == 503 and isinstance(body, dict)
282
+ return _Readiness.STARTING if is_starting else _Readiness.UNKNOWN_SERVICE
283
+
284
+
285
+ def _is_dex_web_readiness(body: object) -> bool:
286
+ """Report whether a /readyz body is Dex Web's readiness JSON, by its values' types."""
287
+ if not isinstance(body, dict) or body.get("status") != "ready":
288
+ return False
289
+ count = body.get("definitionCount")
290
+ return (
291
+ isinstance(body.get("definitionRevision"), str)
292
+ and isinstance(body.get("source"), str)
293
+ and isinstance(count, int)
294
+ and not isinstance(count, bool)
295
+ )
296
+
297
+
298
+ async def _wait_for_readiness(web_url: str) -> _Readiness:
299
+ loop = asyncio.get_running_loop()
300
+ deadline = loop.time() + _READINESS_WAIT_SECONDS
301
+ readiness = _Readiness.STARTING
302
+ while readiness is _Readiness.STARTING and loop.time() < deadline:
303
+ await asyncio.sleep(_POLL_INTERVAL_SECONDS)
304
+ readiness = await _read_readiness(web_url)
305
+ return readiness
306
+
307
+
308
+ async def _is_dex_flow_service(address: str) -> bool:
309
+ """Report whether a port answers as Dex's FlowService: an unknown Flow is not found."""
310
+ with tempfile.TemporaryDirectory(prefix="dex-agents-probe-") as cache_directory:
311
+ blob_cache = open_blob_cache(BlobCacheConfig(cache_directory, _PROBE_BLOB_CACHE_BYTES))
312
+ client = AsyncClient(
313
+ Registry([], allow_async_handlers=True),
314
+ blob_cache,
315
+ ClientOptions(server_address=address),
316
+ )
317
+ probe_flow_id = f"dex-agents-cli-probe-{secrets.token_hex(8)}"
318
+ try:
319
+ await asyncio.wait_for(
320
+ client.describe_flow(probe_flow_id), _FLOW_SERVICE_PROBE_TIMEOUT_SECONDS
321
+ )
322
+ except FlowNotFoundError:
323
+ return True
324
+ except Exception:
325
+ # Any other answer means the port is not a Dex server this command can trust.
326
+ return False
327
+ finally:
328
+ await client.close()
329
+ blob_cache.close()
330
+ return False
331
+
332
+
333
+ def _is_process_alive(pid: int) -> bool:
334
+ try:
335
+ os.kill(pid, 0)
336
+ except ProcessLookupError:
337
+ return False
338
+ except PermissionError:
339
+ return True
340
+ return True
341
+
342
+
343
+ def _tail(log_path: Path) -> str:
344
+ lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
345
+ return "\n".join(lines[-_LOG_TAIL_LINES:])
@@ -0,0 +1,314 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ """Find ``dexcli``, or download a release with its checksum into ``~/.dex/bin``."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import platform
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tarfile
22
+ import tempfile
23
+ from pathlib import Path, PurePosixPath
24
+ from urllib.parse import urlsplit
25
+
26
+ import httpx2
27
+
28
+ from dex_agents_cli_core.environment import CommandEnvironment
29
+
30
+ #: The executables a ``dexcli`` release archive holds; ``dexcli dev`` runs both.
31
+ DEXCLI_EXECUTABLES = ("dexcli", "dex-server-zd")
32
+ #: The file in an installed release's directory that records each executable's SHA-256.
33
+ INSTALL_RECORD_NAME = "install.json"
34
+ _DOWNLOAD_TIMEOUT_SECONDS = 300.0
35
+ _VERSION_TIMEOUT_SECONDS = 30.0
36
+ _MAX_EXECUTABLE_BYTES = 512 * 1024 * 1024
37
+ _MAX_REDIRECTS = 5
38
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
39
+ _VERSION = re.compile(r"v?(\d+)\.(\d+)\.(\d+)")
40
+
41
+
42
+ class DexcliInstallError(Exception):
43
+ """Report that ``dexcli`` is not installed, cannot be downloaded, or was changed."""
44
+
45
+
46
+ def locate_dexcli(environment: CommandEnvironment) -> Path | None:
47
+ """Return the ``dexcli`` to run without downloading one.
48
+
49
+ Args:
50
+ environment: The machine settings.
51
+
52
+ Returns:
53
+ ``DEXCLI``; else the downloaded release in ``~/.dex/bin/dexcli-<version>``,
54
+ after checking both executables against their recorded SHA-256; else
55
+ the first ``dexcli`` on ``PATH`` whose version is at least the pinned
56
+ one; else ``None``.
57
+
58
+ Raises:
59
+ DexcliInstallError: If the downloaded release changed since it was installed.
60
+ """
61
+ if environment.dexcli is not None:
62
+ return environment.dexcli
63
+ installed = installed_release_directory(environment)
64
+ if installed.exists():
65
+ verify_installed_release(installed)
66
+ return installed / "dexcli"
67
+ minimum = _parse_version(environment.dexcli_version)
68
+ for directory in environment.search_path:
69
+ candidate = directory / "dexcli"
70
+ if not (candidate.is_file() and os.access(candidate, os.X_OK)):
71
+ continue
72
+ version = _read_version(candidate)
73
+ if minimum is not None and version is not None and version >= minimum:
74
+ return candidate
75
+ return None
76
+
77
+
78
+ def installed_release_directory(environment: CommandEnvironment) -> Path:
79
+ """Return ``~/.dex/bin/dexcli-<version>``, where the pinned release is installed.
80
+
81
+ Args:
82
+ environment: The machine settings.
83
+
84
+ Returns:
85
+ The directory, which may not exist.
86
+ """
87
+ return environment.bin_directory / f"dexcli-{environment.dexcli_version}"
88
+
89
+
90
+ def verify_installed_release(directory: Path) -> None:
91
+ """Check an installed release's executables against the SHA-256 recorded at install.
92
+
93
+ Args:
94
+ directory: The release's directory.
95
+
96
+ Raises:
97
+ DexcliInstallError: If the record is missing or an executable changed.
98
+ """
99
+ try:
100
+ recorded = json.loads((directory / INSTALL_RECORD_NAME).read_text(encoding="utf-8"))
101
+ digests = {name: str(recorded["sha256"][name]) for name in DEXCLI_EXECUTABLES}
102
+ except (OSError, ValueError, KeyError, TypeError) as error:
103
+ raise DexcliInstallError(
104
+ f"{directory} has no valid {INSTALL_RECORD_NAME}; delete it to download dexcli again"
105
+ ) from error
106
+ for name, digest in digests.items():
107
+ path = directory / name
108
+ is_executable = path.is_file() and os.access(path, os.X_OK)
109
+ if path.is_symlink() or not is_executable or _sha256(path) != digest:
110
+ raise DexcliInstallError(
111
+ f"{path} changed since it was installed; delete {directory} to download it again"
112
+ )
113
+
114
+
115
+ def release_asset_name(version: str, system: str, machine: str) -> str:
116
+ """Return the release archive name for a platform.
117
+
118
+ Args:
119
+ version: The ``dexcli`` version, such as ``1.45.0``.
120
+ system: ``sys.platform``, such as ``darwin`` or ``linux``.
121
+ machine: ``platform.machine()``, such as ``arm64`` or ``x86_64``.
122
+
123
+ Returns:
124
+ Such as ``dexcli_v1.45.0_darwin_arm64.tar.gz``.
125
+
126
+ Raises:
127
+ DexcliInstallError: If no release is built for the platform.
128
+ """
129
+ operating_system = {"darwin": "darwin", "linux": "linux"}.get(system)
130
+ architecture = {"arm64": "arm64", "aarch64": "arm64", "x86_64": "amd64", "amd64": "amd64"}.get(
131
+ machine.lower()
132
+ )
133
+ if operating_system is None or architecture is None:
134
+ raise DexcliInstallError(
135
+ f"no dexcli release is built for {system}/{machine}; install dexcli and put it on PATH"
136
+ )
137
+ return f"dexcli_v{version}_{operating_system}_{architecture}.tar.gz"
138
+
139
+
140
+ def download_dexcli(environment: CommandEnvironment) -> Path:
141
+ """Download the pinned ``dexcli`` release, check it, and install it in ``~/.dex/bin``.
142
+
143
+ The archive's SHA-256 must match its line in the release's
144
+ ``checksums.txt``. Every archive entry must be a plain relative path
145
+ without ``..``, a backslash, or a NUL, and every entry must be a regular
146
+ file or a directory;
147
+ otherwise nothing is installed. Only ``dexcli`` and ``dex-server-zd`` are
148
+ extracted, into a private staging directory with their SHA-256 recorded,
149
+ which then becomes ``~/.dex/bin/dexcli-<version>`` in one rename.
150
+
151
+ Args:
152
+ environment: The machine settings.
153
+
154
+ Returns:
155
+ The installed ``dexcli``.
156
+
157
+ Raises:
158
+ DexcliInstallError: If the download fails or the archive is not trusted.
159
+ """
160
+ version = environment.dexcli_version
161
+ asset = release_asset_name(version, sys.platform, platform.machine())
162
+ base = f"{environment.dexcli_release_base_url.rstrip('/')}/cli-v{version}"
163
+ target = installed_release_directory(environment)
164
+ environment.bin_directory.mkdir(parents=True, exist_ok=True)
165
+ # mkdtemp creates the staging directory readable only by this user.
166
+ staging = Path(tempfile.mkdtemp(prefix=".dexcli-staging-", dir=environment.bin_directory))
167
+ try:
168
+ checksums = _download(f"{base}/checksums.txt", staging / "checksums.txt")
169
+ expected = _expected_sha256(checksums.read_text(encoding="utf-8"), asset)
170
+ archive = _download(f"{base}/{asset}", staging / asset)
171
+ actual = _sha256(archive)
172
+ if actual != expected:
173
+ raise DexcliInstallError(
174
+ f"{asset} has SHA-256 {actual}, but checksums.txt says {expected}; not installed"
175
+ )
176
+ release = staging / "release"
177
+ release.mkdir(mode=0o700)
178
+ digests = _extract_executables(archive, release)
179
+ record = {
180
+ "version": version,
181
+ "archive": asset,
182
+ "archiveSha256": actual,
183
+ "sha256": digests,
184
+ }
185
+ (release / INSTALL_RECORD_NAME).write_text(json.dumps(record, indent=2), encoding="utf-8")
186
+ release.chmod(0o755)
187
+ if target.exists():
188
+ shutil.rmtree(target)
189
+ os.replace(release, target)
190
+ finally:
191
+ shutil.rmtree(staging, ignore_errors=True)
192
+ return target / "dexcli"
193
+
194
+
195
+ def _download(url: str, destination: Path) -> Path:
196
+ """Download a URL, checking it and every redirect: https, or http on a loopback host."""
197
+ current = url
198
+ try:
199
+ with httpx2.Client(follow_redirects=False, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as client:
200
+ for _ in range(_MAX_REDIRECTS + 1):
201
+ check_download_url(current)
202
+ with client.stream("GET", current) as response:
203
+ if response.is_redirect:
204
+ location = response.headers.get("location", "")
205
+ current = str(httpx2.URL(current).join(location))
206
+ continue
207
+ if response.status_code != 200:
208
+ raise DexcliInstallError(
209
+ f"cannot download {current}: HTTP {response.status_code}"
210
+ )
211
+ with destination.open("wb") as file:
212
+ for chunk in response.iter_bytes():
213
+ file.write(chunk)
214
+ return destination
215
+ except httpx2.HTTPError as error:
216
+ raise DexcliInstallError(f"cannot download {current}: {error}") from error
217
+ raise DexcliInstallError(f"cannot download {url}: more than {_MAX_REDIRECTS} redirects")
218
+
219
+
220
+ def check_download_url(url: str) -> None:
221
+ """Refuse a download URL that is neither https nor http on a loopback host.
222
+
223
+ Args:
224
+ url: The URL, or a redirect's target.
225
+
226
+ Raises:
227
+ DexcliInstallError: If the URL could be served or changed by another host.
228
+ """
229
+ parts = urlsplit(url)
230
+ is_loopback_http = parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS
231
+ if not parts.hostname or not (parts.scheme == "https" or is_loopback_http):
232
+ raise DexcliInstallError(
233
+ f"{url} is not an https URL, or http on 127.0.0.1, localhost, or [::1]; "
234
+ "dexcli is downloaded only over https"
235
+ )
236
+
237
+
238
+ def _expected_sha256(checksums: str, asset: str) -> str:
239
+ pattern = re.compile(rf"^([0-9a-f]{{64}}) (?:\./)?{re.escape(asset)}$", re.MULTILINE)
240
+ match = pattern.search(checksums)
241
+ if match is None:
242
+ raise DexcliInstallError(f"checksums.txt has no entry for {asset}")
243
+ return match.group(1)
244
+
245
+
246
+ def _extract_executables(archive: Path, destination: Path) -> dict[str, str]:
247
+ """Extract the executables after checking every entry; return their SHA-256 by name."""
248
+ digests: dict[str, str] = {}
249
+ try:
250
+ with tarfile.open(archive, "r:gz") as tar:
251
+ members = tar.getmembers()
252
+ for member in members:
253
+ _check_archive_entry(member)
254
+ by_name = {PurePosixPath(member.name).as_posix(): member for member in members}
255
+ for name in DEXCLI_EXECUTABLES:
256
+ executable = by_name.get(name)
257
+ if (
258
+ executable is None
259
+ or not executable.isfile()
260
+ or executable.size > _MAX_EXECUTABLE_BYTES
261
+ ):
262
+ raise DexcliInstallError(f"the release archive has no regular file {name}")
263
+ source = tar.extractfile(executable)
264
+ assert source is not None, "a regular tar member always has content"
265
+ output = destination / name
266
+ fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o755)
267
+ with source, os.fdopen(fd, "wb") as file:
268
+ shutil.copyfileobj(source, file)
269
+ digests[name] = _sha256(output)
270
+ except tarfile.TarError as error:
271
+ raise DexcliInstallError(f"the release archive cannot be read: {error}") from error
272
+ return digests
273
+
274
+
275
+ def _check_archive_entry(member: tarfile.TarInfo) -> None:
276
+ path = PurePosixPath(member.name)
277
+ is_unsafe_text = "\\" in member.name or "\x00" in member.name
278
+ if is_unsafe_text or path.is_absolute() or ".." in path.parts:
279
+ raise DexcliInstallError(f"the release archive has an unsafe path {member.name!r}")
280
+ if not (member.isfile() or member.isdir()):
281
+ raise DexcliInstallError(
282
+ f"the release archive entry {member.name!r} is a link or a special file"
283
+ )
284
+
285
+
286
+ def _read_version(executable: Path) -> tuple[int, int, int] | None:
287
+ try:
288
+ completed = subprocess.run(
289
+ [str(executable), "--version"],
290
+ capture_output=True,
291
+ text=True,
292
+ timeout=_VERSION_TIMEOUT_SECONDS,
293
+ check=False,
294
+ )
295
+ except (OSError, subprocess.TimeoutExpired):
296
+ return None
297
+ if completed.returncode != 0:
298
+ return None
299
+ return _parse_version(completed.stdout + completed.stderr)
300
+
301
+
302
+ def _parse_version(text: str) -> tuple[int, int, int] | None:
303
+ match = _VERSION.search(text)
304
+ if match is None:
305
+ return None
306
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
307
+
308
+
309
+ def _sha256(path: Path) -> str:
310
+ digest = hashlib.sha256()
311
+ with path.open("rb") as file:
312
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
313
+ digest.update(chunk)
314
+ return digest.hexdigest()