From 4e6cb4cfb7d2d61febb1c96b48dfd4017c53c851 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:10:02 +0000 Subject: [PATCH] Fix SFTP compatibility with FileZilla and other clients - Implement `scandir` instead of `listdir` to provide file attributes (mode, mtime). - Provide explicit `S_IFDIR` for directories and `S_IFREG` for files. - Include `.` and `..` entries in directory listings. - Improve error handling for non-existent clients and paths in SFTP. - Fix path resolution to correctly identify client root directories. Co-authored-by: VulcanoSoftware <113239901+VulcanoSoftware@users.noreply.github.com> --- dcfs/app/sftp/handler.py | 43 ++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/dcfs/app/sftp/handler.py b/dcfs/app/sftp/handler.py index 095f304..1b23e04 100644 --- a/dcfs/app/sftp/handler.py +++ b/dcfs/app/sftp/handler.py @@ -44,34 +44,57 @@ def _get_ops(self, path_bytes: bytes) -> tuple[Optional[Ops], str]: client_name, sub_path = split_global_path(path) if client_name in self.clients: return Ops(self.clients[client_name]), "/" + sub_path.lstrip("/") + else: + raise asyncssh.SFTPNoSuchFile(f"No such client: {client_name}") + except asyncssh.SFTPNoSuchFile: + raise except Exception: logger.debug(f"Failed to split global path: {path}") return None, "/" - # Client not found - treat as root listing + # Fallback to root return None, "/" - async def listdir(self, path: bytes) -> List[asyncssh.SFTPName]: # type: ignore[override] + async def scandir(self, path: bytes) -> AsyncIterator[asyncssh.SFTPName]: # type: ignore[override] ops, sub_path = self._get_ops(path) - names = [] + + # Standard . and .. entries + yield asyncssh.SFTPName(b".", attrs=asyncssh.SFTPAttrs(mode=stat.S_IFDIR | 0o755)) + yield asyncssh.SFTPName(b"..", attrs=asyncssh.SFTPAttrs(mode=stat.S_IFDIR | 0o755)) if ops is None: if sub_path == "/": for client_name in self.clients: - names.append(asyncssh.SFTPName(client_name.encode("utf-8"))) - return names + yield asyncssh.SFTPName( + client_name.encode("utf-8"), + attrs=asyncssh.SFTPAttrs(mode=stat.S_IFDIR | 0o755), + ) + return if sub_path == "/": directory = ops._client.dir_api.root else: - directory = ops.cd(sub_path) + try: + directory = ops.cd(sub_path) + except FileOrDirectoryDoesNotExist: + raise asyncssh.SFTPNoSuchFile(f"No such directory: {path.decode('utf-8')}") for d in directory.find_dirs(): - names.append(asyncssh.SFTPName(d.name.encode("utf-8"))) + mtime = int(d.modified_at_timestamp / 1000) + yield asyncssh.SFTPName( + d.name.encode("utf-8"), + attrs=asyncssh.SFTPAttrs( + mode=stat.S_IFDIR | 0o755, mtime=mtime, atime=mtime + ), + ) for f in directory.find_files(): - names.append(asyncssh.SFTPName(f.name.encode("utf-8"))) - - return names + # For files we might not want to fetch full desc just for listing + # as it involves network calls to Discord for each file. + # We provide the basic mode to help clients like FileZilla. + yield asyncssh.SFTPName( + f.name.encode("utf-8"), + attrs=asyncssh.SFTPAttrs(mode=stat.S_IFREG | 0o644), + ) async def stat(self, path: bytes) -> asyncssh.SFTPAttrs: # type: ignore[override] ops, sub_path = self._get_ops(path)