Files
ftptui/ftptui/sftp_backend.py
T
x3 226108f4dc Up-/Download gefixt: absoluter Remote-Pfad statt "/./datei" (#3)
paramikos getcwd() liefert None, solange kein chdir() erfolgt ist. Der
Rueckfall auf "." landete als remote_path in der App, woraus _join()/_abs()
Pfade wie "/./datei" bauten. Der Server loeste die relativ zum Dateisystem-
Root statt zum Home auf -> "No such file" beim Download, "Permission denied"
beim Upload. Das Listing funktionierte, weil listdir(".") serverseitig
relativ zum Home aufgeloest wird -- daher fiel es erst beim Transfer auf.

- connect()/chdir()/listdir() loesen den Pfad ueber sftp.normalize() auf
- _abs() haengt relative Pfade an das CWD an, statt blind "/" voranzustellen
- _copy_tree(): die beiden mkdir-Zweige waren vertauscht. Beim Download
  wurde backend.mkdir() auf einen lokalen Pfad angewendet (Verzeichnis
  entstand auf dem Server), beim Upload os.makedirs() auf einen entfernten.
  Ordner-Transfers waren dadurch in beide Richtungen defekt.

Verifiziert per End-to-End-Test gegen einen echten sshd/SFTP-Server:
Datei- und rekursiver Ordner-Transfer in beide Richtungen.

Closes #3
2026-08-31 17:19:15 +00:00

124 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SFTP-Transport basierend auf ``paramiko`` (SSH-FTP)."""
from __future__ import annotations
import stat
from pathlib import PurePosixPath
import paramiko
from .models import RemoteEntry
class SFTPBackend:
"""Adapter, der ``paramiko``/SFTP auf das Transport-Protokoll mappt."""
def __init__(self) -> None:
self._ssh: paramiko.SSHClient | None = None
self._sftp: paramiko.SFTPClient | None = None
self._cwd = "."
def connect(self, host: str, port: int, username: str, password: str) -> None:
self._ssh = paramiko.SSHClient()
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self._ssh.connect(
host,
port=port,
username=username,
password=password,
timeout=30,
)
self._sftp = self._ssh.open_sftp()
self._cwd = self._resolve(".")
def _resolve(self, path: str) -> str:
"""Löst einen Pfad serverseitig zu einem absoluten Pfad auf.
``paramiko.SFTPClient.getcwd()`` liefert ``None``, solange kein
explizites ``chdir()`` erfolgt ist ein Rückfall auf ``"."`` würde
später zu Pfaden wie ``/./datei`` führen, die der Server relativ zum
Dateisystem-Root statt zum Home-Verzeichnis auflöst.
"""
assert self._sftp is not None
try:
return self._sftp.normalize(path)
except OSError:
return path if path.startswith("/") else "/"
def listdir(self, path: str) -> list[RemoteEntry]:
assert self._sftp is not None
entries: list[RemoteEntry] = []
for attr in self._sftp.listdir_attr(path):
mode = attr.st_mode
kind = "dir" if stat.S_ISDIR(mode) else "file"
entries.append(
RemoteEntry(
name=attr.filename,
kind=kind,
size=attr.st_size,
modified=attr.st_mtime,
)
)
self._cwd = self._resolve(path)
return entries
def chdir(self, path: str) -> None:
assert self._sftp is not None
self._sftp.chdir(path)
self._cwd = self._resolve(".")
def pwd(self) -> str:
return self._cwd
def _abs(self, path: str) -> str:
"""Baut einen absoluten Pfad relative Pfade gelten zum aktuellen CWD."""
if not path:
return self._cwd or "/"
base = self._cwd if self._cwd.startswith("/") else "/"
return str(PurePosixPath(base) / path)
def download(self, remote: str, local: str) -> None:
assert self._sftp is not None
self._sftp.get(self._abs(remote), local)
def upload(self, local: str, remote: str) -> None:
assert self._sftp is not None
self._sftp.put(local, self._abs(remote))
def mkdir(self, path: str) -> None:
assert self._sftp is not None
try:
self._sftp.mkdir(self._abs(path))
except IOError:
pass
def remove(self, path: str) -> None:
assert self._sftp is not None
self._sftp.remove(self._abs(path))
def rmdir(self, path: str) -> None:
assert self._sftp is not None
self._recurse_rmdir(self._abs(path))
def _recurse_rmdir(self, path: str) -> None:
assert self._sftp is not None
for attr in self._sftp.listdir_attr(path):
child = f"{path.rstrip('/')}/{attr.filename}"
if stat.S_ISDIR(attr.st_mode):
self._recurse_rmdir(child)
else:
self._sftp.remove(child)
self._sftp.rmdir(path)
def rename(self, src: str, dst: str) -> None:
assert self._sftp is not None
self._sftp.rename(self._abs(src), self._abs(dst))
def close(self) -> None:
if self._sftp is not None:
self._sftp.close()
self._sftp = None
if self._ssh is not None:
self._ssh.close()
self._ssh = None