Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/portkeydrop/dialogs/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ def callback(transferred: int, total: int) -> None:
if item.cancel_event.is_set():
raise InterruptedError("Transfer cancelled")
item.transferred_bytes = transferred
item.total_bytes = total
if total > 0:
item.total_bytes = total
self._notify()

client.download(item.remote_path, f, callback=callback)
Expand All @@ -187,7 +188,8 @@ def callback(transferred: int, total: int) -> None:
if item.cancel_event.is_set():
raise InterruptedError("Transfer cancelled")
item.transferred_bytes = transferred
item.total_bytes = total
if total > 0:
item.total_bytes = total
self._notify()

client.upload(f, item.remote_path, callback=callback)
Expand All @@ -208,6 +210,15 @@ def _run_recursive_download(self, client: TransferClient, item: TransferItem) ->
# Collect all files first to calculate total size
file_queue: list[tuple[str, str, int]] = [] # (remote, local, size)
self._collect_remote_files(client, item.remote_path, item.local_path, file_queue)
# Re-stat files with size=0 to resolve symlink targets
for i, (remote_file, local_file, size) in enumerate(file_queue):
if size == 0:
try:
real_size = client.stat(remote_file).size
if real_size > 0:
file_queue[i] = (remote_file, local_file, real_size)
except Exception:
pass
item.total_bytes = sum(size for _, _, size in file_queue)
item.transferred_bytes = 0
self._notify()
Expand Down
12 changes: 9 additions & 3 deletions src/portkeydrop/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,12 @@ def download(
sftp = self._ensure_connected()
local_path = getattr(local_file, "name", None)

# Resolve symlinks so stat() returns the real file size
try:
resolved = self._run(sftp.realpath(remote_path))
except Exception:
resolved = remote_path

if isinstance(local_path, str) and os.path.isabs(local_path):
# asyncssh native get() — pipelined reads with progress reporting
local_file.close()
Expand All @@ -779,14 +785,14 @@ async def _download():
def handler(srcpath, dstpath, copied, total):
callback(copied, total)

await sftp.get(remote_path, local_path, progress_handler=handler)
await sftp.get(resolved, local_path, progress_handler=handler)

self._run(_download())
else:
# Fallback for in-memory streams (BytesIO, etc.)
async def _download():
async with sftp.open(remote_path, "rb") as rf:
total = (await sftp.stat(remote_path)).size or 0
async with sftp.open(resolved, "rb") as rf:
total = (await sftp.stat(resolved)).size or 0
transferred = 0
while True:
chunk = await rf.read(8192)
Expand Down
128 changes: 124 additions & 4 deletions tests/test_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ def test_list_dir_maps_file_attributes(self, mock_connect):
def test_chdir_download_upload_and_file_ops(self, mock_connect):
mock_conn = AsyncMock()
mock_sftp = AsyncMock()
mock_sftp.realpath.side_effect = ["/", "/uploads"]
mock_sftp.realpath.side_effect = ["/", "/uploads", "/remote.bin"]
# chdir now validates with stat — return directory attributes
chdir_stat_attrs = MagicMock()
chdir_stat_attrs.permissions = stat_mod.S_IFDIR | 0o755
Expand Down Expand Up @@ -688,7 +688,7 @@ class TestSFTPClientNativeTransfer:
def test_download_uses_native_get_with_progress(self, mock_connect):
mock_conn = AsyncMock()
mock_sftp = AsyncMock()
mock_sftp.realpath.return_value = "/"
mock_sftp.realpath.side_effect = lambda p: "/" if p == "." else p
mock_conn.start_sftp_client.return_value = mock_sftp
mock_connect.return_value = mock_conn

Expand Down Expand Up @@ -728,7 +728,7 @@ async def fake_get(remotepath, localpath, *, progress_handler=None, **kwargs):
def test_download_no_callback_still_uses_native_get(self, mock_connect):
mock_conn = AsyncMock()
mock_sftp = AsyncMock()
mock_sftp.realpath.return_value = "/"
mock_sftp.realpath.side_effect = lambda p: "/" if p == "." else p
mock_conn.start_sftp_client.return_value = mock_sftp
mock_connect.return_value = mock_conn

Expand All @@ -751,7 +751,7 @@ def test_download_no_callback_still_uses_native_get(self, mock_connect):
def test_download_bytesio_uses_chunked_fallback(self, mock_connect):
mock_conn = AsyncMock()
mock_sftp = AsyncMock()
mock_sftp.realpath.return_value = "/"
mock_sftp.realpath.side_effect = lambda p: "/" if p == "." else p
mock_conn.start_sftp_client.return_value = mock_sftp
mock_connect.return_value = mock_conn

Expand Down Expand Up @@ -878,6 +878,126 @@ def test_upload_bytesio_uses_chunked_fallback(self, mock_connect):
mock_sftp.put.assert_not_awaited()


class TestSFTPDownloadSymlinkResolution:
"""Tests that SFTPClient.download() resolves symlinks via realpath."""

@patch("asyncssh.connect", new_callable=AsyncMock)
def test_download_resolves_symlink_via_realpath(self, mock_connect):
"""Native get() path uses the resolved path for symlinked files."""
mock_conn = AsyncMock()
mock_sftp = AsyncMock()
mock_sftp.realpath.side_effect = [
"/", # connect
"/real/file.bin", # download resolves symlink
]
mock_conn.start_sftp_client.return_value = mock_sftp
mock_connect.return_value = mock_conn

progress_calls: list[tuple[int, int]] = []

async def fake_get(remotepath, localpath, *, progress_handler=None, **kwargs):
if progress_handler:
progress_handler(remotepath, localpath, 500, 1000)
progress_handler(remotepath, localpath, 1000, 1000)

mock_sftp.get = AsyncMock(side_effect=fake_get)

client = SFTPClient(ConnectionInfo(protocol=Protocol.SFTP, host="example.com"))
client.connect()

mock_file = MagicMock()
mock_file.name = "/tmp/downloaded.bin"
mock_file.close = MagicMock()

client.download(
"/symlink/file.bin",
mock_file,
callback=lambda t, n: progress_calls.append((t, n)),
)

# Verify get() was called with the resolved path
call_args = mock_sftp.get.call_args
assert call_args[0][0] == "/real/file.bin"
assert progress_calls == [(500, 1000), (1000, 1000)]

@patch("asyncssh.connect", new_callable=AsyncMock)
def test_download_fallback_resolves_symlink_via_realpath(self, mock_connect):
"""BytesIO fallback path uses the resolved path for symlinked files."""
mock_conn = AsyncMock()
mock_sftp = AsyncMock()
mock_sftp.realpath.side_effect = [
"/", # connect
"/real/file.txt", # download resolves symlink
]
mock_conn.start_sftp_client.return_value = mock_sftp
mock_connect.return_value = mock_conn

mock_remote_file = AsyncMock()
mock_remote_file.read.side_effect = [b"hello", b""]
mock_open_cm = MagicMock()
mock_open_cm.__aenter__ = AsyncMock(return_value=mock_remote_file)
mock_open_cm.__aexit__ = AsyncMock(return_value=False)
mock_sftp.open = MagicMock(return_value=mock_open_cm)
stat_attrs = MagicMock()
stat_attrs.size = 5
mock_sftp.stat.return_value = stat_attrs

client = SFTPClient(ConnectionInfo(protocol=Protocol.SFTP, host="example.com"))
client.connect()

buf = io.BytesIO()
progress_calls: list[tuple[int, int]] = []
client.download(
"/symlink/file.txt",
buf,
callback=lambda t, n: progress_calls.append((t, n)),
)

# Verify open() and stat() were called with the resolved path
mock_sftp.open.assert_called_once_with("/real/file.txt", "rb")
mock_sftp.stat.assert_called_once_with("/real/file.txt")
assert buf.getvalue() == b"hello"
assert progress_calls == [(5, 5)]

@patch("asyncssh.connect", new_callable=AsyncMock)
def test_download_falls_back_to_original_path_when_realpath_fails(self, mock_connect):
"""If realpath fails, download uses the original path."""
mock_conn = AsyncMock()
mock_sftp = AsyncMock()
mock_sftp.realpath.side_effect = [
"/", # connect
OSError("realpath failed"), # download fallback
]
mock_conn.start_sftp_client.return_value = mock_sftp
mock_connect.return_value = mock_conn

progress_calls: list[tuple[int, int]] = []

async def fake_get(remotepath, localpath, *, progress_handler=None, **kwargs):
if progress_handler:
progress_handler(remotepath, localpath, 100, 100)

mock_sftp.get = AsyncMock(side_effect=fake_get)

client = SFTPClient(ConnectionInfo(protocol=Protocol.SFTP, host="example.com"))
client.connect()

mock_file = MagicMock()
mock_file.name = "/tmp/downloaded.bin"
mock_file.close = MagicMock()

client.download(
"/original/path.bin",
mock_file,
callback=lambda t, n: progress_calls.append((t, n)),
)

# Verify get() was called with the original path (fallback)
call_args = mock_sftp.get.call_args
assert call_args[0][0] == "/original/path.bin"
assert progress_calls == [(100, 100)]


class TestProtocolEnum:
def test_all_protocols(self):
assert Protocol.FTP.value == "ftp"
Expand Down
Loading
Loading