Skip to content

Commit 415128c

Browse files
shchekleinclaude
andcommitted
tests: chroot and authenticate the asyncssh fixture; pin write-through semantics
The fixture no longer serves the whole filesystem without authentication: it is chrooted to a fresh directory, requires the test user key, and shuts down by closing the client connection and draining the loop's tasks (the suite previously ended with 'Task was destroyed but it is pending!'). The functional tests now pin the write-through contract: hardlink peers of the destination see updates and source hardlinks keep their inode, an existing destination keeps its mode, a new file's mode is umask-filtered, a read-only destination is refused and unchanged, and a trailing slash on a file destination is rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8715bd1 commit 415128c

2 files changed

Lines changed: 131 additions & 53 deletions

File tree

tests/conftest.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import threading
3+
from contextlib import suppress
34
from pathlib import Path
45
from queue import Queue
56

@@ -8,18 +9,27 @@
89
import pytest
910

1011
_STATIC = (Path(__file__).parent / "static").resolve()
12+
_USER_KEY = asyncssh.read_private_key(str(_STATIC / "user.key"))
1113

1214

13-
class _NoAuthSSHServer(asyncssh.SSHServer):
15+
class _TestSSHServer(asyncssh.SSHServer):
1416
def begin_auth(self, username):
15-
return False
17+
return True
18+
19+
def public_key_auth_supported(self):
20+
return True
21+
22+
def validate_public_key(self, username, key):
23+
return key == _USER_KEY.convert_to_public()
1624

1725

