@neoline/hostbridge 1.4.3 → 2.0.0

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,405 @@
1
+ """Ordered interactive PTY transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ import re
9
+ import shlex
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from collections.abc import Callable, Iterable, Iterator
14
+ from contextlib import suppress
15
+ from typing import Protocol
16
+
17
+ import pexpect
18
+
19
+ from ..hosts import HostConfig
20
+ from .base import ExecResult, ShellReadResult, TransferResult, TransportBusy, TransportCapabilities, TransportError
21
+
22
+ _REMOTE_BASE64_DECODE = "{ base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D; }"
23
+
24
+
25
+ class ChildProcess(Protocol):
26
+ before: str
27
+ after: object
28
+
29
+ def sendline(self, line: str) -> None:
30
+ raise NotImplementedError
31
+
32
+ def send(self, text: str) -> None:
33
+ raise NotImplementedError
34
+
35
+ def expect(self, patterns: object, timeout: int | float = -1) -> int:
36
+ raise NotImplementedError
37
+
38
+ def isalive(self) -> bool:
39
+ raise NotImplementedError
40
+
41
+ def close(self, force: bool = False) -> None:
42
+ raise NotImplementedError
43
+
44
+ def read_nonblocking(self, size: int = 1, timeout: int | float = -1) -> str:
45
+ raise NotImplementedError
46
+
47
+
48
+ ChildFactory = Callable[[str, list[str], int | float], ChildProcess]
49
+
50
+
51
+ def _default_child_factory(command: str, args: list[str], timeout: int | float) -> ChildProcess:
52
+ return pexpect.spawn(
53
+ command,
54
+ args,
55
+ encoding="utf-8",
56
+ codec_errors="replace",
57
+ timeout=timeout,
58
+ echo=False,
59
+ )
60
+
61
+
62
+ def _iter_base64(chunks: Iterable[bytes]) -> Iterator[str]:
63
+ remainder = b""
64
+ for chunk in chunks:
65
+ if not isinstance(chunk, bytes):
66
+ raise TypeError("stdin chunks must be bytes")
67
+ data = remainder + chunk
68
+ complete = len(data) - (len(data) % 3)
69
+ if complete:
70
+ yield base64.b64encode(data[:complete]).decode("ascii")
71
+ remainder = data[complete:]
72
+ if remainder:
73
+ yield base64.b64encode(remainder).decode("ascii")
74
+
75
+
76
+ def _marker_text(value: object) -> str:
77
+ return value.group(0) if hasattr(value, "group") else str(value)
78
+
79
+
80
+ class PtyTransport:
81
+ capabilities = TransportCapabilities(
82
+ separate_stderr=False,
83
+ native_binary_transfer=False,
84
+ parallel_channels=1,
85
+ )
86
+
87
+ @classmethod
88
+ def connect(
89
+ cls,
90
+ host: HostConfig,
91
+ *,
92
+ child_factory: ChildFactory = _default_child_factory,
93
+ connect_timeout: int | float = 60,
94
+ remote_shell: str = "bash",
95
+ token_factory: Callable[[], str] | None = None,
96
+ ) -> PtyTransport:
97
+ if connect_timeout <= 0:
98
+ raise ValueError("connect_timeout must be positive")
99
+ tokens = token_factory or (lambda: uuid.uuid4().hex)
100
+ child = child_factory(host.command, list(host.args), connect_timeout)
101
+ try:
102
+ for step in host.login_steps:
103
+ child.expect(step.expect, timeout=connect_timeout)
104
+ if step.send_secret is not None:
105
+ secret = host.secrets.get(step.send_secret)
106
+ if secret is None:
107
+ raise TransportError(f"login step references missing secret {step.send_secret!r}")
108
+ child.send(f"{secret}\r")
109
+ else:
110
+ child.send(f"{step.send or ''}\r")
111
+ if host.login_steps:
112
+ child.expect(host.ready_expect or r"(?m)[^\r\n]*[#$]\s*$", timeout=connect_timeout)
113
+ token = tokens()
114
+ marker = f"__HB_READY_{token}__"
115
+ child.send(f"printf '\n{marker}\n'\r")
116
+ child.expect(re.escape(marker), timeout=connect_timeout)
117
+ except Exception as exc:
118
+ child.close(force=True)
119
+ if isinstance(exc, TransportError):
120
+ raise
121
+ raise TransportError(f"failed to establish PTY transport for {host.id}") from exc
122
+ return cls(child, remote_shell=remote_shell, token_factory=tokens)
123
+
124
+ def __init__(
125
+ self,
126
+ child: ChildProcess,
127
+ *,
128
+ remote_shell: str = "bash",
129
+ token_factory: Callable[[], str] | None = None,
130
+ marker_prefix: str = "HB",
131
+ ) -> None:
132
+ self.child = child
133
+ self.remote_shell = remote_shell
134
+ self._token_factory = token_factory or (lambda: uuid.uuid4().hex)
135
+ self._marker_prefix = marker_prefix
136
+ self.operation_lock = threading.Lock()
137
+ self._io_lock = threading.Lock()
138
+
139
+ def exec(
140
+ self,
141
+ command: str,
142
+ *,
143
+ stdin: Iterable[bytes] | None,
144
+ timeout: float,
145
+ output_limit: int,
146
+ ) -> ExecResult:
147
+ if not command.strip():
148
+ raise ValueError("command must not be empty")
149
+ if timeout <= 0:
150
+ raise ValueError("timeout must be positive")
151
+ if output_limit <= 0:
152
+ raise ValueError("output_limit must be positive")
153
+ if not self.operation_lock.acquire(blocking=False):
154
+ raise TransportBusy("PTY transport already has an active operation")
155
+ stdin_path: str | None = None
156
+ try:
157
+ if not self.child.isalive():
158
+ raise TransportError("PTY transport is not alive")
159
+ if stdin is not None:
160
+ stdin_path = self._stage_stdin_locked(stdin, timeout=timeout)
161
+ command = f"{command} < {shlex.quote(stdin_path)}"
162
+ return self._exec_locked(command, timeout=timeout, output_limit=output_limit)
163
+ finally:
164
+ if stdin_path is not None and self.child.isalive():
165
+ with suppress(Exception):
166
+ self._exec_locked(
167
+ f"rm -f -- {shlex.quote(stdin_path)}",
168
+ timeout=min(timeout, 10),
169
+ output_limit=1024,
170
+ )
171
+ self.operation_lock.release()
172
+
173
+ def _stage_stdin_locked(self, chunks: Iterable[bytes], *, timeout: float) -> str:
174
+ token = self._token_factory()
175
+ path = f"/tmp/hostbridge.stdin.{token}"
176
+ heredoc = f"__{self._marker_prefix}_STDIN_{token}__"
177
+ done_pattern = re.compile(rf"__{self._marker_prefix}_STDIN_DONE_{re.escape(token)}__:(\d+)")
178
+ self.child.send(f"cat > {shlex.quote(path)} <<'{heredoc}'\n")
179
+ for encoded in _iter_base64(chunks):
180
+ self.child.send(encoded)
181
+ self.child.send(
182
+ f"\n{heredoc}\n"
183
+ f"tmp={shlex.quote(path)}.b64; mv {shlex.quote(path)} \"$tmp\"; "
184
+ f"{{ {_REMOTE_BASE64_DECODE} < \"$tmp\"; }} > {shlex.quote(path)}; status=$?; rm -f \"$tmp\"; "
185
+ f"printf '\n__{self._marker_prefix}_STDIN_DONE_{token}__:%s\n' \"$status\"\n"
186
+ )
187
+ self.child.expect(done_pattern, timeout=timeout)
188
+ match = re.search(r":(\d+)", _marker_text(self.child.after))
189
+ if match is None or int(match.group(1)) != 0:
190
+ raise TransportError("failed to stage PTY stdin")
191
+ return path
192
+
193
+ def _exec_locked(self, command: str, *, timeout: float, output_limit: int) -> ExecResult:
194
+ token = self._token_factory()
195
+ start_marker = f"__{self._marker_prefix}_START_{token}__"
196
+ exit_pattern = re.compile(rf"__{self._marker_prefix}_EXIT_{re.escape(token)}__:(\d+)")
197
+ encoded_command = base64.b64encode(command.encode("utf-8")).decode("ascii")
198
+ remote_line = (
199
+ f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | {_REMOTE_BASE64_DECODE}); "
200
+ f"printf '\n{start_marker}\n'; "
201
+ f"{self.remote_shell} -lc \"$__scm_cmd\"; "
202
+ "__scm_status=$?; "
203
+ f"printf '\n__{self._marker_prefix}_EXIT_{token}__:%s\n' \"$__scm_status\""
204
+ )
205
+ try:
206
+ self.child.sendline(remote_line)
207
+ self.child.expect(exit_pattern, timeout=timeout)
208
+ except pexpect.TIMEOUT:
209
+ return ExecResult(None, self._output_after_marker(str(self.child.before), start_marker, output_limit), timed_out=True)
210
+ match = re.search(r":(\d+)", _marker_text(self.child.after))
211
+ exit_code = int(match.group(1)) if match else None
212
+ return ExecResult(exit_code, self._output_after_marker(str(self.child.before), start_marker, output_limit))
213
+
214
+ @staticmethod
215
+ def _output_after_marker(raw: str, marker: str, output_limit: int) -> bytes:
216
+ normalized = raw.replace("\x1b[?2004h", "").replace("\x1b[?2004l", "")
217
+ normalized = normalized.replace("\r\n", "\n").replace("\r", "\n")
218
+ marker_index = normalized.rfind(marker)
219
+ output = normalized[marker_index + len(marker) :] if marker_index >= 0 else normalized
220
+ cleaned = [
221
+ line
222
+ for line in output.split("\n")
223
+ if not line.startswith("printf '") and not re.search(r"__[A-Z]+_(?:START|EXIT)_", line)
224
+ ]
225
+ encoded = "\n".join(cleaned).strip("\n").encode("utf-8", errors="replace")
226
+ return encoded[-output_limit:]
227
+
228
+ def upload(
229
+ self,
230
+ chunks: Iterable[bytes],
231
+ remote_path: str,
232
+ *,
233
+ mode: int,
234
+ expected_size: int | None,
235
+ ) -> TransferResult:
236
+ self._validate_remote_path(remote_path)
237
+ if not isinstance(mode, int) or isinstance(mode, bool) or not 0 <= mode <= 0o7777:
238
+ raise ValueError("mode must be an integer between 0 and 07777")
239
+ if expected_size is not None and expected_size < 0:
240
+ raise ValueError("expected_size must be non-negative")
241
+ if not self.operation_lock.acquire(blocking=False):
242
+ raise TransportBusy("PTY transport already has an active operation")
243
+ token = self._token_factory()
244
+ tmp_b64 = f"/tmp/hostbridge.upload.{token}.b64"
245
+ tmp_out = f"/tmp/hostbridge.upload.{token}.out"
246
+ heredoc = f"__{self._marker_prefix}_UPLOAD_{token}__"
247
+ done_pattern = re.compile(rf"__{self._marker_prefix}_UPLOAD_DONE_{re.escape(token)}__:(\d+)")
248
+ digest = hashlib.sha256()
249
+ transferred = 0
250
+
251
+ def counted_chunks() -> Iterator[bytes]:
252
+ nonlocal transferred
253
+ for chunk in chunks:
254
+ if not isinstance(chunk, bytes):
255
+ raise TypeError("upload chunks must be bytes")
256
+ transferred += len(chunk)
257
+ digest.update(chunk)
258
+ yield chunk
259
+
260
+ try:
261
+ if not self.child.isalive():
262
+ raise TransportError("PTY transport is not alive")
263
+ self.child.send(f"cat > {shlex.quote(tmp_b64)} <<'{heredoc}'\n")
264
+ for encoded in _iter_base64(counted_chunks()):
265
+ self.child.send(encoded)
266
+ self.child.send(
267
+ f"\n{heredoc}\nprintf '\n__{self._marker_prefix}_UPLOAD_DONE_{token}__:%s\n' \"$?\"\n"
268
+ )
269
+ self.child.expect(done_pattern, timeout=60)
270
+ match = re.search(r":(\d+)", _marker_text(self.child.after))
271
+ if match is None or int(match.group(1)) != 0:
272
+ raise TransportError("failed to stream PTY upload")
273
+ if expected_size is not None and transferred != expected_size:
274
+ raise TransportError(
275
+ f"upload size mismatch: expected {expected_size}, received {transferred}"
276
+ )
277
+ target_q = shlex.quote(remote_path)
278
+ finalize = (
279
+ "set -e; "
280
+ f"target={target_q}; tmp_b64={shlex.quote(tmp_b64)}; tmp_out={shlex.quote(tmp_out)}; "
281
+ 'mkdir -p "$(dirname "$target")"; '
282
+ f'{{ {_REMOTE_BASE64_DECODE} < "$tmp_b64"; }} > "$tmp_out"; '
283
+ f'chmod {format(mode, "04o")} "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
284
+ "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
285
+ "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
286
+ 'bytes=$(wc -c < "$target" | tr -d " "); printf "BYTES=%s\\nSHA256=%s\\n" "$bytes" "$sha"'
287
+ )
288
+ result = self._exec_locked(finalize, timeout=60, output_limit=4096)
289
+ if result.timed_out or result.exit_code != 0:
290
+ raise TransportError("failed to finalize PTY upload")
291
+ fields = self._metadata_fields(result.stdout)
292
+ remote_size = int(fields.get("BYTES", transferred))
293
+ remote_sha = fields.get("SHA256", digest.hexdigest())
294
+ if remote_size != transferred or remote_sha != digest.hexdigest():
295
+ raise TransportError("PTY upload integrity check failed")
296
+ return TransferResult(transferred, remote_sha)
297
+ except Exception:
298
+ if self.child.isalive():
299
+ with suppress(Exception):
300
+ self._exec_locked(
301
+ f"rm -f -- {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}",
302
+ timeout=10,
303
+ output_limit=1024,
304
+ )
305
+ raise
306
+ finally:
307
+ self.operation_lock.release()
308
+
309
+ def download(self, remote_path: str, *, chunk_size: int) -> Iterator[bytes]:
310
+ self._validate_remote_path(remote_path)
311
+ if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
312
+ raise ValueError("chunk_size must be between 4096 and 1048576 bytes")
313
+
314
+ def stream() -> Iterator[bytes]:
315
+ if not self.operation_lock.acquire(blocking=False):
316
+ raise TransportBusy("PTY transport already has an active operation")
317
+ digest = hashlib.sha256()
318
+ transferred = 0
319
+ try:
320
+ if not self.child.isalive():
321
+ raise TransportError("PTY transport is not alive")
322
+ remote_q = shlex.quote(remote_path)
323
+ metadata = (
324
+ f"target={remote_q}; bytes=$(wc -c < \"$target\" | tr -d ' '); "
325
+ "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
326
+ "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
327
+ 'printf "BYTES=%s\\nSHA256=%s\\n" "$bytes" "$sha"'
328
+ )
329
+ result = self._exec_locked(metadata, timeout=60, output_limit=4096)
330
+ if result.timed_out or result.exit_code != 0:
331
+ raise TransportError(f"failed to inspect remote file {remote_path}")
332
+ fields = self._metadata_fields(result.stdout)
333
+ byte_count = int(fields.get("BYTES", "0"))
334
+ expected_sha = fields.get("SHA256", "")
335
+ chunk_count = (byte_count + chunk_size - 1) // chunk_size
336
+ for index in range(chunk_count):
337
+ command = (
338
+ f"dd if={remote_q} bs={chunk_size} skip={index} count=1 2>/dev/null | "
339
+ "{ base64 --wrap=0 2>/dev/null || base64 | tr -d '\\n'; }"
340
+ )
341
+ result = self._exec_locked(command, timeout=60, output_limit=chunk_size * 2 + 4096)
342
+ if result.timed_out or result.exit_code != 0:
343
+ raise TransportError(f"failed to download PTY chunk {index}")
344
+ try:
345
+ chunk = base64.b64decode(b"".join(result.stdout.split()), validate=True)
346
+ except (binascii.Error, ValueError) as exc:
347
+ raise TransportError(f"remote chunk {index} contains invalid base64") from exc
348
+ transferred += len(chunk)
349
+ digest.update(chunk)
350
+ yield chunk
351
+ if transferred != byte_count or (expected_sha and digest.hexdigest() != expected_sha):
352
+ raise TransportError("PTY download integrity check failed")
353
+ finally:
354
+ self.operation_lock.release()
355
+
356
+ return stream()
357
+
358
+ @staticmethod
359
+ def _validate_remote_path(remote_path: str) -> None:
360
+ if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
361
+ raise ValueError("remote_path must be a non-empty path without NUL bytes")
362
+
363
+ @staticmethod
364
+ def _metadata_fields(output: bytes) -> dict[str, str]:
365
+ fields: dict[str, str] = {}
366
+ for line in output.decode("utf-8", errors="replace").splitlines():
367
+ key, separator, value = line.partition("=")
368
+ if separator:
369
+ fields[key] = value
370
+ return fields
371
+
372
+ def close(self) -> None:
373
+ self.child.close(force=True)
374
+
375
+ def shell_write(self, data: bytes) -> None:
376
+ if not isinstance(data, bytes):
377
+ raise TypeError("shell data must be bytes")
378
+ if not self.child.isalive():
379
+ raise TransportError("PTY transport is not alive")
380
+ with self._io_lock:
381
+ self.child.send(data.decode("utf-8", errors="replace"))
382
+
383
+ def shell_read(self, *, timeout: float, max_bytes: int) -> ShellReadResult:
384
+ if timeout < 0:
385
+ raise ValueError("timeout must be non-negative")
386
+ if max_bytes <= 0:
387
+ raise ValueError("max_bytes must be positive")
388
+ output = bytearray()
389
+ deadline = time.monotonic() + timeout
390
+ with self._io_lock:
391
+ while len(output) < max_bytes:
392
+ remaining = max(0.0, deadline - time.monotonic())
393
+ try:
394
+ chunk = self.child.read_nonblocking(
395
+ size=min(4096, max_bytes - len(output)),
396
+ timeout=remaining,
397
+ )
398
+ except (pexpect.TIMEOUT, pexpect.EOF):
399
+ break
400
+ if not chunk:
401
+ break
402
+ output.extend(chunk.encode("utf-8", errors="replace"))
403
+ if remaining <= 0:
404
+ break
405
+ return ShellReadResult(bytes(output), self.child.isalive())
@@ -0,0 +1,195 @@
1
+ """Native AsyncSSH transport for direct SSH hosts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import uuid
8
+ from collections.abc import Iterable, Sequence
9
+ from contextlib import suppress
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import asyncssh
14
+
15
+ from .base import ExecResult, TransferResult, TransportCapabilities
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class SshConnectionConfig:
20
+ host: str
21
+ port: int = 22
22
+ username: str | None = None
23
+ client_keys: Sequence[str] = ()
24
+ known_hosts: str | None = None
25
+
26
+
27
+ class SshTransport:
28
+ capabilities = TransportCapabilities(
29
+ separate_stderr=True,
30
+ native_binary_transfer=True,
31
+ parallel_channels=8,
32
+ )
33
+
34
+ def __init__(self, connection: asyncssh.SSHClientConnection) -> None:
35
+ self._connection = connection
36
+
37
+ @classmethod
38
+ async def connect(cls, config: SshConnectionConfig) -> SshTransport:
39
+ if not config.host.strip():
40
+ raise ValueError("SSH host must not be empty")
41
+ if not 1 <= config.port <= 65535:
42
+ raise ValueError("SSH port must be between 1 and 65535")
43
+ connection = await asyncssh.connect(
44
+ config.host,
45
+ port=config.port,
46
+ username=config.username,
47
+ client_keys=list(config.client_keys),
48
+ known_hosts=config.known_hosts,
49
+ encoding=None,
50
+ )
51
+ return cls(connection)
52
+
53
+ async def exec(
54
+ self,
55
+ command: str,
56
+ *,
57
+ stdin: Iterable[bytes] | None,
58
+ timeout: float,
59
+ output_limit: int,
60
+ ) -> ExecResult:
61
+ if not command.strip():
62
+ raise ValueError("command must not be empty")
63
+ if timeout <= 0:
64
+ raise ValueError("timeout must be positive")
65
+ if output_limit <= 0:
66
+ raise ValueError("output_limit must be positive")
67
+ process = await self._connection.create_process(command, encoding=None)
68
+ stdout_task = asyncio.create_task(self._read_stream(process.stdout, output_limit))
69
+ stderr_task = asyncio.create_task(self._read_stream(process.stderr, output_limit))
70
+ input_task = asyncio.create_task(self._write_stdin(process, stdin))
71
+ try:
72
+ await asyncio.wait_for(process.wait_closed(), timeout=timeout)
73
+ await input_task
74
+ stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
75
+ return ExecResult(process.exit_status, stdout, stderr)
76
+ except TimeoutError:
77
+ process.terminate()
78
+ with suppress(Exception):
79
+ await process.wait_closed()
80
+ for task in (stdout_task, stderr_task, input_task):
81
+ task.cancel()
82
+ await asyncio.gather(stdout_task, stderr_task, input_task, return_exceptions=True)
83
+ return ExecResult(None, b"", b"", timed_out=True)
84
+
85
+ @staticmethod
86
+ async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin: Iterable[bytes] | None) -> None:
87
+ if stdin is not None:
88
+ for chunk in stdin:
89
+ if not isinstance(chunk, bytes):
90
+ raise TypeError("stdin chunks must be bytes")
91
+ process.stdin.write(chunk)
92
+ await process.stdin.drain()
93
+ process.stdin.write_eof()
94
+
95
+ @staticmethod
96
+ async def _read_stream(stream, output_limit: int) -> bytes:
97
+ output = bytearray()
98
+ while True:
99
+ chunk = await stream.read(64 * 1024)
100
+ if not chunk:
101
+ break
102
+ output.extend(chunk)
103
+ if len(output) > output_limit:
104
+ del output[: len(output) - output_limit]
105
+ return bytes(output)
106
+
107
+ async def upload_file(
108
+ self,
109
+ local_path: Path | str,
110
+ remote_path: str,
111
+ *,
112
+ chunk_size: int = 256 * 1024,
113
+ mode: int = 0o644,
114
+ ) -> TransferResult:
115
+ source = Path(local_path)
116
+ self._validate_chunk_size(chunk_size)
117
+ self._validate_remote_path(remote_path)
118
+ temporary = f"{remote_path}.hostbridge-{uuid.uuid4().hex}.tmp"
119
+ digest = hashlib.sha256()
120
+ transferred = 0
121
+ sftp = await self._connection.start_sftp_client()
122
+ try:
123
+ local_file = source.open("rb")
124
+ try:
125
+ async with sftp.open(temporary, "wb") as remote_file:
126
+ while True:
127
+ chunk = await asyncio.to_thread(local_file.read, chunk_size)
128
+ if not chunk:
129
+ break
130
+ await remote_file.write(chunk)
131
+ digest.update(chunk)
132
+ transferred += len(chunk)
133
+ finally:
134
+ local_file.close()
135
+ await sftp.chmod(temporary, mode)
136
+ await sftp.rename(temporary, remote_path)
137
+ except Exception:
138
+ with suppress(Exception):
139
+ await sftp.remove(temporary)
140
+ raise
141
+ finally:
142
+ sftp.exit()
143
+ return TransferResult(transferred, digest.hexdigest())
144
+
145
+ async def download_file(
146
+ self,
147
+ remote_path: str,
148
+ local_path: Path | str,
149
+ *,
150
+ chunk_size: int = 256 * 1024,
151
+ ) -> TransferResult:
152
+ destination = Path(local_path)
153
+ self._validate_chunk_size(chunk_size)
154
+ self._validate_remote_path(remote_path)
155
+ temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
156
+ digest = hashlib.sha256()
157
+ transferred = 0
158
+ sftp = await self._connection.start_sftp_client()
159
+ try:
160
+ destination.parent.mkdir(parents=True, exist_ok=True)
161
+ local_file = temporary.open("wb")
162
+ try:
163
+ async with sftp.open(remote_path, "rb") as remote_file:
164
+ while True:
165
+ raw_chunk = await remote_file.read(chunk_size)
166
+ if not raw_chunk:
167
+ break
168
+ chunk = raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
169
+ await asyncio.to_thread(local_file.write, chunk)
170
+ digest.update(chunk)
171
+ transferred += len(chunk)
172
+ finally:
173
+ local_file.close()
174
+ temporary.chmod(0o600)
175
+ temporary.replace(destination)
176
+ except Exception:
177
+ temporary.unlink(missing_ok=True)
178
+ raise
179
+ finally:
180
+ sftp.exit()
181
+ return TransferResult(transferred, digest.hexdigest())
182
+
183
+ @staticmethod
184
+ def _validate_chunk_size(chunk_size: int) -> None:
185
+ if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
186
+ raise ValueError("chunk_size must be between 4096 and 1048576 bytes")
187
+
188
+ @staticmethod
189
+ def _validate_remote_path(remote_path: str) -> None:
190
+ if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
191
+ raise ValueError("remote_path must be a non-empty path without NUL bytes")
192
+
193
+ async def close(self) -> None:
194
+ self._connection.close()
195
+ await self._connection.wait_closed()