@neoline/hostbridge 2.0.3 → 2.0.4

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,506 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import posixpath
7
+ import shlex
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ from contextlib import suppress
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import asyncssh
17
+
18
+ from .client import HostBridgeClient, HostBridgeError
19
+ from .mock_ssh_session import HostBridgeSessionFactory
20
+
21
+
22
+ @dataclass
23
+ class HostBridgeSFTPHandle:
24
+ path: str
25
+ temporary_path: Path
26
+ readable: bool = False
27
+ writable: bool = False
28
+ append: bool = False
29
+ mode: int = 0o644
30
+ size: int = 0
31
+ dirty: bool = False
32
+ atime: int | None = None
33
+ mtime: int | None = None
34
+ closed: bool = False
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class _TextCommandResult:
39
+ output: str
40
+ exit_code: int | None
41
+ timed_out: bool
42
+
43
+
44
+ class HostBridgeSFTPServer(asyncssh.SFTPServer):
45
+ def __init__(
46
+ self,
47
+ chan: asyncssh.SSHServerChannel,
48
+ client: HostBridgeClient,
49
+ session_factory: HostBridgeSessionFactory,
50
+ timeout: int,
51
+ max_bytes: int,
52
+ ) -> None:
53
+ super().__init__(chan)
54
+ self._client = client
55
+ self._session_factory = session_factory
56
+ self._session_id: str | None = None
57
+ self._session_lock = threading.Lock()
58
+ self._operation_lock = asyncio.Lock()
59
+ self._timeout = timeout
60
+ self._max_bytes = max_bytes
61
+ self._handles: list[HostBridgeSFTPHandle] = []
62
+
63
+ async def _run_blocking(self, function, /, *args, **kwargs):
64
+ async with self._operation_lock:
65
+ worker = asyncio.create_task(asyncio.to_thread(function, *args, **kwargs))
66
+ try:
67
+ return await asyncio.shield(worker)
68
+ except asyncio.CancelledError:
69
+ while not worker.done():
70
+ try:
71
+ await asyncio.shield(worker)
72
+ except asyncio.CancelledError:
73
+ continue
74
+ except BaseException:
75
+ break
76
+ with suppress(BaseException):
77
+ worker.result()
78
+ raise
79
+
80
+ async def exit(self) -> None:
81
+ await self._run_blocking(self._exit_sync)
82
+
83
+ def _exit_sync(self) -> None:
84
+ for handle in list(self._handles):
85
+ with suppress(Exception):
86
+ self._close_sync(handle)
87
+ if self._session_id is not None:
88
+ self._session_factory.close(self._session_id, reusable=False)
89
+ self._session_id = None
90
+
91
+ async def open(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
92
+ return await self._run_blocking(self._open_sync, path, pflags, attrs)
93
+
94
+ def _open_sync(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
95
+ remote_path = self._path(path)
96
+ readable = bool(pflags & asyncssh.FXF_READ)
97
+ writable = bool(pflags & asyncssh.FXF_WRITE)
98
+ create = bool(pflags & asyncssh.FXF_CREAT)
99
+ truncate = bool(pflags & asyncssh.FXF_TRUNC)
100
+ exclusive = bool(pflags & asyncssh.FXF_EXCL)
101
+ append = bool(pflags & asyncssh.FXF_APPEND)
102
+ if not readable and not writable:
103
+ raise asyncssh.SFTPFailure("file must be opened for reading or writing")
104
+ if (truncate or append) and not writable:
105
+ raise asyncssh.SFTPPermissionDenied("write flags require write access")
106
+ if create:
107
+ create_mode = 0o644 if attrs.permissions is None else attrs.permissions
108
+ self._create_remote(remote_path, mode=create_mode, exclusive=exclusive)
109
+ remote_attrs = self._stat(remote_path, follow=True)
110
+ if remote_attrs is not None and remote_attrs.type == asyncssh.FILEXFER_TYPE_DIRECTORY:
111
+ raise asyncssh.SFTPFileIsADirectory(remote_path)
112
+ mode = attrs.permissions if attrs.permissions is not None else 0o644
113
+ if remote_attrs is not None and remote_attrs.permissions is not None and attrs.permissions is None:
114
+ mode = remote_attrs.permissions
115
+ temporary_path = self._new_temporary_path()
116
+ try:
117
+ if truncate:
118
+ self._truncate_remote(remote_path)
119
+ temporary_path.touch(mode=0o600)
120
+ else:
121
+ self._download_to(remote_path, temporary_path)
122
+ size = temporary_path.stat().st_size
123
+ handle = HostBridgeSFTPHandle(
124
+ remote_path,
125
+ temporary_path,
126
+ readable=readable,
127
+ writable=writable,
128
+ append=append,
129
+ mode=mode,
130
+ size=size,
131
+ dirty=False,
132
+ )
133
+ self._handles.append(handle)
134
+ return handle
135
+ except BaseException:
136
+ temporary_path.unlink(missing_ok=True)
137
+ raise
138
+
139
+ async def read(self, file_obj: object, offset: int, size: int) -> bytes:
140
+ return await self._run_blocking(self._read_sync, file_obj, offset, size)
141
+
142
+ def _read_sync(self, file_obj: object, offset: int, size: int) -> bytes:
143
+ handle = self._handle(file_obj)
144
+ if not handle.readable:
145
+ raise asyncssh.SFTPPermissionDenied("file is not open for reading")
146
+ if offset < 0 or size < 0:
147
+ raise asyncssh.SFTPFailure("read offset and size must be non-negative")
148
+ with handle.temporary_path.open("rb") as stream:
149
+ stream.seek(offset)
150
+ return stream.read(size)
151
+
152
+ async def write(self, file_obj: object, offset: int, data: bytes) -> int:
153
+ return await self._run_blocking(self._write_sync, file_obj, offset, data)
154
+
155
+ def _write_sync(self, file_obj: object, offset: int, data: bytes) -> int:
156
+ handle = self._handle(file_obj)
157
+ if not handle.writable:
158
+ raise asyncssh.SFTPPermissionDenied("file is not open for writing")
159
+ if offset < 0:
160
+ raise asyncssh.SFTPFailure("write offset must be non-negative")
161
+ payload = bytes(data)
162
+ write_offset = handle.size if handle.append else int(offset)
163
+ final_size = max(handle.size, write_offset + len(payload))
164
+ if final_size > self._max_bytes:
165
+ raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
166
+ with handle.temporary_path.open("r+b") as stream:
167
+ stream.seek(write_offset)
168
+ stream.write(payload)
169
+ handle.size = final_size
170
+ handle.dirty = True
171
+ return len(payload)
172
+
173
+ async def close(self, file_obj: object) -> None:
174
+ await self._run_blocking(self._close_sync, file_obj)
175
+
176
+ def _close_sync(self, file_obj: object) -> None:
177
+ handle = self._handle(file_obj)
178
+ if handle.closed:
179
+ return
180
+ try:
181
+ if handle.writable and handle.dirty:
182
+ self._upload(handle)
183
+ elif handle.atime is not None or handle.mtime is not None:
184
+ self._apply_times(handle)
185
+ finally:
186
+ handle.closed = True
187
+ handle.temporary_path.unlink(missing_ok=True)
188
+ with suppress(ValueError):
189
+ self._handles.remove(handle)
190
+
191
+ async def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
192
+ return await self._run_blocking(self._stat, self._path(path), follow=True)
193
+
194
+ async def lstat(self, path: bytes) -> asyncssh.SFTPAttrs:
195
+ return await self._run_blocking(self._stat, self._path(path), follow=False)
196
+
197
+ async def fstat(self, file_obj: object) -> asyncssh.SFTPAttrs:
198
+ return await self._run_blocking(self._fstat_sync, file_obj)
199
+
200
+ def _fstat_sync(self, file_obj: object) -> asyncssh.SFTPAttrs:
201
+ handle = self._handle(file_obj)
202
+ return asyncssh.SFTPAttrs(
203
+ size=handle.size,
204
+ permissions=handle.mode,
205
+ type=asyncssh.FILEXFER_TYPE_REGULAR,
206
+ )
207
+
208
+ async def fsetstat(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
209
+ await self._run_blocking(self._fsetstat_sync, file_obj, attrs)
210
+
211
+ def _fsetstat_sync(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
212
+ handle = self._handle(file_obj)
213
+ if not handle.writable:
214
+ raise asyncssh.SFTPPermissionDenied("file is not open for writing")
215
+ if attrs.permissions is not None:
216
+ handle.mode = attrs.permissions
217
+ handle.dirty = True
218
+ if attrs.size is not None:
219
+ if attrs.size < 0 or attrs.size > self._max_bytes:
220
+ raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
221
+ with handle.temporary_path.open("r+b") as stream:
222
+ stream.truncate(attrs.size)
223
+ handle.size = attrs.size
224
+ handle.dirty = True
225
+ handle.atime = attrs.atime if attrs.atime is not None else handle.atime
226
+ handle.mtime = attrs.mtime if attrs.mtime is not None else handle.mtime
227
+
228
+ async def scandir(self, path: bytes):
229
+ items = await self._run_blocking(self._listdir, self._path(path))
230
+ for item in items:
231
+ yield item
232
+
233
+ async def mkdir(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
234
+ await self._run_blocking(self._mkdir_sync, path, attrs)
235
+
236
+ def _mkdir_sync(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
237
+ mode = attrs.permissions if attrs.permissions is not None else 0o755
238
+ self._checked_run(f"mkdir -m {shlex.quote(format(mode & 0o7777, 'o'))} -- {shlex.quote(self._path(path))}")
239
+
240
+ async def rmdir(self, path: bytes) -> None:
241
+ await self._run_blocking(self._rmdir_sync, path)
242
+
243
+ def _rmdir_sync(self, path: bytes) -> None:
244
+ self._checked_run(f"rmdir -- {shlex.quote(self._path(path))}")
245
+
246
+ async def remove(self, path: bytes) -> None:
247
+ await self._run_blocking(self._remove_sync, path)
248
+
249
+ def _remove_sync(self, path: bytes) -> None:
250
+ self._checked_run(f"rm -- {shlex.quote(self._path(path))}")
251
+
252
+ async def rename(self, oldpath: bytes, newpath: bytes) -> None:
253
+ await self._run_blocking(self._rename_sync, oldpath, newpath)
254
+
255
+ def _rename_sync(self, oldpath: bytes, newpath: bytes) -> None:
256
+ self._checked_run(f"mv -- {shlex.quote(self._path(oldpath))} {shlex.quote(self._path(newpath))}")
257
+
258
+ async def realpath(self, path: bytes) -> bytes:
259
+ return await self._run_blocking(self._realpath_sync, path)
260
+
261
+ def _realpath_sync(self, path: bytes) -> bytes:
262
+ script = (
263
+ "import os,sys; "
264
+ "path=sys.argv[1]; "
265
+ "parent=os.path.dirname(path) or '/'; "
266
+ "base=os.path.basename(path); "
267
+ "print(os.path.join(os.path.realpath(parent), base) if not os.path.exists(path) else os.path.realpath(path))"
268
+ )
269
+ command = "python3 -c " + shlex.quote(script) + " " + shlex.quote(self._path(path))
270
+ result = self._checked_run(command)
271
+ return (result.output.strip() or self._path(path)).encode("utf-8")
272
+
273
+ async def setstat(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
274
+ await self._run_blocking(self._setstat_sync, path, attrs)
275
+
276
+ def _setstat_sync(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
277
+ remote_path = self._path(path)
278
+ if attrs.size is not None:
279
+ if attrs.size < 0 or attrs.size > self._max_bytes:
280
+ raise asyncssh.SFTPFailure(f"file size must be between 0 and {self._max_bytes} bytes")
281
+ script = "import os,sys; os.truncate(sys.argv[1], int(sys.argv[2]))"
282
+ self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {attrs.size}")
283
+ if attrs.permissions is not None:
284
+ self._checked_run(
285
+ f"chmod {shlex.quote(format(attrs.permissions & 0o7777, 'o'))} -- {shlex.quote(remote_path)}"
286
+ )
287
+ if attrs.atime is not None or attrs.mtime is not None:
288
+ script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
289
+ current = self._stat(remote_path, follow=True)
290
+ atime = int(attrs.atime if attrs.atime is not None else current.atime or time.time())
291
+ mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
292
+ self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
293
+
294
+ def _download_to(self, remote_path: str, temporary_path: Path) -> None:
295
+ try:
296
+ result = self._client.download_file(
297
+ self._backend_session_id(),
298
+ remote_path,
299
+ temporary_path,
300
+ timeout=self._timeout,
301
+ owner="mock-ssh",
302
+ max_bytes=self._max_bytes,
303
+ )
304
+ if result.bytes_transferred > self._max_bytes:
305
+ raise HostBridgeError(
306
+ "resource_limit",
307
+ f"file exceeds mock-ssh limit ({self._max_bytes} bytes)",
308
+ )
309
+ except HostBridgeError as exc:
310
+ raise self._sftp_error(exc) from exc
311
+
312
+ def _upload(self, handle: HostBridgeSFTPHandle) -> None:
313
+ if handle.size > self._max_bytes:
314
+ raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
315
+ try:
316
+ self._client.upload_file(
317
+ self._backend_session_id(),
318
+ handle.temporary_path,
319
+ handle.path,
320
+ mode=handle.mode & 0o7777,
321
+ timeout=self._timeout,
322
+ owner="mock-ssh",
323
+ max_bytes=self._max_bytes,
324
+ )
325
+ self._apply_times(handle)
326
+ except HostBridgeError as exc:
327
+ print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
328
+ raise self._sftp_error(exc) from exc
329
+
330
+ def _apply_times(self, handle: HostBridgeSFTPHandle) -> None:
331
+ if handle.atime is None and handle.mtime is None:
332
+ return
333
+ current = self._stat(handle.path, follow=True)
334
+ atime = int(handle.atime if handle.atime is not None else current.atime or time.time())
335
+ mtime = int(handle.mtime if handle.mtime is not None else current.mtime or time.time())
336
+ script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
337
+ self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(handle.path)} {atime} {mtime}")
338
+
339
+ def _create_remote(self, remote_path: str, *, mode: int, exclusive: bool) -> None:
340
+ script = (
341
+ "import os,sys; "
342
+ "flags=os.O_WRONLY|os.O_CREAT|(os.O_EXCL if sys.argv[3]=='1' else 0); "
343
+ "fd=os.open(sys.argv[1],flags,int(sys.argv[2],8)); os.close(fd)"
344
+ )
345
+ self._checked_run(
346
+ f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} "
347
+ f"{shlex.quote(format(mode & 0o7777, 'o'))} {'1' if exclusive else '0'}"
348
+ )
349
+
350
+ def _truncate_remote(self, remote_path: str) -> None:
351
+ script = "import os,sys; fd=os.open(sys.argv[1],os.O_WRONLY|os.O_TRUNC); os.close(fd)"
352
+ self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
353
+
354
+ @staticmethod
355
+ def _new_temporary_path() -> Path:
356
+ descriptor, name = tempfile.mkstemp(prefix="hostbridge-sftp-")
357
+ os.close(descriptor)
358
+ return Path(name)
359
+
360
+ def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
361
+ script = r"""
362
+ import json, os, stat, sys
363
+ path = sys.argv[1]
364
+ st = os.stat(path) if sys.argv[2] == "1" else os.lstat(path)
365
+ print(json.dumps({
366
+ "size": st.st_size,
367
+ "permissions": st.st_mode,
368
+ "uid": st.st_uid,
369
+ "gid": st.st_gid,
370
+ "atime": int(st.st_atime),
371
+ "mtime": int(st.st_mtime),
372
+ "type": "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file",
373
+ }))
374
+ """
375
+ result = self._checked_run(
376
+ f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {'1' if follow else '0'}"
377
+ )
378
+ return self._attrs(json.loads(result.output))
379
+
380
+ def _listdir(self, remote_path: str) -> list[asyncssh.SFTPName]:
381
+ script = r"""
382
+ import json, os, stat, sys, time
383
+ path = sys.argv[1]
384
+ items = []
385
+ for name in os.listdir(path):
386
+ full = os.path.join(path, name)
387
+ st = os.lstat(full)
388
+ item_type = "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file"
389
+ items.append({
390
+ "filename": name,
391
+ "longname": name,
392
+ "attrs": {
393
+ "size": st.st_size,
394
+ "permissions": st.st_mode,
395
+ "uid": st.st_uid,
396
+ "gid": st.st_gid,
397
+ "atime": int(st.st_atime),
398
+ "mtime": int(st.st_mtime),
399
+ "type": item_type,
400
+ },
401
+ })
402
+ print(json.dumps(items))
403
+ """
404
+ result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
405
+ return [
406
+ asyncssh.SFTPName(item["filename"], item["longname"], self._attrs(item["attrs"]))
407
+ for item in json.loads(result.output or "[]")
408
+ ]
409
+
410
+ def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
411
+ permissions = self._int_value(data.get("permissions"), 0)
412
+ raw_type = data.get("type")
413
+ file_type = {
414
+ "file": asyncssh.FILEXFER_TYPE_REGULAR,
415
+ "dir": asyncssh.FILEXFER_TYPE_DIRECTORY,
416
+ "link": asyncssh.FILEXFER_TYPE_SYMLINK,
417
+ }.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
418
+ return asyncssh.SFTPAttrs(
419
+ type=file_type,
420
+ size=self._int_value(data.get("size"), 0),
421
+ uid=self._int_value(data.get("uid"), 0),
422
+ gid=self._int_value(data.get("gid"), 0),
423
+ permissions=permissions,
424
+ atime=self._int_value(data.get("atime"), int(time.time())),
425
+ mtime=self._int_value(data.get("mtime"), int(time.time())),
426
+ )
427
+
428
+ @staticmethod
429
+ def _int_value(value: object, default: int) -> int:
430
+ if value is None:
431
+ return default
432
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
433
+ raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
434
+ try:
435
+ return int(value)
436
+ except ValueError as exc:
437
+ raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
438
+
439
+ def _checked_run(self, command: str) -> _TextCommandResult:
440
+ try:
441
+ result = self._client.exec(
442
+ self._backend_session_id(),
443
+ command,
444
+ timeout=self._timeout,
445
+ owner="mock-ssh",
446
+ )
447
+ except HostBridgeError as exc:
448
+ raise self._sftp_error(exc) from exc
449
+ output = result.stdout.decode("utf-8", errors="replace")
450
+ stderr = result.stderr.decode("utf-8", errors="replace")
451
+ if result.timed_out:
452
+ print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
453
+ raise asyncssh.SFTPFailure("remote command timed out")
454
+ if result.exit_code is None:
455
+ raise asyncssh.SFTPFailure("remote command completed without an exit status")
456
+ if result.exit_code != 0:
457
+ error_output = "\n".join(part for part in (output, stderr) if part).strip()
458
+ error = self._sftp_error(
459
+ HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
460
+ )
461
+ if isinstance(error, asyncssh.SFTPNoSuchFile):
462
+ raise error
463
+ print(
464
+ f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
465
+ file=sys.stderr,
466
+ flush=True,
467
+ )
468
+ raise error
469
+ return _TextCommandResult(output, result.exit_code, result.timed_out)
470
+
471
+ def _backend_session_id(self) -> str:
472
+ with self._session_lock:
473
+ if self._session_id is None:
474
+ self._session_id = self._session_factory.open()
475
+ return self._session_id
476
+
477
+ @staticmethod
478
+ def _path(path: bytes) -> str:
479
+ text = path.decode("utf-8", "surrogateescape")
480
+ if not text:
481
+ return "."
482
+ return posixpath.normpath(text)
483
+
484
+ @staticmethod
485
+ def _handle(file_obj: object) -> HostBridgeSFTPHandle:
486
+ if not isinstance(file_obj, HostBridgeSFTPHandle):
487
+ raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
488
+ return file_obj
489
+
490
+ @staticmethod
491
+ def _sftp_error(exc: HostBridgeError) -> Exception:
492
+ text = str(exc)
493
+ lowered = text.lower()
494
+ if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
495
+ return asyncssh.SFTPNoSuchFile(text)
496
+ if "permission denied" in lowered:
497
+ return asyncssh.SFTPPermissionDenied(text)
498
+ if "file exists" in lowered or "fileexistserror" in lowered:
499
+ return asyncssh.SFTPFileAlreadyExists(text)
500
+ if "directory not empty" in lowered:
501
+ return asyncssh.SFTPDirNotEmpty(text)
502
+ if "is a directory" in lowered:
503
+ return asyncssh.SFTPFileIsADirectory(text)
504
+ if "not a directory" in lowered:
505
+ return asyncssh.SFTPNotADirectory(text)
506
+ return asyncssh.SFTPFailure(text)