|
6 | 6 | import tempfile |
7 | 7 | import warnings |
8 | 8 | from concurrent import futures |
| 9 | +from contextlib import suppress |
9 | 10 | from datetime import datetime, timedelta, timezone |
10 | 11 | from pathlib import Path |
11 | 12 | from types import SimpleNamespace |
12 | 13 |
|
13 | 14 | import fsspec |
14 | 15 | 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 |
16 | 18 | from importlib_metadata import entry_points |
17 | 19 |
|
18 | 20 | from sshfs import SSHFileSystem |
@@ -234,73 +236,121 @@ async def __aexit__(self, *exc): |
234 | 236 | return _Ctx() |
235 | 237 |
|
236 | 238 |
|
237 | | -@pytest.fixture |
| 239 | +@pytest.fixture(scope="session") |
238 | 240 | 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) |
241 | 253 |
|
242 | 254 |
|
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 |
248 | 263 |
|
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 |
256 | 264 |
|
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) |
262 | 270 |
|
| 271 | + umask = os.umask(0) |
| 272 | + os.umask(umask) |
263 | 273 |
|
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): |
265 | 295 | fs = copydata_fs |
266 | | - src = tmp_path / "src" |
| 296 | + local, remote = copydata_dir |
| 297 | + src = local / "src" |
267 | 298 | src.write_bytes(b"payload") |
268 | 299 |
|
269 | 300 | with pytest.raises(shutil.SameFileError): |
270 | | - fs.cp_file(str(src), str(src)) |
| 301 | + fs.cp_file(remote + "/src", remote + "/src") |
271 | 302 |
|
272 | 303 | # bytes and str spellings of the same path are still aliases |
273 | 304 | with pytest.raises(shutil.SameFileError): |
274 | | - fs.cp_file(str(src).encode(), str(src)) |
| 305 | + fs.cp_file((remote + "/src").encode(), remote + "/src") |
275 | 306 |
|
276 | | - link = tmp_path / "link" |
277 | | - link.symlink_to(src) |
| 307 | + (local / "link").symlink_to("src") |
278 | 308 | with pytest.raises(shutil.SameFileError): |
279 | | - fs.cp_file(str(src), str(link)) |
| 309 | + fs.cp_file(remote + "/src", remote + "/link") |
280 | 310 |
|
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") |
286 | 316 | assert src.read_bytes() == b"payload" |
287 | | - assert hard.read_bytes() == b"payload" |
| 317 | + assert (local / "hard").stat().st_ino == src.stat().st_ino |
288 | 318 |
|
289 | 319 |
|
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): |
291 | 321 | 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() |
294 | 325 |
|
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" |
299 | 328 |
|
300 | 329 | # "copy into" resolving to the source itself is an alias |
301 | 330 | 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" |
304 | 354 |
|
305 | 355 |
|
306 | 356 | def test_mv_fallback_keeps_source_on_copy_failure(fs, monkeypatch): |
|
0 commit comments