1826
@pytest.fixture(scope="session")
19-
def asyncssh_server():
27+
def asyncssh_server(tmp_path_factory):
2028
"""SFTP server that, unlike the paramiko-based mockssh fixture,
21-
implements the copy-data and limits extensions. Serves the local
22-
filesystem as the current user; yields (host, port)."""
29+
implements the copy-data and limits extensions. Authenticated with
30+
the test user key and chrooted to a fresh directory; yields
31+
(host, port, root) where the remote "/" maps to root."""
32+
root = tmp_path_factory.mktemp("asyncssh-root")
2333
loop = asyncio.new_event_loop()
2434
thread = threading.Thread(target=loop.run_forever, daemon=True)
2535
thread.start()
@@ -28,18 +38,36 @@ async def _listen():
2838
return await asyncssh.listen(
2939
"127.0.0.1",
3040
0,
31-
server_host_keys=[str(_STATIC / "user.key")],
32-
server_factory=_NoAuthSSHServer,
33-
sftp_factory=asyncssh.SFTPServer,
41+
server_host_keys=[_USER_KEY],
42+
server_factory=_TestSSHServer,
43+
sftp_factory=lambda chan: asyncssh.SFTPServer(
44+
chan, chroot=str(root)
45+
),
3446
)
3547

3648
server = asyncio.run_coroutine_threadsafe(_listen(), loop).result(30)
3749
try:
38-
yield "127.0.0.1", server.get_port()
50+
yield "127.0.0.1", server.get_port(), root
3951
finally:
40-
loop.call_soon_threadsafe(server.close)
52+
53+
async def _shutdown():
54+
server.close()
55+
await server.wait_closed()
56+
tasks = [
57+
task
58+
for task in asyncio.all_tasks()
59+
if task is not asyncio.current_task()
60+
]
61+
for task in tasks:
62+
task.cancel()
63+
if tasks:
64+
await asyncio.wait(tasks, timeout=5)
65+
66+
with suppress(Exception):
67+
asyncio.run_coroutine_threadsafe(_shutdown(), loop).result(30)
4168
loop.call_soon_threadsafe(loop.stop)
4269
thread.join(timeout=5)
70+
loop.close()
4371

4472

4573
def _handler_run(self):

tests/test_sshfs.py

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
import tempfile
77
import warnings
88
from concurrent import futures
9+
from contextlib import suppress
910
from datetime import datetime, timedelta, timezone
1011
from pathlib import Path
1112
from types import SimpleNamespace
1213

1314
import fsspec
1415
import pytest
15-
from asyncssh.sftp import SFTPAttrs, SFTPFailure, SFTPOpUnsupported
16+
from asyncssh.sftp import SFTPAttrs, SFTPError, SFTPFailure, SFTPOpUnsupported
17+
from fsspec.asyn import sync
1618
from importlib_metadata import entry_points
1719

1820
from sshfs import SSHFileSystem
@@ -234,73 +236,121 @@ async def __aexit__(self, *exc):
234236
return _Ctx()
235237

236238

237-
@pytest.fixture
239+
@pytest.fixture(scope="session")
238240
def copydata_fs(asyncssh_server):
239-
host, port = asyncssh_server
240-
yield SSHFileSystem(host=host, port=port, username="user")
241+
host, port, _root = asyncssh_server
242+
fs = SSHFileSystem(
243+
host=host,
244+
port=port,
245+
username="user",
246+
client_keys=[USERS["user"]],
247+
)
248+
yield fs
249+
# Close the connection so the server fixture can shut its loop
250+
# down without cancelling live connection tasks.
251+
with suppress(Exception):
252+
sync(fs.loop, fs._stack.aclose, timeout=5)
241253

242254

243-
def test_cp_file_copy_data(copydata_fs, tmp_path):
244-
fs = copydata_fs
245-
src = tmp_path / "src"
246-
src.write_bytes(b"payload")
247-
src.chmod(0o640)
255+
@pytest.fixture
256+
def copydata_dir(asyncssh_server, request):
257+
_host, _port, root = asyncssh_server
258+
local = root / request.node.name
259+
local.mkdir()
260+
# the server is chrooted to `root`, so `local` is served as this
261+
# remote path
262+
yield local, "/" + local.name
248263

249-
dst = tmp_path / "dst"
250-
fs.cp_file(str(src), str(dst))
251-
# the copy-data path was actually taken, not the shell fallback
252-
assert fs._supports_remote_copy is True
253-
assert dst.read_bytes() == b"payload"
254-
# a new destination gets the source's mode
255-
assert (dst.stat().st_mode & 0o7777) == 0o640
256264

257-
# an existing destination keeps its own mode, like cp
258-
dst.chmod(0o600)
259-
fs.cp_file(str(src), str(dst))
260-
assert dst.read_bytes() == b"payload"
261-
assert (dst.stat().st_mode & 0o7777) == 0o600
265+
def test_cp_file_copy_data(copydata_fs, copydata_dir):
266+
fs = copydata_fs
267+
local, remote = copydata_dir
268+
(local / "src").write_bytes(b"payload")
269+
(local / "src").chmod(0o666)
262270

271+
umask = os.umask(0)
272+
os.umask(umask)
263273

264-
def test_cp_file_copy_data_aliases(copydata_fs, tmp_path):
274+
fs.cp_file(remote + "/src", remote + "/dst")
275+
# the copy-data path was actually taken, not the shell fallback
276+
assert fs._supports_remote_copy is True
277+
assert (local / "dst").read_bytes() == b"payload"
278+
# a new file gets the source's mode filtered by the server's
279+
# umask, like cp
280+
assert ((local / "dst").stat().st_mode & 0o7777) == 0o666 & ~umask
281+
282+
# an existing destination keeps its inode: its own mode survives
283+
# and hardlink peers see the update, like cp writing through the
284+
# file
285+
(local / "dst").chmod(0o600)
286+
os.link(local / "dst", local / "peer")
287+
(local / "src").write_bytes(b"new payload")
288+
fs.cp_file(remote + "/src", remote + "/dst")
289+
assert (local / "dst").read_bytes() == b"new payload"
290+
assert ((local / "dst").stat().st_mode & 0o7777) == 0o600
291+
assert (local / "peer").read_bytes() == b"new payload"
292+
293+
294+
def test_cp_file_copy_data_aliases(copydata_fs, copydata_dir):
265295
fs = copydata_fs
266-
src = tmp_path / "src"
296+
local, remote = copydata_dir
297+
src = local / "src"
267298
src.write_bytes(b"payload")
268299

269300
with pytest.raises(shutil.SameFileError):
270-
fs.cp_file(str(src), str(src))
301+
fs.cp_file(remote + "/src", remote + "/src")
271302

272303
# bytes and str spellings of the same path are still aliases
273304
with pytest.raises(shutil.SameFileError):
274-
fs.cp_file(str(src).encode(), str(src))
305+
fs.cp_file((remote + "/src").encode(), remote + "/src")
275306

276-
link = tmp_path / "link"
277-
link.symlink_to(src)
307+
(local / "link").symlink_to("src")
278308
with pytest.raises(shutil.SameFileError):
279-
fs.cp_file(str(src), str(link))
309+
fs.cp_file(remote + "/src", remote + "/link")
280310

281-
# hardlink aliases cannot be detected over SFTP; the copy must
282-
# still never destroy the source
283-
hard = tmp_path / "hard"
284-
os.link(src, hard)
285-
fs.cp_file(str(src), str(hard))
311+
# hardlink aliases cannot be detected over SFTP: the write-through
312+
# copy puts the bytes over themselves and must leave the file,
313+
# its content and the link intact
314+
os.link(src, local / "hard")
315+
fs.cp_file(remote + "/src", remote + "/hard")
286316
assert src.read_bytes() == b"payload"
287-
assert hard.read_bytes() == b"payload"
317+
assert (local / "hard").stat().st_ino == src.stat().st_ino
288318

289319

290-
def test_cp_file_copy_data_directory_destination(copydata_fs, tmp_path):
320+
def test_cp_file_copy_data_directory_destination(copydata_fs, copydata_dir):
291321
fs = copydata_fs
292-
src = tmp_path / "src"
293-
src.write_bytes(b"payload")
322+
local, remote = copydata_dir
323+
(local / "src").write_bytes(b"payload")
324+
(local / "d").mkdir()
294325

295-
directory = tmp_path / "directory"
296-
directory.mkdir()
297-
fs.cp_file(str(src), str(directory))
298-
assert (directory / "src").read_bytes() == b"payload"
326+
fs.cp_file(remote + "/src", remote + "/d")
327+
assert (local / "d" / "src").read_bytes() == b"payload"
299328

300329
# "copy into" resolving to the source itself is an alias
301330
with pytest.raises(shutil.SameFileError):
302-
fs.cp_file(str(directory / "src"), str(directory))
303-
assert (directory / "src").read_bytes() == b"payload"
331+
fs.cp_file(remote + "/d/src", remote + "/d")
332+
assert (local / "d" / "src").read_bytes() == b"payload"
333+
334+
335+
def test_cp_file_copy_data_destination_errors(copydata_fs, copydata_dir):
336+
fs = copydata_fs
337+
local, remote = copydata_dir
338+
(local / "src").write_bytes(b"payload")
339+
340+
# a read-only destination is refused at open, like cp, and stays
341+
# untouched
342+
ro = local / "ro"
343+
ro.write_bytes(b"old")
344+
ro.chmod(0o444)
345+
with pytest.raises(PermissionError):
346+
fs.cp_file(remote + "/src", remote + "/ro")
347+
assert ro.read_bytes() == b"old"
348+
349+
# a trailing slash on a file destination is not a directory (the
350+
# server rejects it; like mkdir, the SFTP error is passed through)
351+
with pytest.raises((OSError, SFTPError)):
352+
fs.cp_file(remote + "/src", remote + "/ro/")
353+
assert ro.read_bytes() == b"old"
304354

305355

306356
def test_mv_fallback_keeps_source_on_copy_failure(fs, monkeypatch):

0 commit comments

Comments
 (0)