From 23da7a67241a9801faad9d7da105a6c3f933a86c Mon Sep 17 00:00:00 2001 From: Xuz Date: Wed, 16 Sep 2026 10:32:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=9C=9F=E5=AE=9E=20SFTP=20?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=B8=8E=20WinSCP=20=E5=BC=8F=E6=96=AD?= =?UTF-8?q?=E7=82=B9=E7=BB=AD=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 +- CLAUDE.md | 10 +- Cargo.lock | 54 ++ Cargo.toml | 9 +- README.md | 47 ++ src/app/actions.rs | 103 ++++ src/app/mod.rs | 3 + src/connection.rs | 214 ++++++++ src/explorer/explorer_panel.rs | 632 ++++++++++++++++++++--- src/explorer/file_listing.rs | 178 ++++++- src/explorer/file_pane.rs | 694 ++++++++++++++++++++------ src/explorer/mock_fs.rs | 224 --------- src/explorer/mod.rs | 7 +- src/explorer/model.rs | 215 +------- src/explorer/upload_dialog.rs | 341 +++++++++++++ src/lib.rs | 2 + src/session/group_dialog.rs | 14 +- src/session/session_dialog.rs | 12 +- src/sftp/client.rs | 322 ++++++++++++ src/sftp/control.rs | 72 +++ src/sftp/journal.rs | 184 +++++++ src/sftp/mod.rs | 15 + src/sftp/model.rs | 350 +++++++++++++ src/sftp/protocol_tests.rs | 404 +++++++++++++++ src/sftp/tests.rs | 857 ++++++++++++++++++++++++++++++++ src/sftp/transport.rs | 61 +++ src/sftp/upload.rs | 723 +++++++++++++++++++++++++++ src/sftp/worker.rs | 369 ++++++++++++++ src/ssh/connection.rs | 681 +++++++++++++++++++++++++ src/ssh/mod.rs | 6 +- src/ssh/transport.rs | 725 ++------------------------- src/terminal/transport.rs | 218 +------- src/workspace/workspace_view.rs | 365 +++++++++++--- tests/ui.rs | 667 ++++++++++++++++++++++++- 34 files changed, 7137 insertions(+), 1651 deletions(-) create mode 100644 README.md create mode 100644 src/connection.rs delete mode 100644 src/explorer/mock_fs.rs create mode 100644 src/explorer/upload_dialog.rs create mode 100644 src/sftp/client.rs create mode 100644 src/sftp/control.rs create mode 100644 src/sftp/journal.rs create mode 100644 src/sftp/mod.rs create mode 100644 src/sftp/model.rs create mode 100644 src/sftp/protocol_tests.rs create mode 100644 src/sftp/tests.rs create mode 100644 src/sftp/transport.rs create mode 100644 src/sftp/upload.rs create mode 100644 src/sftp/worker.rs create mode 100644 src/ssh/connection.rs diff --git a/AGENTS.md b/AGENTS.md index 6177256..05248ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。 -会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。**仍然是 mock 的**:只剩 SFTP 文件浏览器(内存里的种子目录树)。 +会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。 已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。 @@ -37,15 +37,19 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLR_DATA_DIR` → `dirs::data_dir()/shellr`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。 - `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 -- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。`transport.rs` 还定义了整套 prompt 协议(`TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret`),传输层靠它向界面要主机信任和认证答案。 +- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。 - `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。 - `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。 -- `explorer/` — `model.rs`(纯内存 `DirTree` / `Location` 导航)、`mock_fs.rs`(种子目录树)、`file_listing.rs`(`TableDelegate`)、`file_pane.rs`(单栏:地址行 + `DataTable` + 底部汇总)、`explorer_panel.rs`(`h_resizable` 里的两个栏)。 +- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 +- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 - `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。 关键流程与不变量: +- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除会话会停止上传并保留续传数据。每会话一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。 +- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。 + - **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。 - **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。 - **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。 diff --git a/CLAUDE.md b/CLAUDE.md index f6bc424..7318977 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。 -会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。**仍然是 mock 的**:只剩 SFTP 文件浏览器(内存里的种子目录树)。 +会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。 已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。 @@ -39,15 +39,19 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLR_DATA_DIR` → `dirs::data_dir()/shellr`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。 - `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 -- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。`transport.rs` 还定义了整套 prompt 协议(`TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret`),传输层靠它向界面要主机信任和认证答案。 +- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。 - `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。 - `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。 -- `explorer/` — `model.rs`(纯内存 `DirTree` / `Location` 导航)、`mock_fs.rs`(种子目录树)、`file_listing.rs`(`TableDelegate`)、`file_pane.rs`(单栏:地址行 + `DataTable` + 底部汇总)、`explorer_panel.rs`(`h_resizable` 里的两个栏)。 +- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 +- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 - `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。 关键流程与不变量: +- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除会话会停止上传并保留续传数据。每会话一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。 +- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。 + - **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。 - **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。 - **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。 diff --git a/Cargo.lock b/Cargo.lock index 612b91f..8e0460f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1644,6 +1644,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -2667,6 +2681,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "glow" version = "0.17.0" @@ -6685,6 +6711,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "russh-sftp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "chrono", + "dashmap", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + [[package]] name = "russh-util" version = "0.52.0" @@ -7363,16 +7409,22 @@ dependencies = [ "alacritty_terminal", "anyhow", "async-channel", + "chrono", "dirs 6.0.0", + "futures", "gpui-kit", "keyring", "portable-pty", "rusqlite", "russh", + "russh-sftp", "serde", + "serde_json", + "sha2 0.10.9", "tempfile", "tokio", "unicode-width", + "uuid", "zeroize", ] @@ -8147,6 +8199,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -8194,6 +8247,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "libc", "pin-project-lite", "tokio", diff --git a/Cargo.toml b/Cargo.toml index e369610..8f6fb53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,16 +11,23 @@ alacritty_terminal = "0.26" anyhow = "1" async-channel = "2" dirs = "6" +futures = "0.3" portable-pty = "0.9" russh = "0.63" +russh-sftp = "=3.0.0" +serde_json = "1" +sha2 = "0.10" +uuid = { version = "1", features = ["v4"] } +chrono = "0.4" rusqlite = { version = "0.40", features = ["bundled"] } serde = { version = "1", features = ["derive"] } -tokio = { version = "1", features = ["io-util", "net", "rt", "sync", "time"] } +tokio = { version = "1", features = ["fs", "io-util", "net", "rt", "sync", "time"] } unicode-width = "0.2" zeroize = "1" keyring = "4" [dev-dependencies] +tokio = { version = "1", features = ["process", "test-util"] } gpui-kit = { version = "0.6.1", features = ["test-support"] } tempfile = "3" diff --git a/README.md b/README.md new file mode 100644 index 0000000..57026e1 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# shellr + +基于 GPUI Kit 的中文 SSH 会话管理工具,提供持久化会话与分组、本地终端、SSH 多终端和真实 SFTP 双栏浏览器。 + +## SFTP 上传 + +打开会话的「SFTP」标签即可浏览本地与远程目录。SFTP 使用独立 SSH 连接,仅请求 SFTP 子系统,不要求服务器提供 Shell,关闭终端不会中止上传。 + +- 地址输入、上级目录、刷新、列排序和目录双击导航;本地从系统用户目录开始,远程初始目录由服务器查询。 +- 本地复选框选择上传项目,空格切换活动行、⌘/Ctrl+A 全选、Shift 点击复选框范围选择。选中集合按路径保存,排序不改变所选项目。 +- 使用「上传所选…」、本地列表中的 F5、「选择文件上传…」,或从本地面板 / Finder 拖入远程列表。拖到目录行时使用该目录,其余列表区域使用当前目录。 +- 所有入口均显示确认框,可修改目标目录。确认后固定来源、会话端点和目标;每会话同时运行一个批次,批内依次处理,其他会话和终端可继续操作。 +- 递归上传包含空目录;符号链接原样创建,不跟随。默认二进制传输,保留普通文件修改时间;新建项使用服务器默认权限,覆盖保留目标基本权限。不复制所有者、ACL 或扩展属性。 +- 同名目录合并,文件 / 链接冲突询问覆盖、跳过或取消,可应用于本批剩余冲突。目录与非目录冲突不会自动删除。错误可重试、跳过或取消,完成汇总分别显示成功、跳过和失败。 + +## 断点续传和替换 + +文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后才发布正式文件;服务器支持时先调用 `fsync@openssh.com`。支持 `posix-rename@openssh.com` 的服务器使用原子替换;其他服务器先将旧文件移到 `.shellr-.backup`,再发布新文件,此兼容路径不保证原子性。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。 + +续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变会话数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。 + +网络故障每批最多自动重连三次,间隔 1、3、10 秒。耗尽后使用「继续上传」,手动恢复会重置次数。认证失败、主机密钥变化和主动取消需要用户处理。重连沿用原连接配置,会话编辑不会改变正在上传的目标。 + +取消、关闭标签、断开会话或退出时保留续传记录;关闭正在上传的标签会先确认。重启后不会自动上传,再次选择相同来源和目标时会询问续传。停止状态下可使用「丢弃续传进度」清理该批受管理的临时文件;存在无法判断的恢复备份时会保留文件并说明原因。成功发布的项目自动清理记录和备份。 + +文件名不能准确表示时会明确报错。当前 `russh-sftp 3.0.0` 的远端字符串解码会替换无效 UTF-8,浏览器因此拒绝包含替换字符 `U+FFFD` 的远端名称,避免访问错误路径。符号链接沿用 OpenSSH 的 SFTP v3 参数顺序。 + +**尚未实现:下载、目录同步、传输队列和 Dock 布局持久化。** 界面不会对这些操作显示虚假的传输成功提示。 + +## 运行和验证 + +工具链固定 Rust 1.98.1,`russh-sftp` 固定 3.0.0;保持仓库锁定的 GPUI 与 russh 版本。 + +```sh +cargo run +cargo test +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` + +数据目录默认是系统应用数据目录中的 `shellr`,可通过 `SHELLR_DATA_DIR` 覆盖。会话保存在 `shellr.db`,主机信任记录保存在该目录的 `known_hosts`,凭据使用系统钥匙串。 + +`src/sftp/tests.rs` 使用临时目录与故障注入覆盖文件内容、空目录、链接、覆盖、权限、取消、源文件变化、前缀验证及替换各阶段恢复。Unix 协议测试使用本机 OpenSSH `sftp-server`(macOS `/usr/libexec/sftp-server`,Linux `/usr/lib/openssh/sftp-server`);工作线程测试仅监听 `127.0.0.1` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。 + +`tests/ui.rs` 驱动真实 `Workspace`,通过 `Workspace::new_with_services` 注入 SFTP、本地目录和终端服务,不连接用户服务器或真实钥匙串。Finder 的系统原生拖放仍需在真实 macOS 窗口中补充人工验收;无头测试覆盖原生文件拖入事件和面板内部拖放。 + +上传交互参考 [WinSCP 上传流程](https://winscp.net/eng/docs/task_upload),协议适配参考 [russh-sftp 请求接口](https://docs.rs/russh-sftp/3.0.0/russh_sftp/client/struct.RawSftpSession.html) 与 [OpenSSH 扩展规范](https://raw.githubusercontent.com/openssh/openssh-portable/master/PROTOCOL)。 diff --git a/src/app/actions.rs b/src/app/actions.rs index 35470a1..3464a07 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -142,3 +142,106 @@ local_terminal_action!( /// Restart a local terminal with a fresh emulator and PTY. RestartLocalTerminal ); + +/// Commands shared by explorer controls, drops, dialogs and keyboard bindings. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub enum ExplorerCommand { + Navigate { + remote: bool, + path: String, + }, + Up { + remote: bool, + }, + Refresh { + remote: bool, + }, + Check { + name: String, + checked: bool, + extend: bool, + }, + ToggleSelection, + SelectAll, + UploadSelected, + ChooseFiles, + UploadPaths { + paths: Vec, + target: String, + }, + BeginUpload { + paths: Vec, + target: String, + }, + Answer { + request_id: u64, + answer: crate::sftp::UploadAnswer, + }, + CancelUpload, + ResumeUpload, + DiscardUpload, + ToggleDetails, + CloseConfirmed, +} +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = shellr, no_json)] +pub struct ExplorerAction { + session: SessionId, + command: ExplorerCommand, + generation: Option, +} +impl ExplorerAction { + pub fn new(session: SessionId, command: ExplorerCommand) -> Self { + Self { + session, + command, + generation: None, + } + } + pub fn with_generation(mut self, generation: u64) -> Self { + self.generation = Some(generation); + self + } + pub fn generation(&self) -> Option { + self.generation + } + pub fn session(&self) -> SessionId { + self.session + } + pub fn command(&self) -> &ExplorerCommand { + &self.command + } +} +gpui_kit::actions!( + shellr, + [ + UploadSelectedFiles, + ToggleUploadSelection, + SelectAllUploadFiles + ] +); + +/// UI entities dispatch after their update has finished so Workspace can read +/// their latest snapshot without re-entering a leased entity. +pub(crate) trait ExplorerDispatch { + fn dispatch_explorer_action( + &self, + action: &ExplorerAction, + window: &mut gpui_kit::Window, + cx: &mut gpui_kit::App, + ); +} +impl ExplorerDispatch for gpui_kit::FocusHandle { + fn dispatch_explorer_action( + &self, + action: &ExplorerAction, + window: &mut gpui_kit::Window, + cx: &mut gpui_kit::App, + ) { + let focus = self.clone(); + let action = action.clone(); + window.defer(cx, move |window, cx| { + focus.dispatch_action(&action, window, cx) + }); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 1997895..e74360b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -43,6 +43,9 @@ fn key_bindings() -> Vec { KeyBinding::new(&primary("0"), ZoomReset, None), KeyBinding::new(&primary("q"), Quit, None), KeyBinding::new("shift-escape", ToggleZoom, None), + KeyBinding::new("f5", UploadSelectedFiles, Some("LocalFileList")), + KeyBinding::new("space", ToggleUploadSelection, Some("LocalFileList")), + KeyBinding::new(&primary("a"), SelectAllUploadFiles, Some("LocalFileList")), KeyBinding::new("enter", ConnectSelected, Some(SESSION_PANEL_CONTEXT)), #[cfg(target_os = "macos")] KeyBinding::new("cmd-c", CopyTerminal, Some(TERMINAL_KEY_CONTEXT)), diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..7fab5cd --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,214 @@ +//! Shared connection questions. Secrets are never serialized or printed. +use zeroize::Zeroizing; + +/// One field requested by an SSH keyboard-interactive challenge. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectionPromptField { + label: String, + echo: bool, +} + +impl ConnectionPromptField { + pub fn new(label: impl Into, echo: bool) -> Self { + Self { + label: label.into(), + echo, + } + } + + pub fn label(&self) -> &str { + &self.label + } + pub fn echo(&self) -> bool { + self.echo + } +} + +/// A transport question that must be answered by the UI before SSH setup can +/// continue. The id is scoped to a single transport generation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectionPrompt { + request_id: u64, + kind: ConnectionPromptKind, +} + +impl ConnectionPrompt { + pub fn new(request_id: u64, kind: ConnectionPromptKind) -> Self { + Self { request_id, kind } + } + + pub fn request_id(&self) -> u64 { + self.request_id + } + pub fn kind(&self) -> &ConnectionPromptKind { + &self.kind + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ConnectionPromptKind { + UnknownHost(UnknownHostPrompt), + HostKeyChanged(HostKeyChangedPrompt), + Authentication(AuthenticationPrompt), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnknownHostPrompt { + host: String, + port: u16, + algorithm: String, + fingerprint: String, +} + +impl UnknownHostPrompt { + pub fn host(&self) -> &str { + &self.host + } + pub fn port(&self) -> u16 { + self.port + } + pub fn algorithm(&self) -> &str { + &self.algorithm + } + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostKeyChangedPrompt { + host: String, + port: u16, + algorithm: String, + old_fingerprints: Vec, + fingerprint: String, + known_hosts_path: std::path::PathBuf, +} + +impl HostKeyChangedPrompt { + pub fn host(&self) -> &str { + &self.host + } + pub fn port(&self) -> u16 { + self.port + } + pub fn algorithm(&self) -> &str { + &self.algorithm + } + pub fn old_fingerprints(&self) -> &[String] { + &self.old_fingerprints + } + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + pub fn known_hosts_path(&self) -> &std::path::Path { + &self.known_hosts_path + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthenticationPrompt { + title: String, + instructions: String, + fields: Vec, +} + +impl AuthenticationPrompt { + pub fn title(&self) -> &str { + &self.title + } + pub fn instructions(&self) -> &str { + &self.instructions + } + pub fn fields(&self) -> &[ConnectionPromptField] { + &self.fields + } +} + +impl ConnectionPromptKind { + pub fn unknown_host( + host: impl Into, + port: u16, + algorithm: impl Into, + fingerprint: impl Into, + ) -> Self { + Self::UnknownHost(UnknownHostPrompt { + host: host.into(), + port, + algorithm: algorithm.into(), + fingerprint: fingerprint.into(), + }) + } + + pub fn host_key_changed( + host: impl Into, + port: u16, + algorithm: impl Into, + old_fingerprints: Vec, + fingerprint: impl Into, + known_hosts_path: impl Into, + ) -> Self { + Self::HostKeyChanged(HostKeyChangedPrompt { + host: host.into(), + port, + algorithm: algorithm.into(), + old_fingerprints, + fingerprint: fingerprint.into(), + known_hosts_path: known_hosts_path.into(), + }) + } + + pub fn authentication( + title: impl Into, + instructions: impl Into, + fields: Vec, + ) -> Self { + Self::Authentication(AuthenticationPrompt { + title: title.into(), + instructions: instructions.into(), + fields, + }) + } +} + +/// A secret that is erased when dropped and never prints its contents. +pub struct ConnectionSecret(Zeroizing); + +impl ConnectionSecret { + pub fn new(value: impl Into) -> Self { + Self(Zeroizing::new(value.into())) + } + pub(crate) fn expose(&self) -> &str { + self.0.as_str() + } + pub(crate) fn into_inner(mut self) -> String { + std::mem::take(&mut *self.0) + } +} + +impl std::fmt::Debug for ConnectionSecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ConnectionSecret([已隐藏])") + } +} + +/// UI response to a transport prompt. Deliberately not `Clone`: answers may +/// contain passwords, private-key passphrases or one-time codes. +pub enum ConnectionPromptReply { + TrustAndSave, + Answers(Vec), + Cancel, +} + +impl std::fmt::Debug for ConnectionPromptReply { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TrustAndSave => formatter.write_str("TrustAndSave"), + Self::Answers(answers) => formatter + .debug_tuple("Answers") + .field(&format_args!("{} 个已隐藏答案", answers.len())) + .finish(), + Self::Cancel => formatter.write_str("Cancel"), + } + } +} diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index b191dae..8a34d4a 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -1,149 +1,508 @@ -use gpui_kit::component::{ - dock::{BasePanel, Panel, PanelEvent, TabGroup}, - menu::PopupMenu, - resizable::{h_resizable, resizable_panel}, +use super::{FilePane, PaneSide}; +use crate::app::ExplorerDispatch as _; +use crate::{ + app::{CatalogIcon, CloseExplorer, EditSession, ExplorerAction, ExplorerCommand}, + connection::{ConnectionPrompt, ConnectionPromptReply}, + session::{ConnectionState, SessionId, SessionStore}, + sftp::{ + RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider, + SharedSftpTransportProvider, UploadPhase, UploadProgress, UploadQuestion, UploadRequest, + }, + shared::ClosableTabTitle, }; +use gpui_kit::component::{ + ActiveTheme as _, Sizable as _, WindowExt as _, + button::{Button, ButtonVariants as _}, + dock::{BasePanel, Panel, PanelEvent, TabGroup}, + h_flex, + menu::PopupMenu, + notification::Notification, + progress::Progress, + resizable::{h_resizable, resizable_panel}, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; - -use crate::app::{CatalogIcon, CloseExplorer, EditSession}; -use crate::session::{SessionId, SessionStore}; -use crate::shared::ClosableTabTitle; - -use super::{DirTree, FilePane, Location, PaneSide, local_tree, remote_home, remote_tree}; - -/// Emitted to the workspace, which keeps its panel registry and its record of -/// the displayed center tab in step. -#[derive(Clone, Debug, PartialEq, Eq)] +use std::{ + sync::atomic::{AtomicU64, Ordering}, + time::Duration, +}; +static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1); +#[derive(Clone, Debug)] pub enum ExplorerPanelEvent { - /// The panel became the displayed tab of its group. Activated(SessionId), Closed(SessionId), + StateChanged(SessionId), + PromptRequested(SessionId, u64, ConnectionPrompt), } - -/// A center tab with the WinSCP-style two-pane file browser for a session. pub struct ExplorerPanel { session_id: SessionId, + generation: u64, + endpoint: String, store: Entity, local: Entity, remote: Entity, + local_provider: SharedLocalDirectoryProvider, + commands: async_channel::Sender, + state: ConnectionState, + message: String, + progress: Option, + details: bool, + upload_pending: bool, + pub(super) question: Option, + pub(super) dialog_open: bool, + pub(super) dispatch: FocusHandle, focus_handle: FocusHandle, tab_group: Option>, _subscriptions: Vec, + _events: Task<()>, } - impl ExplorerPanel { pub fn new( session_id: SessionId, store: Entity, + provider: SharedSftpTransportProvider, + local_provider: SharedLocalDirectoryProvider, + dispatch: FocusHandle, window: &mut Window, cx: &mut Context, ) -> Self { - let session = store.read(cx).session(session_id).cloned(); - let local_home = "/Users/xuz"; + let session = store + .read(cx) + .session(session_id) + .cloned() + .expect("workspace checked session"); + let endpoint = format!("{}@{}:{}", session.user, session.host, session.port); + let home = local_provider.home().to_string_lossy().into_owned(); let local = cx.new(|cx| { FilePane::new( PaneSide::Local, session_id, - Location::new(local_tree(), local_home, Some(local_home)), + home.clone(), + dispatch.clone(), window, cx, ) }); - let (remote_dirs, home) = session - .as_ref() - .map(|session| (remote_tree(session), remote_home(session))) - .unwrap_or_else(|| (DirTree::new(), "/".to_string())); let remote = cx.new(|cx| { FilePane::new( PaneSide::Remote, session_id, - Location::new(remote_dirs, &home, Some(&home)), + String::new(), + dispatch.clone(), window, cx, ) }); - let subscriptions = vec![cx.observe(&store, |_, _, cx| cx.notify())]; - + local.update(cx, |pane, cx| { + pane.load_local(home, local_provider.clone(), window, cx) + }); + let (commands, receiver) = async_channel::unbounded(); + let (sender, events) = async_channel::unbounded(); + let transport = provider.create(&session); + let failed = sender.clone(); + if let Err(error) = std::thread::Builder::new() + .name("shellr-sftp".into()) + .spawn(move || { + if let Err(error) = transport.run(receiver, sender.clone()) { + let _ = sender.send_blocking(SftpEvent::Disconnected(error.to_string())); + } + }) + { + let _ = failed.try_send(SftpEvent::Disconnected(format!("无法启动 SFTP:{error}"))); + } + // Poll snapshots as the terminal engine does. Worker threads never wake + // a foreground-only GPUI task directly. + let events_task = cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; + let pending: Vec<_> = (0..128).map_while(|_| events.try_recv().ok()).collect(); + if !pending.is_empty() + && this + .update_in(cx, |this, window, cx| { + for event in pending { + this.on_event(event, window, cx); + } + }) + .is_err() + { + break; + } + if events.is_closed() && events.is_empty() { + break; + } + } + }); + let subscriptions = vec![ + cx.observe(&store, |_, _, cx| cx.notify()), + cx.on_app_quit(|this, _| { + this.send(SftpCommand::Shutdown); + async {} + }), + ]; Self { session_id, + generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed), + endpoint, store, local, remote, + local_provider, + commands, + state: ConnectionState::Connecting, + message: "正在连接 SFTP…".into(), + progress: None, + details: false, + upload_pending: false, + question: None, + dialog_open: false, + dispatch, focus_handle: cx.focus_handle(), tab_group: None, _subscriptions: subscriptions, + _events: events_task, } } - pub fn session_id(&self) -> SessionId { self.session_id } - + pub fn generation(&self) -> u64 { + self.generation + } + pub fn endpoint(&self) -> &str { + &self.endpoint + } pub fn tab_group(&self) -> Option> { self.tab_group.clone() } - pub fn local(&self) -> &Entity { &self.local } - pub fn remote(&self) -> &Entity { &self.remote } + pub fn connection_state(&self) -> ConnectionState { + self.state + } + pub fn progress(&self) -> Option<&UploadProgress> { + self.progress.as_ref() + } + pub fn is_uploading(&self) -> bool { + self.upload_pending + || self + .progress + .as_ref() + .is_some_and(UploadProgress::is_active) + } + pub fn send(&self, command: SftpCommand) { + let _ = self.commands.try_send(command); + } + pub fn reply_to_prompt(&self, request_id: u64, reply: ConnectionPromptReply) { + self.send(SftpCommand::PromptReply { request_id, reply }); + } + pub fn disconnect(&mut self, window: &mut Window, cx: &mut Context) { + self.send(SftpCommand::Disconnect); + self.state = ConnectionState::Disconnected; + self.message = "SFTP 已断开".into(); + self.remote + .update(cx, |pane, cx| pane.disconnected("SFTP 已断开".into(), cx)); + self.close_upload_dialog(window, cx); + self.sync_available(cx); + cx.emit(ExplorerPanelEvent::StateChanged(self.session_id)); + cx.notify(); + } + pub fn close_upload_dialog(&mut self, window: &mut Window, cx: &mut Context) { + if self.dialog_open { + self.dialog_open = false; + self.question = None; + if window.has_active_dialog(cx) { + window.close_dialog(cx); + } + } + } + fn sync_available(&mut self, cx: &mut Context) { + let enabled = self.state == ConnectionState::Connected && !self.is_uploading(); + self.local + .update(cx, |pane, cx| pane.set_available(enabled, cx)); + self.remote + .update(cx, |pane, cx| pane.set_available(enabled, cx)); + } + fn on_event(&mut self, event: SftpEvent, window: &mut Window, cx: &mut Context) { + match event { + SftpEvent::Connecting => { + self.generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed); + self.state = ConnectionState::Connecting; + self.message = "正在连接 SFTP…".into(); + cx.emit(ExplorerPanelEvent::StateChanged(self.session_id)); + } + SftpEvent::Connected { home } => { + self.state = ConnectionState::Connected; + self.message = self.endpoint.clone(); + self.remote + .update(cx, |pane, _| pane.set_home(home.to_string())); + let path = self.remote.read(cx).path(); + self.navigate(true, path, window, cx); + cx.emit(ExplorerPanelEvent::StateChanged(self.session_id)); + } + SftpEvent::Disconnected(message) => { + self.state = ConnectionState::Disconnected; + self.remote + .update(cx, |pane, cx| pane.disconnected(message.clone(), cx)); + self.message = message; + cx.emit(ExplorerPanelEvent::StateChanged(self.session_id)); + } + SftpEvent::Prompt(prompt) => cx.emit(ExplorerPanelEvent::PromptRequested( + self.session_id, + self.generation, + prompt, + )), + SftpEvent::Listed { request_id, result } => self.remote.update(cx, |pane, cx| { + pane.apply_listing(request_id, result, window, cx) + }), + SftpEvent::Progress(progress) => { + let complete = progress.phase() == UploadPhase::Completed; + self.upload_pending = false; + self.progress = Some(progress); + if !self.is_uploading() { + self.close_upload_dialog(window, cx); + } + if complete && self.state == ConnectionState::Connected { + let path = self.remote.read(cx).path(); + self.navigate(true, path, window, cx); + } + } + SftpEvent::Question(question) => { + self.question = Some(question); + cx.spawn_in(window, async move |this, cx| { + loop { + let done = this + .update_in(cx, |this, window, cx| { + if this.question.is_none() { + return true; + } + if window.has_active_dialog(cx) { + return false; + } + this.open_question(window, cx); + true + }) + .unwrap_or(true); + if done { + break; + } + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + } + }) + .detach(); + } + SftpEvent::Idle => { + self.upload_pending = false; + } + SftpEvent::Notice(message) => { + window.push_notification(Notification::error(message), cx); + } + } + self.sync_available(cx); + cx.notify(); + } + pub fn navigate( + &mut self, + remote: bool, + path: String, + window: &mut Window, + cx: &mut Context, + ) { + let pane = if remote { + self.remote.clone() + } else { + self.local.clone() + }; + let path = pane.read(cx).expanded_path(&path); + if remote { + let id = pane.update(cx, |pane, cx| pane.begin_load(cx)); + match RemotePath::new(path) { + Ok(path) => self.send(SftpCommand::List { + request_id: id, + path, + }), + Err(e) => pane.update(cx, |pane, cx| { + pane.apply_listing(id, Err(e.to_string()), window, cx) + }), + } + } else { + pane.update(cx, |pane, cx| { + pane.load_local(path, self.local_provider.clone(), window, cx) + }); + } + } + pub fn execute( + &mut self, + command: &ExplorerCommand, + window: &mut Window, + cx: &mut Context, + ) { + match command { + ExplorerCommand::Navigate { remote, path } => { + self.navigate(*remote, path.clone(), window, cx) + } + ExplorerCommand::Up { remote } => { + let pane = if *remote { &self.remote } else { &self.local }; + let path = pane.read(cx).parent_path(); + self.navigate(*remote, path, window, cx); + } + ExplorerCommand::Refresh { remote } => { + let pane = if *remote { &self.remote } else { &self.local }; + let path = pane.read(cx).path(); + self.navigate(*remote, path, window, cx); + } + ExplorerCommand::Check { + name, + checked, + extend, + } => self + .local + .update(cx, |pane, cx| pane.check(name, *checked, *extend, cx)), + ExplorerCommand::ToggleSelection => { + self.local.update(cx, |pane, cx| pane.toggle_selected(cx)) + } + ExplorerCommand::SelectAll => self.local.update(cx, |pane, cx| pane.select_all(cx)), + ExplorerCommand::UploadSelected => { + let paths = self.local.read(cx).upload_sources(); + let target = self.remote.read(cx).path(); + self.open_upload(paths, target, window, cx); + } + ExplorerCommand::ChooseFiles => { + if self.is_uploading() || self.state != ConnectionState::Connected { + return; + } + let choice = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: Some("选择要上传的文件或目录".into()), + }); + let target = self.remote.read(cx).path(); + cx.spawn_in(window, async move |this, cx| { + if let Ok(Ok(Some(paths))) = choice.await { + let _ = this.update_in(cx, |this, window, cx| { + this.dispatch.dispatch_explorer_action( + &ExplorerAction::new( + this.session_id, + ExplorerCommand::UploadPaths { paths, target }, + ), + window, + cx, + ); + }); + } + }) + .detach(); + } + ExplorerCommand::UploadPaths { paths, target } => { + self.open_upload(paths.clone(), target.clone(), window, cx) + } + ExplorerCommand::BeginUpload { paths, target } => { + if self.is_uploading() || self.state != ConnectionState::Connected { + return; + } + match RemotePath::new(target.clone()) + .and_then(|path| UploadRequest::new(paths.clone(), path)) + { + Ok(request) => { + self.upload_pending = true; + self.details = false; + self.send(SftpCommand::Upload(request)); + } + Err(error) => { + window.push_notification(Notification::error(error.to_string()), cx) + } + } + } + ExplorerCommand::Answer { request_id, answer } => { + if self + .question + .as_ref() + .is_some_and(|q| q.id() == *request_id) + { + self.question = None; + self.dialog_open = false; + self.send(SftpCommand::Answer { + request_id: *request_id, + answer: *answer, + }); + } + } + ExplorerCommand::CancelUpload => self.send(SftpCommand::Cancel), + ExplorerCommand::ResumeUpload => { + if !self.is_uploading() { + self.upload_pending = true; + self.send(SftpCommand::Resume); + } + } + ExplorerCommand::DiscardUpload => { + if !self.is_uploading() { + self.send(SftpCommand::Discard); + } + } + ExplorerCommand::ToggleDetails => self.details = !self.details, + ExplorerCommand::CloseConfirmed => {} + } + self.sync_available(cx); + cx.notify(); + } +} +impl Drop for ExplorerPanel { + fn drop(&mut self) { + self.send(SftpCommand::Shutdown); + } } - impl EventEmitter for ExplorerPanel {} impl EventEmitter for ExplorerPanel {} - impl Focusable for ExplorerPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } - impl BasePanel for ExplorerPanel { fn panel_name(&self) -> &'static str { "ExplorerPanel" } - fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { if active { let id = self.session_id; self.store .update(cx, |store, cx| store.set_active(Some(id), cx)); - // The tab that just went inactive stops rendering, so the focus - // it still holds leaves the dispatch tree and every - // `window.dispatch_action` (the tab's 「×」, ⌘W, the shortcuts) - // lands nowhere. Whoever becomes active has to take focus. window.focus(&self.focus_handle, cx); cx.emit(ExplorerPanelEvent::Activated(id)); } } - fn on_added_to(&mut self, group: WeakEntity, _: &mut Window, _: &mut Context) { self.tab_group = Some(group); } - - fn on_removed(&mut self, _: &mut Window, cx: &mut Context) { + fn on_removed(&mut self, window: &mut Window, cx: &mut Context) { + self.disconnect(window, cx); + self.send(SftpCommand::Shutdown); self.tab_group = None; cx.emit(ExplorerPanelEvent::Closed(self.session_id)); } } - impl Panel for ExplorerPanel { fn title(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let name = self .store .read(cx) .session(self.session_id) - .map(|session| session.name.clone()) + .map(|s| s.name.clone()) .unwrap_or_else(|| "SFTP".into()); ClosableTabTitle::new(CatalogIcon::FolderTree, format!("{name} · SFTP")).closable( ("close-explorer", self.session_id.0), Box::new(CloseExplorer(self.session_id)), ) } - fn dropdown_menu( &mut self, menu: PopupMenu, @@ -152,35 +511,178 @@ impl Panel for ExplorerPanel { ) -> PopupMenu { menu.menu("编辑会话…", Box::new(EditSession(self.session_id))) } - fn inner_padding(&self, _: &App) -> bool { false } } - impl Render for ExplorerPanel { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - let sid = self.session_id.0; - div() - .id(("explorer", sid)) + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let sid = self.session_id; + let command_button = |id: &'static str, label: &'static str, command: ExplorerCommand| { + let dispatch = self.dispatch.clone(); + Button::new(id) + .ghost() + .small() + .label(label) + .on_click(move |_, w, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(sid, command.clone()), + w, + cx, + ) + }) + }; + v_flex() + .id(("explorer", sid.0)) .test_support() .track_focus(&self.focus_handle) .size_full() + .min_w_0() .child( - // Pane sizes are an API boundary that takes `Pixels`; the - // resize handle owns the hairline between the panes. - h_resizable(("explorer-panes", sid)) - .child( - resizable_panel() - .size(px(480.)) - .size_range(px(240.)..Pixels::MAX) - .child(self.local.clone()), - ) - .child( - resizable_panel() - .size_range(px(240.)..Pixels::MAX) - .child(self.remote.clone()), + h_flex() + .gap_2() + .px_2() + .py_1() + .text_sm() + .border_b_1() + .border_color(cx.theme().border) + .child(div().flex_1().min_w_0().child(self.message.clone())) + .when( + self.state == ConnectionState::Disconnected && !self.is_uploading(), + |this| { + this.child(command_button( + "reconnect-sftp", + "重新连接", + ExplorerCommand::ResumeUpload, + )) + }, ), ) + .child( + div().flex_1().min_h_0().child( + h_resizable(("explorer-panes", sid.0)) + .child( + resizable_panel() + .size(px(480.)) + .size_range(px(320.)..Pixels::MAX) + .child(self.local.clone()), + ) + .child( + resizable_panel() + .size_range(px(280.)..Pixels::MAX) + .child(self.remote.clone()), + ), + ), + ) + .when_some(self.progress.as_ref(), |this, progress| { + let status = match progress.phase() { + UploadPhase::Scanning => "正在扫描", + UploadPhase::Uploading => "正在上传", + UploadPhase::Waiting => "等待处理", + UploadPhase::Reconnecting => "正在重连", + UploadPhase::Stopped => "已停止 · 可继续上传", + UploadPhase::Completed => { + if progress.failed() > 0 { + "上传结束 · 部分失败" + } else { + "上传结束" + } + } + }; + let value = if progress.phase() == UploadPhase::Completed { + 100. + } else if progress.total_bytes() > 0 { + progress.completed_bytes() as f32 / progress.total_bytes() as f32 * 100. + } else { + 0. + }; + this.child( + v_flex() + .id("upload-progress") + .test_support() + .gap_2() + .px_3() + .py_2() + .border_t_1() + .border_color(cx.theme().border) + .child( + h_flex() + .gap_2() + .child( + div() + .id("upload-status") + .test_support() + .text_sm() + .child(status), + ) + .child(div().flex_1()) + .when(progress.is_active(), |this| { + this.child(command_button( + "cancel-upload", + "取消", + ExplorerCommand::CancelUpload, + )) + }) + .when(progress.phase() == UploadPhase::Stopped, |this| { + this.child(command_button( + "resume-upload", + "继续上传", + ExplorerCommand::ResumeUpload, + )) + .child(command_button( + "discard-upload", + "丢弃续传进度", + ExplorerCommand::DiscardUpload, + )) + }) + .child(command_button( + "upload-details", + "详情", + ExplorerCommand::ToggleDetails, + )), + ) + .child( + div() + .text_sm() + .text_ellipsis() + .child(progress.current().to_string()), + ) + .child( + Progress::new("upload-bytes") + .value(value) + .accessibility_label("上传进度") + .small(), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!( + "{} / {} · {}/秒 · 成功 {} · 跳过 {} · 失败 {}", + super::format_size(progress.completed_bytes()), + super::format_size(progress.total_bytes()), + super::format_size(progress.bytes_per_second()), + progress.succeeded(), + progress.skipped(), + progress.failed() + )), + ) + .when(self.details, |this| { + this.child( + div() + .id("upload-detail-list") + .max_h_32() + .overflow_y_scroll() + .text_xs() + .children( + progress + .details() + .iter() + .map(|detail| div().child(detail.clone())), + ), + ) + }), + ) + }) } } diff --git a/src/explorer/file_listing.rs b/src/explorer/file_listing.rs index 52b0ec9..b57d47f 100644 --- a/src/explorer/file_listing.rs +++ b/src/explorer/file_listing.rs @@ -1,8 +1,16 @@ +use crate::app::ExplorerDispatch as _; +use crate::{ + app::{ExplorerAction, ExplorerCommand}, + session::SessionId, +}; use gpui_kit::component::{ ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, table::{Column, ColumnSort, TableDelegate, TableState}, }; use gpui_kit::*; +use std::{collections::HashSet, path::PathBuf, rc::Rc}; + +use gpui_kit::component::checkbox::Checkbox; use super::{FileEntry, FileKind, PaneSide, format_size}; @@ -11,6 +19,13 @@ pub struct FileListing { columns: Vec, rows: Vec, sort: Option<(usize, ColumnSort)>, + active_name: Option, + side: PaneSide, + session: SessionId, + path: String, + checked: Rc>, + enabled: bool, + dispatch: Option, } impl FileListing { @@ -27,6 +42,9 @@ impl FileListing { .width(px(150.)) .sortable(), ]; + if side == PaneSide::Local { + columns.insert(0, Column::new("check", "").width(px(36.))); + } if side == PaneSide::Remote { columns.push(Column::new("permissions", "权限").width(px(110.))); } @@ -34,9 +52,34 @@ impl FileListing { columns, rows, sort: None, + active_name: None, + side, + session: SessionId(0), + path: String::new(), + checked: Rc::new(HashSet::new()), + enabled: false, + dispatch: None, } } + pub fn set_active_name(&mut self, name: Option) { + self.active_name = name; + } + pub fn configure( + &mut self, + session: SessionId, + path: String, + checked: Rc>, + enabled: bool, + dispatch: FocusHandle, + ) { + self.session = session; + self.path = path; + self.checked = checked; + self.enabled = enabled; + self.dispatch = Some(dispatch); + } + pub fn rows(&self) -> &[FileEntry] { &self.rows } @@ -103,6 +146,34 @@ impl TableDelegate for FileListing { return div().into_any_element(); }; match self.columns[col_ix].key.as_ref() { + "check" => { + if entry.is_parent() { + return div().into_any_element(); + } + let name = entry.name.to_string(); + let checked = self.checked.contains(&name); + let dispatch = self.dispatch.clone(); + let sid = self.session; + Checkbox::new(ElementId::Name(format!("check:{name}").into())) + .checked(checked) + .on_change(move |checked, window, cx| { + if let Some(dispatch) = &dispatch { + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::Check { + name: name.clone(), + checked: *checked, + extend: window.modifiers().shift, + }, + ), + window, + cx, + ); + } + }) + .into_any_element() + } "name" => { let icon = match entry.kind { FileKind::Dir => IconName::Folder, @@ -146,12 +217,72 @@ impl TableDelegate for FileListing { _: &mut Window, _: &mut Context>, ) -> Stateful
{ - let name = self - .rows - .get(row_ix) - .map(|entry| entry.name.to_string()) - .unwrap_or_default(); - div().id(ElementId::Name(format!("file:{name}").into())) + let Some(entry) = self.rows.get(row_ix) else { + return div().id(("empty-file-row", row_ix)); + }; + let name = entry.name.to_string(); + let row = div().id(ElementId::Name(format!("file:{name}").into())); + let Some(dispatch) = self.dispatch.clone() else { + return row; + }; + let sid = self.session; + if self.side == PaneSide::Local && !entry.is_parent() { + let names = if self.checked.contains(&name) { + self.checked.iter().cloned().collect::>() + } else { + vec![name.clone()] + }; + let paths = names + .iter() + .map(|name| PathBuf::from(&self.path).join(name)) + .collect(); + row.on_drag(LocalFilesDrag { paths }, |drag, _, _, cx| { + cx.new(|_| FileDragPreview { + count: drag.paths.len(), + }) + }) + } else if self.side == PaneSide::Remote && entry.is_dir() && !entry.is_parent() { + let target = format!("{}/{name}", self.path.trim_end_matches('/')); + let enabled = self.enabled; + row.on_drop({ + let dispatch = dispatch.clone(); + let target = target.clone(); + move |paths: &ExternalPaths, window, cx| { + if enabled { + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::UploadPaths { + paths: paths.paths().to_vec(), + target: target.clone(), + }, + ), + window, + cx, + ); + cx.stop_propagation(); + } + } + }) + .on_drop(move |drag: &LocalFilesDrag, window, cx| { + if enabled { + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::UploadPaths { + paths: drag.paths.clone(), + target: target.clone(), + }, + ), + window, + cx, + ); + cx.stop_propagation(); + } + }) + } else { + row + } } fn perform_sort( @@ -166,6 +297,20 @@ impl TableDelegate for FileListing { sort => Some((col_ix, sort)), }; self.apply_sort(); + let index = self + .active_name + .as_ref() + .and_then(|name| self.rows.iter().position(|e| e.name.as_ref() == name)); + let entity = cx.entity().downgrade(); + cx.defer(move |cx| { + let _ = entity.update(cx, |table, cx| { + if let Some(index) = index { + table.set_selected_row(index, cx); + } else { + table.clear_selection(cx); + } + }); + }); cx.notify(); } @@ -184,3 +329,24 @@ impl TableDelegate for FileListing { .child("空目录") } } + +#[derive(Clone)] +pub(super) struct LocalFilesDrag { + pub paths: Vec, +} +struct FileDragPreview { + count: usize, +} +impl Render for FileDragPreview { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .px_3() + .py_2() + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .child(format!("上传 {} 个项目", self.count)) + } +} diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index c5d9f07..2311959 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -1,204 +1,470 @@ +use super::{FileEntry, FileListing}; +use crate::app::ExplorerDispatch as _; +use crate::{ + app::{CatalogIcon, ExplorerAction, ExplorerCommand}, + session::SessionId, + sftp::{DirectoryListing, EntryKind, SharedLocalDirectoryProvider}, +}; use gpui_kit::component::{ - ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, WindowExt as _, + ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, button::{Button, ButtonVariants as _}, h_flex, input::{Input, InputEvent, InputState}, table::{DataTable, TableEvent, TableState}, v_flex, }; +use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; +use std::{collections::HashSet, path::PathBuf, rc::Rc}; -use crate::app::CatalogIcon; -use crate::session::SessionId; - -use super::{FileEntry, FileListing, Location}; - -/// Which side of the explorer a pane is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PaneSide { Local, Remote, } - impl PaneSide { pub fn label(self) -> &'static str { - match self { - PaneSide::Local => "本地", - PaneSide::Remote => "远程", + if self == Self::Local { + "本地" + } else { + "远程" } } - fn pane_id(self) -> &'static str { - match self { - PaneSide::Local => "local-pane", - PaneSide::Remote => "remote-pane", + if self == Self::Local { + "local-pane" + } else { + "remote-pane" } } - fn path_id(self) -> &'static str { - match self { - PaneSide::Local => "local-path", - PaneSide::Remote => "remote-path", + if self == Self::Local { + "local-path" + } else { + "remote-path" } } } - -/// One side of the explorer: address row, file table, summary footer. pub struct FilePane { side: PaneSide, session_id: SessionId, - location: Location, + path: String, + home: String, table: Entity>, path_input: Entity, - selected: Option, + selected: Option, + checked: HashSet, + anchor: Option, + request_id: u64, + loading: bool, + error: Option, + transfer_enabled: bool, + dispatch: FocusHandle, _subscriptions: Vec, + load_task: Option>, } - impl FilePane { pub fn new( side: PaneSide, session_id: SessionId, - location: Location, + home: String, + dispatch: FocusHandle, window: &mut Window, cx: &mut Context, ) -> Self { - let rows = location.rows(); + let mut listing = FileListing::new(side, Vec::new()); + listing.configure( + session_id, + home.clone(), + Rc::new(HashSet::new()), + false, + dispatch.clone(), + ); let table = cx.new(|cx| { - TableState::new(FileListing::new(side, rows), window, cx) + TableState::new(listing, window, cx) .row_selectable(true) .col_selectable(false) .cell_selectable(false) }); - let path_input = cx.new(|cx| InputState::new(window, cx).default_value(location.display())); - let subscriptions = - vec![ - cx.subscribe_in(&table, window, |this, _, event: &TableEvent, window, cx| { - match event { - TableEvent::SelectRow(ix) => { - this.selected = Some(*ix); - cx.notify(); - } - TableEvent::DoubleClickedRow(ix) => this.open_row(*ix, window, cx), - TableEvent::ClearSelection => { - this.selected = None; - cx.notify(); - } - _ => {} + let path_input = cx.new(|cx| InputState::new(window, cx).default_value(&home)); + let subscriptions = vec![ + cx.subscribe_in( + &table, + window, + |this, table, event: &TableEvent, window, cx| match event { + TableEvent::SelectRow(ix) => { + this.selected = table + .read(cx) + .delegate() + .entry(*ix) + .map(|e| e.name.to_string()); + table.update(cx, |table, _| { + table.delegate_mut().set_active_name(this.selected.clone()) + }); + cx.notify(); } - }), - cx.subscribe_in( - &path_input, - window, - |this, state, event: &InputEvent, window, cx| { - if let InputEvent::PressEnter { .. } = event { - let path = state.read(cx).value().to_string(); - this.navigate_to(&path, window, cx); + TableEvent::DoubleClickedRow(ix) => { + let entry = table.read(cx).delegate().entry(*ix).cloned(); + if let Some(entry) = entry + && entry.is_dir() + { + let command = if entry.is_parent() { + ExplorerCommand::Up { + remote: this.side == PaneSide::Remote, + } + } else { + ExplorerCommand::Navigate { + remote: this.side == PaneSide::Remote, + path: this.child_path(&entry.name), + } + }; + this.dispatch.dispatch_explorer_action( + &ExplorerAction::new(this.session_id, command), + window, + cx, + ); } - }, - ), - ]; - + } + TableEvent::ClearSelection => { + this.selected = None; + cx.notify(); + } + _ => {} + }, + ), + cx.subscribe_in( + &path_input, + window, + |this, state, event: &InputEvent, window, cx| { + if matches!(event, InputEvent::PressEnter { .. }) { + this.dispatch.dispatch_explorer_action( + &ExplorerAction::new( + this.session_id, + ExplorerCommand::Navigate { + remote: this.side == PaneSide::Remote, + path: state.read(cx).value().to_string(), + }, + ), + window, + cx, + ); + } + }, + ), + ]; Self { side, session_id, - location, + path: home.clone(), + home, table, path_input, selected: None, + checked: HashSet::new(), + anchor: None, + request_id: 0, + loading: false, + error: None, + transfer_enabled: false, + dispatch, _subscriptions: subscriptions, + load_task: None, } } - pub fn side(&self) -> PaneSide { self.side } - - /// Absolute path of the directory shown. pub fn path(&self) -> String { - self.location.path() + self.path.clone() } - pub fn selected_entry(&self, cx: &App) -> Option { - self.selected - .and_then(|ix| self.table.read(cx).delegate().entry(ix).cloned()) + self.selected.as_ref().and_then(|name| { + self.table + .read(cx) + .delegate() + .rows() + .iter() + .find(|e| e.name.as_ref() == name) + .cloned() + }) } - - fn open_row(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { - let Some(entry) = self.table.read(cx).delegate().entry(ix).cloned() else { - return; - }; - if !entry.is_dir() { - return; - } - let moved = if entry.is_parent() { - self.location.up() + fn child_path(&self, name: &str) -> String { + if self.side == PaneSide::Local { + PathBuf::from(&self.path) + .join(name) + .to_string_lossy() + .into_owned() } else { - self.location.enter(&entry.name) - }; - if moved { - self.reload(window, cx); + format!("{}/{name}", self.path.trim_end_matches('/')) } } - - fn go_up(&mut self, window: &mut Window, cx: &mut Context) { - if self.location.up() { - self.reload(window, cx); - } - } - - fn navigate_to(&mut self, path: &str, window: &mut Window, cx: &mut Context) { - if self.location.set_path(path) { - self.reload(window, cx); + pub fn parent_path(&self) -> String { + if self.side == PaneSide::Local { + PathBuf::from(&self.path) + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.path.clone()) } else { - window.push_notification(format!("路径不存在:{path}"), cx); + crate::sftp::RemotePath::new(&self.path) + .map(|p| p.parent().to_string()) + .unwrap_or_else(|_| "/".into()) } } - - /// Re-read the current directory into the table and address field. - pub fn reload(&mut self, window: &mut Window, cx: &mut Context) { - let rows = self.location.rows(); - self.table.update(cx, |table, cx| { - table.delegate_mut().set_rows(rows); - table.clear_selection(cx); - table.refresh(cx); - }); - let display = self.location.display(); - self.path_input - .update(cx, |input, cx| input.set_value(display, window, cx)); - self.selected = None; + pub fn expanded_path(&self, path: &str) -> String { + if path == "~" { + self.home.clone() + } else if let Some(rest) = path.strip_prefix("~/") { + format!("{}/{rest}", self.home.trim_end_matches('/')) + } else if (self.side == PaneSide::Local && !PathBuf::from(path).is_absolute()) + || (self.side == PaneSide::Remote && !path.starts_with('/')) + { + self.child_path(path) + } else { + path.to_string() + } + } + pub fn set_home(&mut self, home: String) { + if self.home.is_empty() { + self.path = home.clone(); + } + self.home = home; + } + pub fn set_available(&mut self, available: bool, cx: &mut Context) { + self.transfer_enabled = available; + self.sync_listing(cx); cx.notify(); } - - /// Mock transfer: the real product would enqueue an upload/download. - fn transfer(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(entry) = self.selected_entry(cx) { - window.push_notification(format!("已加入传输队列:{}", entry.name), cx); - } + fn sync_listing(&mut self, cx: &mut Context) { + let checked = Rc::new(self.checked.clone()); + self.table.update(cx, |table, cx| { + table.delegate_mut().configure( + self.session_id, + self.path.clone(), + checked, + self.transfer_enabled, + self.dispatch.clone(), + ); + cx.notify(); + }); } -} - -impl Render for FilePane { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let sid = self.session_id.0; - let selected = self.selected_entry(cx); - let transferable = selected.as_ref().is_some_and(|entry| !entry.is_parent()); - let count = self + pub fn check(&mut self, name: &str, checked: bool, extend: bool, cx: &mut Context) { + let names: Vec<_> = self .table .read(cx) .delegate() .rows() .iter() - .filter(|entry| !entry.is_parent()) + .filter(|e| !e.is_parent()) + .map(|e| e.name.to_string()) + .collect(); + if !names.iter().any(|n| n == name) { + return; + } + let mut chosen = vec![name.to_string()]; + if extend + && let (Some(start), Some(end)) = ( + self.anchor + .as_ref() + .and_then(|anchor| names.iter().position(|n| n == anchor)), + names.iter().position(|n| n == name), + ) + { + chosen = names[start.min(end)..=start.max(end)].to_vec(); + } + for name in chosen { + if checked { + self.checked.insert(name); + } else { + self.checked.remove(&name); + } + } + self.anchor = Some(name.into()); + self.sync_listing(cx); + cx.notify(); + } + pub fn toggle_selected(&mut self, cx: &mut Context) { + if let Some(name) = self.selected.clone() { + self.check(&name, !self.checked.contains(&name), false, cx); + } + } + pub fn select_all(&mut self, cx: &mut Context) { + self.checked = self + .table + .read(cx) + .delegate() + .rows() + .iter() + .filter(|e| !e.is_parent()) + .map(|e| e.name.to_string()) + .collect(); + self.sync_listing(cx); + cx.notify(); + } + pub fn upload_sources(&self) -> Vec { + let mut sources: Vec<_> = self + .checked + .iter() + .map(|name| PathBuf::from(&self.path).join(name)) + .collect(); + sources.sort(); + sources + } + pub fn disconnected(&mut self, message: String, cx: &mut Context) { + self.request_id += 1; + self.loading = false; + self.error = Some(message); + cx.notify(); + } + pub fn begin_load(&mut self, cx: &mut Context) -> u64 { + self.request_id += 1; + self.loading = true; + self.error = None; + cx.notify(); + self.request_id + } + pub fn apply_listing( + &mut self, + request_id: u64, + result: Result, + window: &mut Window, + cx: &mut Context, + ) { + if request_id != self.request_id { + return; + } + self.loading = false; + match result { + Ok(listing) => { + if listing.path() != self.path { + self.checked.clear(); + self.selected = None; + self.anchor = None; + } + self.path = listing.path().into(); + self.error = None; + let mut rows: Vec<_> = listing + .entries() + .iter() + .map(|entry| { + let metadata = entry.metadata(); + let modified = metadata + .modified() + .and_then(|t| chrono::DateTime::from_timestamp(t as i64, 0)) + .map(|t| { + t.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M") + .to_string() + }) + .unwrap_or_else(|| "—".into()); + let permissions = metadata + .permissions() + .map(|p| format!("{:03o}", p & 0o777)) + .unwrap_or_else(|| "—".into()); + match metadata.kind() { + EntryKind::Directory => { + FileEntry::dir(entry.name(), &modified, &permissions) + } + EntryKind::Symlink => { + FileEntry::symlink(entry.name(), &modified, &permissions) + } + _ => FileEntry::file( + entry.name(), + metadata.size(), + &modified, + &permissions, + ), + } + }) + .collect(); + rows.sort_by(|a, b| { + b.is_dir() + .cmp(&a.is_dir()) + .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + self.checked + .retain(|name| rows.iter().any(|e| e.name.as_ref() == name)); + if self.parent_path() != self.path { + rows.insert(0, FileEntry::parent()); + } + self.table.update(cx, |table, cx| { + table.delegate_mut().set_rows(rows); + table.clear_selection(cx); + table.refresh(cx); + }); + self.sync_listing(cx); + let display = if self.path == self.home { + "~".into() + } else if let Some(rest) = self + .path + .strip_prefix(&format!("{}/", self.home.trim_end_matches('/'))) + { + format!("~/{rest}") + } else { + self.path.clone() + }; + self.path_input + .update(cx, |input, cx| input.set_value(display, window, cx)); + } + Err(error) => self.error = Some(error), + } + cx.notify(); + } + pub fn load_local( + &mut self, + path: String, + provider: SharedLocalDirectoryProvider, + window: &mut Window, + cx: &mut Context, + ) { + let id = self.begin_load(cx); + self.load_task = Some(cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { + provider + .list(&PathBuf::from(path)) + .map_err(|e| format!("无法读取目录:{e}")) + }) + .await; + let _ = this.update_in(cx, |this, window, cx| { + this.apply_listing(id, result, window, cx) + }); + })); + } +} +impl Render for FilePane { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let remote = self.side == PaneSide::Remote; + let sid = self.session_id; + let button = + |id: &'static str, icon: IconName, tip: &'static str, command: ExplorerCommand| { + let dispatch = self.dispatch.clone(); + Button::new(id) + .ghost() + .small() + .icon(icon) + .tooltip(tip) + .on_click(move |_, window, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(sid, command.clone()), + window, + cx, + ) + }) + }; + let dispatch = self.dispatch.clone(); + let file_count = self + .table + .read(cx) + .delegate() + .rows() + .iter() + .filter(|e| !e.is_parent()) .count(); - let (transfer_id, transfer_icon, transfer_label) = match self.side { - PaneSide::Local => ("upload", CatalogIcon::Upload, "上传"), - PaneSide::Remote => ("download", CatalogIcon::Download, "下载"), - }; - v_flex() - .id((self.side.pane_id(), sid)) + .id((self.side.pane_id(), sid.0)) .test_support() .size_full() + .min_w_0() .child( h_flex() .gap_1() @@ -218,56 +484,170 @@ impl Render for FilePane { Input::new(&self.path_input) .id(self.side.path_id()) .small() - .flex_1(), + .flex_1() + .min_w_0(), ) .child( - Button::new("up") - .ghost() - .xsmall() - .icon(IconName::ArrowUp) - .tooltip("上级目录") - .disabled(self.location.is_root()) - .on_click(cx.listener(|this, _, window, cx| this.go_up(window, cx))), + button( + "up", + IconName::ArrowUp, + "上级目录", + ExplorerCommand::Up { remote }, + ) + .disabled(self.parent_path() == self.path), ) + .child(button( + "refresh", + IconName::Redo, + "刷新", + ExplorerCommand::Refresh { remote }, + )), + ) + .when(!remote, |this| { + this.child( + h_flex() + .gap_2() + .px_2() + .py_1() + .border_b_1() + .border_color(cx.theme().border) + .child( + Button::new("upload") + .outline() + .small() + .icon(Icon::new(CatalogIcon::Upload)) + .label("上传所选…") + .tooltip("上传所选文件(F5)") + .disabled(!self.transfer_enabled || self.checked.is_empty()) + .on_click({ + let dispatch = dispatch.clone(); + move |_, w, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::UploadSelected, + ), + w, + cx, + ) + } + }), + ) + .child( + Button::new("choose-upload") + .ghost() + .small() + .label("选择文件上传…") + .disabled(!self.transfer_enabled) + .on_click(move |_, w, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(sid, ExplorerCommand::ChooseFiles), + w, + cx, + ) + }), + ), + ) + }) + .when(remote, |this| { + this.child( + h_flex() + .px_2() + .py_1() + .h_8() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("拖入文件或目录以上传"), + ) + }) + .when(self.loading, |this| { + this.child(h_flex().px_2().py_1().text_sm().child("正在读取目录…")) + }) + .when_some(self.error.clone(), |this, error| { + this.child( + div() + .id("directory-error") + .test_support() + .px_2() + .py_1() + .text_sm() + .text_color(cx.theme().danger) + .child(error), + ) + }) + .child( + div() + .flex_1() + .min_h_0() + .key_context(if remote { + "RemoteFileList" + } else { + "LocalFileList" + }) + .when(remote, |this| { + let dispatch = self.dispatch.clone(); + let path = self.path.clone(); + let enabled = self.transfer_enabled; + this.drag_over::(|style, _, _, cx| { + style.bg(cx.theme().muted) + }) + .on_drop(move |paths: &ExternalPaths, window, cx| { + if enabled { + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::UploadPaths { + paths: paths.paths().to_vec(), + target: path.clone(), + }, + ), + window, + cx, + ); + cx.stop_propagation(); + } + }) + .on_drop({ + let dispatch = self.dispatch.clone(); + let path = self.path.clone(); + move |drag: &super::file_listing::LocalFilesDrag, window, cx| { + if enabled { + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::UploadPaths { + paths: drag.paths.clone(), + target: path.clone(), + }, + ), + window, + cx, + ); + cx.stop_propagation(); + } + } + }) + }) .child( - Button::new("refresh") - .ghost() - .xsmall() - .icon(Icon::new(CatalogIcon::RefreshCw)) - .tooltip("刷新") - .on_click(cx.listener(|this, _, window, cx| this.reload(window, cx))), - ) - .child( - Button::new(transfer_id) - .outline() - .xsmall() - .icon(Icon::new(transfer_icon)) - .label(transfer_label) - .disabled(!transferable) - .on_click(cx.listener(|this, _, window, cx| this.transfer(window, cx))), + DataTable::new(&self.table) + .stripe(false) + .bordered(false) + .small(), ), ) - .child( - div().flex_1().min_h_0().child( - DataTable::new(&self.table) - .stripe(false) - .bordered(false) - .small(), - ), - ) .child( h_flex() - .justify_between() .px_2() .py_1() .border_t_1() .border_color(cx.theme().border) .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!( - "共 {count} 项,已选 {} 项", - usize::from(transferable) - )), + .child(if remote { + format!("{file_count} 个项目") + } else { + format!("{file_count} 个项目 · 已选择 {} 项", self.checked.len()) + }), ) } } diff --git a/src/explorer/mock_fs.rs b/src/explorer/mock_fs.rs deleted file mode 100644 index 9f233f6..0000000 --- a/src/explorer/mock_fs.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! Seed directory trees for the mock local and remote file systems. - -use crate::session::Session; - -use super::{DirTree, FileEntry}; - -/// The mock local machine, rooted at `/Users/xuz`. -pub fn local_tree() -> DirTree { - let mut tree = DirTree::new(); - tree.add_dir( - "/", - vec![FileEntry::dir("Users", "2026-01-12 09:10", "drwxr-xr-x")], - ) - .add_dir( - "/Users", - vec![FileEntry::dir("xuz", "2026-01-12 09:10", "drwxr-xr-x")], - ) - .add_dir( - "/Users/xuz", - vec![ - FileEntry::dir("Downloads", "2026-09-09 18:42", "drwxr-xr-x"), - FileEntry::dir("Projects", "2026-09-10 11:05", "drwxr-xr-x"), - FileEntry::dir("Documents", "2026-08-30 20:14", "drwxr-xr-x"), - FileEntry::file(".zshrc", 2_310, "2026-07-02 08:15", "-rw-r--r--"), - FileEntry::file("notes.md", 8_412, "2026-09-08 14:32", "-rw-r--r--"), - ], - ) - .add_dir( - "/Users/xuz/Downloads", - vec![ - FileEntry::file( - "shellr-0.1.0.dmg", - 41_943_040, - "2026-09-09 18:42", - "-rw-r--r--", - ), - FileEntry::file("report.pdf", 1_258_291, "2026-09-05 16:20", "-rw-r--r--"), - FileEntry::file("nginx.conf", 3_072, "2026-09-01 10:03", "-rw-r--r--"), - ], - ) - .add_dir( - "/Users/xuz/Projects", - vec![ - FileEntry::dir("shellr", "2026-09-10 11:05", "drwxr-xr-x"), - FileEntry::dir("gpui-kit", "2026-09-10 10:40", "drwxr-xr-x"), - ], - ) - .add_dir( - "/Users/xuz/Projects/shellr", - vec![ - FileEntry::dir("src", "2026-09-10 11:05", "drwxr-xr-x"), - FileEntry::file("Cargo.toml", 612, "2026-09-10 11:05", "-rw-r--r--"), - FileEntry::file("README.md", 1_480, "2026-09-10 11:05", "-rw-r--r--"), - ], - ) - .add_dir( - "/Users/xuz/Projects/shellr/src", - vec![ - FileEntry::file("main.rs", 902, "2026-09-10 11:05", "-rw-r--r--"), - FileEntry::file("lib.rs", 388, "2026-09-10 11:05", "-rw-r--r--"), - ], - ) - .add_dir( - "/Users/xuz/Projects/gpui-kit", - vec![FileEntry::file( - "Cargo.toml", - 4_120, - "2026-09-10 10:40", - "-rw-r--r--", - )], - ) - .add_dir( - "/Users/xuz/Documents", - vec![ - FileEntry::file("架构设计.docx", 356_352, "2026-08-30 20:14", "-rw-r--r--"), - FileEntry::file("服务器清单.xlsx", 24_576, "2026-08-21 09:00", "-rw-r--r--"), - ], - ); - tree -} - -/// The mock remote host for a session, rooted at `/`. -pub fn remote_tree(session: &Session) -> DirTree { - let home = format!("/home/{}", session.user); - let mut tree = DirTree::new(); - tree.add_dir( - "/", - vec![ - FileEntry::dir("etc", "2026-06-15 07:00", "drwxr-xr-x"), - FileEntry::dir("var", "2026-06-15 07:00", "drwxr-xr-x"), - FileEntry::dir("home", "2026-06-15 07:00", "drwxr-xr-x"), - FileEntry::dir("opt", "2026-06-15 07:00", "drwxr-xr-x"), - FileEntry::symlink("bin", "2026-06-15 07:00", "lrwxrwxrwx"), - ], - ) - .add_dir( - "/etc", - vec![ - FileEntry::dir("nginx", "2026-08-02 12:30", "drwxr-xr-x"), - FileEntry::dir("ssh", "2026-06-15 07:00", "drwxr-xr-x"), - FileEntry::file("hosts", 221, "2026-06-15 07:00", "-rw-r--r--"), - FileEntry::file("hostname", 7, "2026-06-15 07:00", "-rw-r--r--"), - ], - ) - .add_dir( - "/etc/nginx", - vec![ - FileEntry::file("nginx.conf", 1_490, "2026-08-02 12:30", "-rw-r--r--"), - FileEntry::dir("sites-enabled", "2026-08-02 12:30", "drwxr-xr-x"), - ], - ) - .add_dir( - "/etc/nginx/sites-enabled", - vec![FileEntry::file( - "default", - 2_412, - "2026-08-02 12:30", - "-rw-r--r--", - )], - ) - .add_dir( - "/etc/ssh", - vec![FileEntry::file( - "sshd_config", - 3_264, - "2026-06-15 07:00", - "-rw-------", - )], - ) - .add_dir( - "/var", - vec![ - FileEntry::dir("www", "2026-09-01 22:10", "drwxr-xr-x"), - FileEntry::dir("log", "2026-09-10 00:00", "drwxr-xr-x"), - ], - ) - .add_dir( - "/var/www", - vec![ - FileEntry::dir("html", "2026-09-01 22:10", "drwxr-xr-x"), - FileEntry::file("app.log", 5_242_880, "2026-09-10 08:41", "-rw-r--r--"), - ], - ) - .add_dir( - "/var/www/html", - vec![ - FileEntry::file("index.html", 612, "2026-09-01 22:10", "-rw-r--r--"), - FileEntry::file("favicon.ico", 4_286, "2026-09-01 22:10", "-rw-r--r--"), - ], - ) - .add_dir( - "/var/log", - vec![ - FileEntry::file("syslog", 12_582_912, "2026-09-10 08:41", "-rw-r-----"), - FileEntry::file("auth.log", 786_432, "2026-09-10 08:41", "-rw-r-----"), - ], - ) - .add_dir( - "/home", - vec![FileEntry::dir( - &session.user, - "2026-06-15 07:00", - "drwxr-x---", - )], - ) - .add_dir( - &home, - vec![ - FileEntry::file(".bashrc", 3_771, "2026-06-15 07:00", "-rw-r--r--"), - FileEntry::file("deploy.sh", 1_024, "2026-09-03 19:22", "-rwxr-xr-x"), - FileEntry::dir("backups", "2026-09-08 02:00", "drwxr-xr-x"), - ], - ) - .add_dir( - &format!("{home}/backups"), - vec![FileEntry::file( - "db-2026-09-08.sql.gz", - 73_400_320, - "2026-09-08 02:00", - "-rw-r--r--", - )], - ) - .add_dir( - "/opt", - vec![FileEntry::dir("app", "2026-07-20 15:00", "drwxr-xr-x")], - ) - .add_dir( - "/opt/app", - vec![ - FileEntry::file("server", 18_874_368, "2026-07-20 15:00", "-rwxr-xr-x"), - FileEntry::file("config.toml", 1_212, "2026-07-20 15:00", "-rw-r--r--"), - ], - ); - tree -} - -/// The directory a remote pane starts in. -pub fn remote_home(session: &Session) -> String { - format!("/home/{}", session.user) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::explorer::Location; - use crate::session::SessionStore; - - #[test] - fn remote_tree_has_a_home_for_the_session_user() { - let store = SessionStore::seed(); - let session = store.sessions().iter().find(|s| s.name == "db-01").unwrap(); - let tree = remote_tree(session); - assert!(tree.contains("/home/postgres")); - let home = remote_home(session); - let location = Location::new(tree, &home, Some(&home)); - assert_eq!(location.display(), "~"); - } - - #[test] - fn local_tree_starts_in_home() { - let location = Location::new(local_tree(), "/Users/xuz", Some("/Users/xuz")); - assert!(location.rows().iter().any(|e| e.name == "Downloads")); - } -} diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index ca212a9..280e44f 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -1,13 +1,14 @@ -//! SFTP 文件浏览: the mock file system model and the two-pane explorer. +//! Real local/remote file browsing and SFTP upload UI. mod explorer_panel; mod file_listing; mod file_pane; -mod mock_fs; mod model; pub use explorer_panel::{ExplorerPanel, ExplorerPanelEvent}; pub use file_listing::FileListing; pub use file_pane::{FilePane, PaneSide}; -pub use mock_fs::{local_tree, remote_home, remote_tree}; pub use model::*; + +mod upload_dialog; +pub use upload_dialog::confirm_close_upload; diff --git a/src/explorer/model.rs b/src/explorer/model.rs index 43ad9c9..a10c261 100644 --- a/src/explorer/model.rs +++ b/src/explorer/model.rs @@ -1,6 +1,4 @@ -//! Pure in-memory file system model used by the explorer panes. - -use std::collections::BTreeMap; +//! Directory row snapshots and display formatting. use gpui_kit::SharedString; @@ -96,130 +94,6 @@ impl FileEntry { } } -/// An in-memory directory tree keyed by absolute path (`/`, `/etc`, ...). -#[derive(Clone, Debug, Default)] -pub struct DirTree { - dirs: BTreeMap>, -} - -impl DirTree { - pub fn new() -> Self { - Self::default() - } - - pub fn add_dir(&mut self, path: &str, entries: Vec) -> &mut Self { - self.dirs.insert(normalize(path), entries); - self - } - - pub fn contains(&self, path: &str) -> bool { - self.dirs.contains_key(&normalize(path)) - } - - pub fn entries(&self, path: &str) -> &[FileEntry] { - self.dirs - .get(&normalize(path)) - .map(Vec::as_slice) - .unwrap_or(&[]) - } -} - -/// A pane's current directory inside a `DirTree`, with navigation. -#[derive(Clone, Debug)] -pub struct Location { - tree: DirTree, - cwd: Vec, - home: Option>, -} - -impl Location { - /// Start at `start`; when `home` is given, paths under it display as `~/…`. - pub fn new(tree: DirTree, start: &str, home: Option<&str>) -> Self { - let mut location = Self { - tree, - cwd: Vec::new(), - home: home.map(split_path), - }; - if !location.set_path(start) { - location.cwd.clear(); - } - location - } - - /// Absolute path of the current directory. - pub fn path(&self) -> String { - join_path(&self.cwd) - } - - /// The path as shown in the address field (`~/Downloads` under home). - pub fn display(&self) -> String { - match &self.home { - Some(home) if self.cwd.starts_with(home) => { - let rest = &self.cwd[home.len()..]; - if rest.is_empty() { - "~".to_string() - } else { - format!("~/{}", rest.join("/")) - } - } - _ => self.path(), - } - } - - pub fn is_root(&self) -> bool { - self.cwd.is_empty() - } - - /// Enter a child directory by name. Returns `false` if it does not exist. - pub fn enter(&mut self, name: &str) -> bool { - if name == ".." { - return self.up(); - } - let mut next = self.cwd.clone(); - next.push(name.to_string()); - let path = join_path(&next); - if !self.tree.contains(&path) { - return false; - } - self.cwd = next; - true - } - - /// Go to the parent directory. Returns `false` at the root. - pub fn up(&mut self) -> bool { - self.cwd.pop().is_some() - } - - /// Jump to an absolute path, or a `~`-relative one when a home is set. - pub fn set_path(&mut self, path: &str) -> bool { - let path = path.trim(); - let expanded = match (&self.home, path.strip_prefix('~')) { - (Some(home), Some(rest)) => format!("{}{}", join_path(home), rest), - _ => path.to_string(), - }; - if !self.tree.contains(&expanded) { - return false; - } - self.cwd = split_path(&expanded); - true - } - - /// Rows for the table: `..` first (unless at the root), then directories, - /// then files, each group sorted by name. - pub fn rows(&self) -> Vec { - let mut entries: Vec = self.tree.entries(&self.path()).to_vec(); - entries.sort_by(|a, b| { - b.is_dir() - .cmp(&a.is_dir()) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - if !self.is_root() { - entries.insert(0, FileEntry::parent()); - } - entries - } -} - /// Human-readable size: `12 B`, `348 KB`, `1.2 MB`. pub fn format_size(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; @@ -239,97 +113,10 @@ pub fn format_size(bytes: u64) -> String { } } -fn normalize(path: &str) -> String { - join_path(&split_path(path)) -} - -fn split_path(path: &str) -> Vec { - path.split('/') - .filter(|part| !part.is_empty() && *part != ".") - .map(str::to_string) - .collect() -} - -fn join_path(parts: &[String]) -> String { - if parts.is_empty() { - "/".to_string() - } else { - format!("/{}", parts.join("/")) - } -} - #[cfg(test)] mod tests { use super::*; - fn tree() -> DirTree { - let mut tree = DirTree::new(); - tree.add_dir( - "/", - vec![ - FileEntry::dir("home", "", ""), - FileEntry::file("readme.txt", 12, "", ""), - ], - ) - .add_dir("/home", vec![FileEntry::dir("xuz", "", "")]) - .add_dir( - "/home/xuz", - vec![ - FileEntry::file("b.txt", 1, "", ""), - FileEntry::dir("Downloads", "", ""), - FileEntry::file("A.txt", 2, "", ""), - ], - ) - .add_dir("/home/xuz/Downloads", vec![]); - tree - } - - #[test] - fn enter_known_dir_updates_cwd() { - let mut location = Location::new(tree(), "/", None); - assert!(location.enter("home")); - assert!(location.enter("xuz")); - assert_eq!(location.path(), "/home/xuz"); - } - - #[test] - fn enter_unknown_name_is_rejected() { - let mut location = Location::new(tree(), "/home", None); - assert!(!location.enter("nope")); - assert_eq!(location.path(), "/home"); - } - - #[test] - fn up_at_root_stays_root() { - let mut location = Location::new(tree(), "/", None); - assert!(!location.up()); - assert!(location.is_root()); - assert!(location.enter("home")); - assert!(location.up()); - assert!(location.is_root()); - } - - #[test] - fn set_path_rejects_missing_path_and_expands_home() { - let mut location = Location::new(tree(), "/", Some("/home/xuz")); - assert!(!location.set_path("/nowhere")); - assert_eq!(location.path(), "/"); - assert!(location.set_path("~/Downloads")); - assert_eq!(location.path(), "/home/xuz/Downloads"); - assert_eq!(location.display(), "~/Downloads"); - assert!(location.set_path("/home")); - assert_eq!(location.display(), "/home"); - } - - #[test] - fn rows_put_dotdot_first_then_dirs_then_files_by_name() { - let location = Location::new(tree(), "/home/xuz", None); - let names: Vec<_> = location.rows().iter().map(|e| e.name.to_string()).collect(); - assert_eq!(names, ["..", "Downloads", "A.txt", "b.txt"]); - let root = Location::new(tree(), "/", None); - assert_eq!(root.rows()[0].name.as_ref(), "home"); - } - #[test] fn format_size_units() { assert_eq!(format_size(12), "12 B"); diff --git a/src/explorer/upload_dialog.rs b/src/explorer/upload_dialog.rs new file mode 100644 index 0000000..0f3ab08 --- /dev/null +++ b/src/explorer/upload_dialog.rs @@ -0,0 +1,341 @@ +use super::ExplorerPanel; +use crate::app::ExplorerDispatch as _; +use crate::{ + app::{ExplorerAction, ExplorerCommand}, + session::SessionId, + sftp::{UploadAnswer, UploadChoice, UploadQuestionKind}, +}; +use gpui_kit::component::{ + ActiveTheme as _, Sizable as _, WindowExt as _, + button::{Button, ButtonVariants as _}, + checkbox::Checkbox, + dialog::{DialogAction, DialogClose, DialogFooter}, + form::{Field, Form}, + input::{Input, InputState}, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; +use std::{cell::Cell, path::PathBuf, rc::Rc}; + +impl ExplorerPanel { + pub(super) fn open_upload( + &mut self, + paths: Vec, + target: String, + window: &mut Window, + cx: &mut Context, + ) { + if paths.is_empty() + || self.is_uploading() + || self.connection_state() != crate::session::ConnectionState::Connected + || window.has_active_dialog(cx) + { + return; + } + let target_input = cx.new(|cx| InputState::new(window, cx).default_value(&target)); + let form = cx.new(|_| UploadForm { + paths: paths.clone(), + target: target_input.clone(), + endpoint: self.endpoint().into(), + error: None, + }); + let dispatch = self.dispatch.clone(); + let sid = self.session_id(); + let generation = self.generation(); + let focus = window.focused(cx).unwrap_or_else(|| self.focus_handle(cx)); + let owner = cx.entity().downgrade(); + self.dialog_open = true; + window.open_dialog(cx, move |dialog, _, _| { + dialog + .title("上传文件") + .child(form.clone()) + .overlay_closable(false) + .footer( + DialogFooter::new() + .child(DialogClose::new().trigger(|b| b.label("取消"))) + .child( + DialogAction::new() + .child(Button::new("upload-confirm").primary().label("上传")), + ), + ) + .on_ok({ + let form = form.clone(); + let dispatch = dispatch.clone(); + let paths = paths.clone(); + move |_, window, cx| { + let target = form.read(cx).target.read(cx).value().to_string(); + if target.is_empty() { + form.update(cx, |form, cx| { + form.error = Some("请输入目标目录".into()); + cx.notify(); + }); + return false; + } + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::BeginUpload { + paths: paths.clone(), + target, + }, + ) + .with_generation(generation), + window, + cx, + ); + true + } + }) + .on_close({ + let focus = focus.clone(); + let owner = owner.clone(); + move |_, window, cx| { + let owner = owner.clone(); + window.defer(cx, move |_, cx| { + let _ = owner.update(cx, |panel, _| panel.dialog_open = false); + }); + window.focus(&focus, cx); + } + }) + }); + } + pub(super) fn open_question(&mut self, window: &mut Window, cx: &mut Context) { + let Some(question) = self.question.clone() else { + return; + }; + let form = cx.new(|_| ConflictForm { + apply_all: false, + kind: question.kind(), + }); + let answered = Rc::new(Cell::new(false)); + let dispatch = self.dispatch.clone(); + let sid = self.session_id(); + let generation = self.generation(); + let focus = window.focused(cx).unwrap_or_else(|| self.focus_handle(cx)); + self.dialog_open = true; + let (title, primary_label, primary_choice) = match question.kind() { + UploadQuestionKind::Conflict => ("同名文件已存在", "覆盖", UploadChoice::Overwrite), + UploadQuestionKind::Resume => ("发现未完成的上传", "继续上传", UploadChoice::Resume), + UploadQuestionKind::InvalidResume => { + ("无法继续此文件", "重新上传", UploadChoice::Restart) + } + UploadQuestionKind::Error => ("无法上传此项目", "重试", UploadChoice::Retry), + }; + window.open_dialog(cx, move |dialog, _, _| { + let answer_button = |id: &'static str, label: &'static str, choice: UploadChoice| { + let answered = answered.clone(); + let dispatch = dispatch.clone(); + let form = form.clone(); + let request_id = question.id(); + Button::new(id).label(label).on_click(move |_, window, cx| { + answered.set(true); + let all = form.read(cx).apply_all; + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::Answer { + request_id, + answer: UploadAnswer::new(choice, all), + }, + ) + .with_generation(generation), + window, + cx, + ); + window.close_dialog(cx); + }) + }; + dialog + .title(title) + .overlay_closable(false) + .child( + v_flex() + .gap_3() + .child(div().text_sm().child(question.path().to_string())) + .child(div().text_sm().child(question.message().to_string())) + .child(form.clone()), + ) + .footer( + DialogFooter::new() + .child(answer_button( + "upload-question-cancel", + "取消上传", + UploadChoice::Cancel, + )) + .child(answer_button( + "upload-question-skip", + "跳过", + UploadChoice::Skip, + )) + .when(question.kind() == UploadQuestionKind::Resume, |footer| { + footer.child(answer_button( + "upload-question-restart", + "重新上传", + UploadChoice::Restart, + )) + }) + .child( + DialogAction::new().child( + Button::new("upload-question-confirm") + .primary() + .label(primary_label), + ), + ), + ) + .on_ok({ + let answered = answered.clone(); + let dispatch = dispatch.clone(); + let form = form.clone(); + let request_id = question.id(); + move |_, window, cx| { + answered.set(true); + let all = form.read(cx).apply_all; + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::Answer { + request_id, + answer: UploadAnswer::new(primary_choice, all), + }, + ) + .with_generation(generation), + window, + cx, + ); + true + } + }) + .on_close({ + let answered = answered.clone(); + let dispatch = dispatch.clone(); + let request_id = question.id(); + let focus = focus.clone(); + move |_, window, cx| { + if !answered.replace(true) { + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::Answer { + request_id, + answer: UploadAnswer::new(UploadChoice::Cancel, false), + }, + ) + .with_generation(generation), + window, + cx, + ); + } + window.focus(&focus, cx); + } + }) + }); + } +} +struct UploadForm { + paths: Vec, + target: Entity, + endpoint: String, + error: Option, +} +impl Render for UploadForm { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .gap_3() + .child(div().text_sm().child(format!( + "上传 {} 个项目到 {}", + self.paths.len(), + self.endpoint + ))) + .child( + div() + .id("upload-source-list") + .max_h_32() + .overflow_y_scroll() + .text_sm() + .children( + self.paths + .iter() + .map(|p| div().child(p.display().to_string())), + ), + ) + .child( + Form::new().child( + Field::new() + .label("目标目录") + .required(true) + .child(Input::new(&self.target).id("upload-target").small()), + ), + ) + .when_some(self.error.clone(), |this, error| { + this.child( + div() + .id("upload-form-error") + .test_support() + .text_color(cx.theme().danger) + .child(error), + ) + }) + } +} +struct ConflictForm { + apply_all: bool, + kind: UploadQuestionKind, +} +impl Render for ConflictForm { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div().when(self.kind == UploadQuestionKind::Conflict, |this| { + this.child( + Checkbox::new("upload-apply-all") + .label("应用于本批剩余冲突") + .checked(self.apply_all) + .on_change(cx.listener(|this, value, _, cx| { + this.apply_all = *value; + cx.notify(); + })), + ) + }) + } +} +/// Session-scoped close confirmation dispatches through the same workspace handler. +pub fn confirm_close_upload( + session: SessionId, + generation: u64, + dispatch: FocusHandle, + window: &mut Window, + cx: &mut App, +) { + let focus = window.focused(cx); + window.open_alert_dialog(cx, move |dialog, _, _| { + dialog + .title("停止上传并关闭?") + .description("上传进度会保留,下次选择相同文件和目标目录时可以继续上传。") + .button_props( + gpui_kit::component::dialog::DialogButtonProps::default() + .ok_text("停止上传并关闭") + .cancel_text("继续上传"), + ) + .show_cancel(true) + .on_cancel({ + let focus = focus.clone(); + move |_, window, cx| { + if let Some(focus) = &focus { + window.focus(focus, cx); + } + true + } + }) + .on_ok({ + let dispatch = dispatch.clone(); + move |_, window, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(session, ExplorerCommand::CloseConfirmed) + .with_generation(generation), + window, + cx, + ); + true + } + }) + }); +} diff --git a/src/lib.rs b/src/lib.rs index a12eeb2..2d80172 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,11 @@ //! `secrets` (系统钥匙串), `workspace` (窗口壳), `app` (动作、快捷键、资源). pub mod app; +pub mod connection; pub mod explorer; pub mod secrets; pub mod session; +pub mod sftp; pub mod shared; pub mod ssh; pub mod terminal; diff --git a/src/session/group_dialog.rs b/src/session/group_dialog.rs index 41a0043..46eeae3 100644 --- a/src/session/group_dialog.rs +++ b/src/session/group_dialog.rs @@ -206,13 +206,23 @@ pub fn confirm_delete_group( name: &str, sessions: usize, subgroups: usize, - closes_tabs: bool, + affected: (bool, usize), on_delete: DeleteHandler, window: &mut Window, cx: &mut App, ) { + let (closes_tabs, uploads) = affected; let title: SharedString = format!("删除“{name}”?").into(); - let description = describe_contents(sessions, subgroups, closes_tabs); + let mut description = describe_contents(sessions, subgroups, closes_tabs); + if uploads > 0 { + description = Some( + format!( + "{}将停止 {uploads} 个上传批次并保留续传进度。", + description.unwrap_or_default() + ) + .into(), + ); + } window.open_alert_dialog(cx, move |alert, _, _| { alert .title(title.clone()) diff --git a/src/session/session_dialog.rs b/src/session/session_dialog.rs index a2ad920..0663140 100644 --- a/src/session/session_dialog.rs +++ b/src/session/session_dialog.rs @@ -579,17 +579,25 @@ pub fn open_session_dialog( /// Ask before deleting a session. `on_delete` runs when the user confirms. pub fn confirm_delete_session( session: &Session, - closes_tabs: bool, + affected: (bool, usize), on_delete: DeleteHandler, window: &mut Window, cx: &mut App, ) { + let (closes_tabs, uploads) = affected; let title: SharedString = format!("删除“{}”?", session.name).into(); window.open_alert_dialog(cx, move |alert, _, _| { alert .title(title.clone()) .when(closes_tabs, |alert| { - alert.description("会一并关闭该会话已打开的终端和 SFTP 标签。") + alert.description(format!( + "会一并关闭该会话已打开的终端和 SFTP 标签。{}", + if uploads > 0 { + format!("将停止 {uploads} 个上传批次并保留续传进度。") + } else { + String::new() + } + )) }) .button_props( DialogButtonProps::default() diff --git a/src/sftp/client.rs b/src/sftp/client.rs new file mode 100644 index 0000000..9a4620f --- /dev/null +++ b/src/sftp/client.rs @@ -0,0 +1,322 @@ +use super::{DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, RemotePath}; +use crate::ssh::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts}; +use anyhow::{Result, anyhow, bail}; +use russh_sftp::{ + client::{RawSftpSession, error::Error}, + protocol::{FileAttributes, OpenFlags, Packet, StatusCode}, +}; +use std::sync::Arc; + +/// Small protocol seam, also implemented by deterministic fault-injection tests. +pub(crate) trait RemoteFs { + async fn metadata(&self, path: &RemotePath) -> Result>; + async fn open(&self, path: &RemotePath, create: bool) -> Result; + async fn sync(&self, handle: &str) -> Result<()>; + async fn write(&self, handle: &str, offset: u64, bytes: Vec) -> Result<()>; + async fn close(&self, handle: &str) -> Result<()>; + async fn attributes( + &self, + handle: &str, + modified: Option, + permissions: Option, + ) -> Result<()>; + async fn mkdir(&self, path: &RemotePath) -> Result<()>; + async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()>; + async fn readlink(&self, path: &RemotePath) -> Result; + async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()>; + async fn remove(&self, path: &RemotePath) -> Result<()>; + fn atomic_replace(&self) -> bool; +} + +pub(crate) struct SftpClient { + raw: RawSftpSession, + _ssh: Option, + fingerprint: String, + atomic_replace: bool, + fsync: bool, +} +impl SftpClient { + pub async fn connect( + connector: &SshConnector, + config: &SshConnectionConfig, + prompts: Arc, + ) -> Result { + let (ssh, fingerprint) = connector.connect(config, prompts).await?; + tokio::time::timeout(std::time::Duration::from_secs(15), async { + let channel = ssh.channel_open_session().await?; + channel.request_subsystem(true, "sftp").await?; + Self::initialize( + RawSftpSession::new(channel.into_stream()), + Some(ssh), + fingerprint, + ) + .await + }) + .await + .map_err(|_| anyhow!("启动 SFTP 超时"))? + } + async fn initialize( + raw: RawSftpSession, + ssh: Option, + fingerprint: String, + ) -> Result { + let version = raw.init().await?; + if version.version != 3 { + bail!("服务器未提供 SFTP v3"); + } + let atomic_replace = version + .extensions + .get("posix-rename@openssh.com") + .is_some_and(|v| v == "1"); + let fsync = version + .extensions + .get("fsync@openssh.com") + .is_some_and(|v| v == "1"); + Ok(Self { + raw, + _ssh: ssh, + fingerprint, + atomic_replace, + fsync, + }) + } + #[cfg(test)] + pub(super) async fn local_test_server( + directory: &std::path::Path, + ) -> Result<(Self, tokio::process::Child)> { + let executable = ["/usr/libexec/sftp-server", "/usr/lib/openssh/sftp-server"] + .into_iter() + .find(|p| std::path::Path::new(p).exists()) + .ok_or_else(|| anyhow!("本机没有 OpenSSH sftp-server"))?; + let mut child = tokio::process::Command::new(executable) + .current_dir(directory) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn()?; + let stream = tokio::io::join(child.stdout.take().unwrap(), child.stdin.take().unwrap()); + let client = + Self::initialize(RawSftpSession::new(stream), None, "local-test-key".into()).await?; + Ok((client, child)) + } + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + pub async fn canonicalize(&self, path: &RemotePath) -> Result { + let response = self.raw.realpath(path.as_str()).await?; + RemotePath::new(checked_text( + &response + .files + .first() + .ok_or_else(|| anyhow!("服务器未返回目录路径"))? + .filename, + )?) + } + pub async fn list(&self, path: &RemotePath) -> Result { + let path = self.canonicalize(path).await?; + let handle = self.raw.opendir(path.as_str()).await?.handle; + let result: Result = async { + let mut entries = Vec::new(); + loop { + match self.raw.readdir(&handle).await { + Ok(page) => { + for entry in page.files { + if matches!(entry.filename.as_str(), "." | "..") { + continue; + } + checked_text(&entry.filename)?; + path.join(&entry.filename)?; + entries + .push(DirectoryEntry::new(entry.filename, metadata(&entry.attrs))); + } + } + Err(Error::Status(status)) if status.status_code == StatusCode::Eof => break, + Err(error) => return Err(error.into()), + } + } + Ok(DirectoryListing::new(path.as_str(), entries)) + } + .await; + let closed = self.raw.close(handle).await; + let listing = result?; + closed?; + Ok(listing) + } +} +impl RemoteFs for SftpClient { + async fn metadata(&self, path: &RemotePath) -> Result> { + match self.raw.lstat(path.as_str()).await { + Ok(attrs) => Ok(Some(metadata(&attrs.attrs))), + Err(Error::Status(status)) if status.status_code == StatusCode::NoSuchFile => Ok(None), + Err(error) => Err(error.into()), + } + } + async fn open(&self, path: &RemotePath, create: bool) -> Result { + let flags = if create { + OpenFlags::CREATE | OpenFlags::EXCLUDE | OpenFlags::WRITE + } else { + OpenFlags::WRITE + }; + Ok(self + .raw + .open(path.as_str(), flags, FileAttributes::empty()) + .await? + .handle) + } + async fn sync(&self, handle: &str) -> Result<()> { + if self.fsync { + self.raw.fsync(handle).await?; + } + Ok(()) + } + async fn write(&self, handle: &str, offset: u64, bytes: Vec) -> Result<()> { + self.raw.write(handle, offset, bytes).await?; + Ok(()) + } + async fn close(&self, handle: &str) -> Result<()> { + self.raw.close(handle).await?; + Ok(()) + } + + async fn attributes( + &self, + handle: &str, + modified: Option, + permissions: Option, + ) -> Result<()> { + self.raw + .fsetstat( + handle, + FileAttributes { + mtime: modified, + atime: modified, + permissions: permissions.map(|p| p & 0o777), + ..FileAttributes::empty() + }, + ) + .await?; + Ok(()) + } + async fn mkdir(&self, path: &RemotePath) -> Result<()> { + self.raw + .mkdir(path.as_str(), FileAttributes::empty()) + .await?; + Ok(()) + } + async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()> { + self.raw.symlink(target, path.as_str()).await?; + Ok(()) + } + async fn readlink(&self, path: &RemotePath) -> Result { + checked_text( + &self + .raw + .readlink(path.as_str()) + .await? + .files + .first() + .ok_or_else(|| anyhow!("无法读取链接目标"))? + .filename, + ) + } + async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> { + if replace { + let mut payload = Vec::new(); + for value in [from.as_str(), to.as_str()] { + payload.extend_from_slice(&(value.len() as u32).to_be_bytes()); + payload.extend_from_slice(value.as_bytes()); + } + match self + .raw + .extended("posix-rename@openssh.com", payload) + .await? + { + Packet::Status(status) if status.status_code == StatusCode::Ok => {} + Packet::Status(status) => return Err(Error::Status(status).into()), + _ => bail!("服务器返回了无效的重命名响应"), + } + } else { + self.raw.rename(from.as_str(), to.as_str()).await?; + } + Ok(()) + } + async fn remove(&self, path: &RemotePath) -> Result<()> { + match self.raw.remove(path.as_str()).await { + Ok(_) => Ok(()), + Err(Error::Status(s)) if s.status_code == StatusCode::NoSuchFile => Ok(()), + Err(error) => Err(error.into()), + } + } + fn atomic_replace(&self) -> bool { + self.atomic_replace + } +} +fn metadata(attrs: &FileAttributes) -> FileMetadata { + let kind = if attrs.is_dir() { + EntryKind::Directory + } else if attrs.is_symlink() { + EntryKind::Symlink + } else if attrs.is_regular() { + EntryKind::File + } else { + EntryKind::Other + }; + FileMetadata::new( + kind, + attrs.size.unwrap_or(0), + attrs.mtime, + attrs.permissions, + ) +} +pub(crate) fn is_network_error(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + if cause.downcast_ref::().is_some_and(|e| { + matches!( + e, + russh::Error::Disconnect + | russh::Error::HUP + | russh::Error::ConnectionTimeout + | russh::Error::KeepaliveTimeout + | russh::Error::InactivityTimeout + ) + }) { + return true; + } + cause.downcast_ref::().is_some_and(|e| { + matches!( + e.kind(), + std::io::ErrorKind::NotConnected + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::BrokenPipe + ) + }) || cause + .downcast_ref::() + .is_some_and(|error| match error { + Error::IO(_) | Error::Timeout => true, + Error::UnexpectedBehavior(message) => { + message == "sender dropped" + || message.contains("SendError") + || message.contains("RecvError") + } + Error::Status(status) => matches!( + status.status_code, + StatusCode::ConnectionLost | StatusCode::NoConnection + ), + _ => false, + }) + }) +} + +// russh-sftp v3 decodes wire strings lossily. Refuse ambiguous names before +// they can become a path for navigation or mutation. +fn checked_text(value: &str) -> Result { + if value.contains('\u{fffd}') { + bail!("远端名称包含无法可靠表示的字符,已停止操作"); + } + Ok(value.to_string()) +} diff --git a/src/sftp/control.rs b/src/sftp/control.rs new file mode 100644 index 0000000..58f8288 --- /dev/null +++ b/src/sftp/control.rs @@ -0,0 +1,72 @@ +use super::{SftpEvent, UploadAnswer, UploadChoice, UploadQuestion, UploadQuestionKind}; +use anyhow::Result; +use async_channel::{Receiver, Sender}; +use std::{ + future::Future, + sync::atomic::{AtomicU64, Ordering}, +}; +use tokio::sync::watch; +static NEXT_QUESTION: AtomicU64 = AtomicU64::new(1); +#[derive(Debug)] +pub(crate) struct Cancelled; +impl std::fmt::Display for Cancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("上传已停止,进度已保留") + } +} +impl std::error::Error for Cancelled {} + +pub(crate) struct UploadControl { + pub events: Sender, + pub cancel: watch::Receiver, + pub answers: Receiver<(u64, UploadAnswer)>, +} +impl UploadControl { + pub fn check(&self) -> Result<()> { + if *self.cancel.borrow() { + Err(Cancelled.into()) + } else { + Ok(()) + } + } + pub async fn run(&self, future: impl Future>) -> Result { + self.check()?; + let mut cancel = self.cancel.clone(); + tokio::select! { + result = future => result, + _ = async { + loop { + if *cancel.borrow_and_update() { break; } + if cancel.changed().await.is_err() { break; } + } + } => Err(Cancelled.into()) + } + } + pub async fn ask( + &self, + kind: UploadQuestionKind, + path: &str, + message: &str, + ) -> Result { + self.check()?; + let id = NEXT_QUESTION.fetch_add(1, Ordering::Relaxed); + self.events + .send(SftpEvent::Question(UploadQuestion::new( + id, kind, path, message, + ))) + .await?; + self.run(async { + loop { + let (reply_id, answer) = self.answers.recv().await?; + if reply_id == id { + return if answer.choice() == UploadChoice::Cancel { + Err(Cancelled.into()) + } else { + Ok(answer) + }; + } + } + }) + .await + } +} diff --git a/src/sftp/journal.rs b/src/sftp/journal.rs new file mode 100644 index 0000000..e92043a --- /dev/null +++ b/src/sftp/journal.rs @@ -0,0 +1,184 @@ +use super::{EntryKind, FileMetadata, RemotePath}; +use anyhow::{Context as _, Result, bail}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use std::{ + path::{Path, PathBuf}, + time::UNIX_EPOCH, +}; +use tokio::io::AsyncWriteExt as _; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SourceMetadata { + pub size: u64, + pub modified_ns: u128, +} +impl SourceMetadata { + pub async fn read(path: &Path) -> Result { + let metadata = tokio::fs::symlink_metadata(path).await?; + if !metadata.is_file() { + bail!("本地来源已不再是普通文件"); + } + Ok(Self { + size: metadata.len(), + modified_ns: metadata.modified()?.duration_since(UNIX_EPOCH)?.as_nanos(), + }) + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum PublishPhase { + Writing, + Ready, + BackingUp, + BackedUp, + Publishing, + Published, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct ResumeRecord { + pub version: u32, + pub endpoint: String, + pub host_key: String, + pub source: PathBuf, + #[serde(alias = "fingerprint")] + pub source_metadata: Option, + pub link_target: Option, + pub target: RemotePath, + pub temporary: RemotePath, + pub backup: RemotePath, + pub original: Option, + pub phase: PublishPhase, +} +impl ResumeRecord { + pub fn new( + endpoint: &str, + host_key: &str, + source: &Path, + target: RemotePath, + original: Option, + ) -> Result { + let id = uuid::Uuid::new_v4(); + let parent = target.parent(); + let temporary = RemotePath::new(format!("{}.filepart", target.as_str()))?; + Ok(Self { + version: 2, + endpoint: endpoint.into(), + host_key: host_key.into(), + source: source.into(), + source_metadata: None, + link_target: None, + temporary, + backup: parent.join(&format!(".shellr-{id}.backup"))?, + target, + original, + phase: PublishPhase::Writing, + }) + } + pub fn validate( + &self, + endpoint: &str, + host_key: &str, + source: &Path, + target: &RemotePath, + ) -> Result<()> { + if !matches!(self.version, 1 | 2) + || self.endpoint != endpoint + || self.host_key != host_key + || self.source != source + || &self.target != target + { + bail!("续传记录与当前来源或服务器不匹配"); + } + let temporary_is_valid = if self.version == 1 { + managed_uuid_path(&self.temporary, target, ".filepart") + } else { + self.temporary == RemotePath::new(format!("{}.filepart", target.as_str()))? + }; + if !temporary_is_valid || !managed_uuid_path(&self.backup, target, ".backup") { + bail!("无效的续传临时路径"); + } + if self + .original + .as_ref() + .is_some_and(|m| m.kind() == EntryKind::Directory) + { + bail!("续传记录不能替换目录"); + } + Ok(()) + } +} + +fn managed_uuid_path(path: &RemotePath, target: &RemotePath, suffix: &str) -> bool { + let name = path.as_str().rsplit('/').next().unwrap_or_default(); + name.strip_prefix(".shellr-") + .and_then(|value| value.strip_suffix(suffix)) + .and_then(|value| uuid::Uuid::parse_str(value).ok()) + .is_some() + && path.parent() == target.parent() + && path != target +} +#[derive(Clone)] +pub(crate) struct Journal { + root: PathBuf, +} +impl Journal { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + fn path(&self, endpoint: &str, source: &Path, target: &RemotePath) -> PathBuf { + let mut hash = Sha256::new(); + for part in [ + endpoint.as_bytes(), + source.as_os_str().as_encoded_bytes(), + target.as_str().as_bytes(), + ] { + hash.update((part.len() as u64).to_be_bytes()); + hash.update(part); + } + self.root.join(format!("{:x}.json", hash.finalize())) + } + pub async fn load( + &self, + endpoint: &str, + source: &Path, + target: &RemotePath, + ) -> Result> { + match tokio::fs::read(self.path(endpoint, source, target)).await { + Ok(bytes) => Ok(Some( + serde_json::from_slice(&bytes).context("续传记录损坏,原文件和临时文件均已保留")?, + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).context("无法读取续传记录"), + } + } + pub async fn save(&self, record: &ResumeRecord) -> Result<()> { + tokio::fs::create_dir_all(&self.root).await?; + let path = self.path(&record.endpoint, &record.source, &record.target); + let temp = path.with_extension("pending"); + let mut options = tokio::fs::OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options.open(&temp).await?; + file.write_all(&serde_json::to_vec(record)?).await?; + file.sync_all().await?; + drop(file); + tokio::fs::rename(&temp, &path) + .await + .context("无法保存续传进度")?; + #[cfg(unix)] + { + tokio::fs::File::open(&self.root).await?.sync_all().await?; + } + Ok(()) + } + pub async fn remove(&self, record: &ResumeRecord) -> Result<()> { + match tokio::fs::remove_file(self.path(&record.endpoint, &record.source, &record.target)) + .await + { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } +} diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs new file mode 100644 index 0000000..26cc0da --- /dev/null +++ b/src/sftp/mod.rs @@ -0,0 +1,15 @@ +//! Real file browsing and resumable SFTP uploads. No GPUI or workspace dependencies. +mod model; +mod transport; +pub use model::*; +pub use transport::*; +mod client; +mod control; +mod journal; +mod upload; +mod worker; +pub use worker::SshSftpTransportProvider; +#[cfg(all(test, unix))] +mod protocol_tests; +#[cfg(test)] +mod tests; diff --git a/src/sftp/model.rs b/src/sftp/model.rs new file mode 100644 index 0000000..08d186e --- /dev/null +++ b/src/sftp/model.rs @@ -0,0 +1,350 @@ +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// A server path. Never interpreted using the client operating system's rules. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RemotePath(String); +impl RemotePath { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if path.is_empty() || path.contains('\0') { + bail!("远程路径不能为空或包含空字符"); + } + Ok(Self(path)) + } + pub fn as_str(&self) -> &str { + &self.0 + } + pub fn join(&self, name: &str) -> Result { + if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\0']) { + bail!("无效的文件名:{name}"); + } + Self::new(format!("{}/{name}", self.0.trim_end_matches('/'))) + } + pub fn parent(&self) -> Self { + let path = self.0.trim_end_matches('/'); + Self( + path.rsplit_once('/') + .map(|(parent, _)| if parent.is_empty() { "/" } else { parent }) + .unwrap_or("/") + .to_owned(), + ) + } + pub fn is_root(&self) -> bool { + self.0 == "/" + } +} +impl std::fmt::Display for RemotePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntryKind { + File, + Directory, + Symlink, + Other, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileMetadata { + kind: EntryKind, + size: u64, + modified: Option, + permissions: Option, +} +impl FileMetadata { + pub fn new( + kind: EntryKind, + size: u64, + modified: Option, + permissions: Option, + ) -> Self { + Self { + kind, + size, + modified, + permissions, + } + } + pub fn kind(&self) -> EntryKind { + self.kind + } + pub fn size(&self) -> u64 { + self.size + } + pub fn modified(&self) -> Option { + self.modified + } + pub fn permissions(&self) -> Option { + self.permissions + } +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectoryEntry { + name: String, + metadata: FileMetadata, +} +impl DirectoryEntry { + pub fn new(name: impl Into, metadata: FileMetadata) -> Self { + Self { + name: name.into(), + metadata, + } + } + pub fn name(&self) -> &str { + &self.name + } + pub fn metadata(&self) -> &FileMetadata { + &self.metadata + } +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectoryListing { + path: String, + entries: Vec, +} +impl DirectoryListing { + pub fn new(path: impl Into, entries: Vec) -> Self { + Self { + path: path.into(), + entries, + } + } + pub fn path(&self) -> &str { + &self.path + } + pub fn entries(&self) -> &[DirectoryEntry] { + &self.entries + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UploadRequest { + sources: Vec, + destination: RemotePath, +} +impl UploadRequest { + pub fn new(sources: Vec, destination: RemotePath) -> Result { + if sources.is_empty() { + bail!("请选择要上传的文件或目录"); + } + Ok(Self { + sources, + destination, + }) + } + pub fn sources(&self) -> &[PathBuf] { + &self.sources + } + pub fn destination(&self) -> &RemotePath { + &self.destination + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UploadPhase { + Scanning, + Uploading, + Waiting, + Reconnecting, + Stopped, + Completed, +} +#[derive(Clone, Debug, PartialEq)] +pub struct UploadProgress { + pub(crate) phase: UploadPhase, + pub(crate) current: String, + pub(crate) completed_bytes: u64, + pub(crate) total_bytes: u64, + pub(crate) total: usize, + pub(crate) succeeded: usize, + pub(crate) skipped: usize, + pub(crate) failed: usize, + pub(crate) bytes_per_second: u64, + pub(crate) details: Vec, +} +impl Default for UploadProgress { + fn default() -> Self { + Self { + phase: UploadPhase::Scanning, + current: String::new(), + completed_bytes: 0, + total_bytes: 0, + total: 0, + succeeded: 0, + skipped: 0, + failed: 0, + bytes_per_second: 0, + details: Vec::new(), + } + } +} +impl UploadProgress { + pub fn new(phase: UploadPhase) -> Self { + Self { + phase, + ..Self::default() + } + } + pub fn phase(&self) -> UploadPhase { + self.phase + } + pub fn current(&self) -> &str { + &self.current + } + pub fn completed_bytes(&self) -> u64 { + self.completed_bytes + } + pub fn total_bytes(&self) -> u64 { + self.total_bytes + } + pub fn total(&self) -> usize { + self.total + } + pub fn succeeded(&self) -> usize { + self.succeeded + } + pub fn skipped(&self) -> usize { + self.skipped + } + pub fn failed(&self) -> usize { + self.failed + } + pub fn bytes_per_second(&self) -> u64 { + self.bytes_per_second + } + pub fn details(&self) -> &[String] { + &self.details + } + pub fn is_active(&self) -> bool { + !matches!(self.phase, UploadPhase::Stopped | UploadPhase::Completed) + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UploadQuestionKind { + Conflict, + Resume, + InvalidResume, + Error, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UploadQuestion { + id: u64, + kind: UploadQuestionKind, + path: String, + message: String, +} +impl UploadQuestion { + pub fn new( + id: u64, + kind: UploadQuestionKind, + path: impl Into, + message: impl Into, + ) -> Self { + Self { + id, + kind, + path: path.into(), + message: message.into(), + } + } + pub fn id(&self) -> u64 { + self.id + } + pub fn kind(&self) -> UploadQuestionKind { + self.kind + } + pub fn path(&self) -> &str { + &self.path + } + pub fn message(&self) -> &str { + &self.message + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +pub enum UploadChoice { + Overwrite, + Skip, + Cancel, + Retry, + Resume, + Restart, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +pub struct UploadAnswer { + choice: UploadChoice, + apply_to_all: bool, +} +impl UploadAnswer { + pub fn new(choice: UploadChoice, apply_to_all: bool) -> Self { + Self { + choice, + apply_to_all, + } + } + pub fn choice(&self) -> UploadChoice { + self.choice + } + pub fn apply_to_all(&self) -> bool { + self.apply_to_all + } +} + +/// Local I/O is blocking and must be called on a background executor. +pub trait LocalDirectoryProvider: Send + Sync + 'static { + fn home(&self) -> PathBuf; + fn list(&self, path: &Path) -> Result; +} +pub type SharedLocalDirectoryProvider = std::sync::Arc; +#[derive(Default)] +pub struct SystemLocalDirectoryProvider; +impl LocalDirectoryProvider for SystemLocalDirectoryProvider { + fn home(&self) -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) + } + fn list(&self, path: &Path) -> Result { + let path = std::fs::canonicalize(path)?; + let mut entries = Vec::new(); + for entry in std::fs::read_dir(&path)? { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("目录包含无法用 UTF-8 表示的文件名"))?; + let metadata = std::fs::symlink_metadata(entry.path())?; + entries.push(DirectoryEntry::new(name, local_metadata(&metadata))); + } + Ok(DirectoryListing::new( + path.to_str() + .ok_or_else(|| anyhow::anyhow!("路径不是有效的 UTF-8"))?, + entries, + )) + } +} +pub(crate) fn local_metadata(metadata: &std::fs::Metadata) -> FileMetadata { + let kind = if metadata.is_symlink() { + EntryKind::Symlink + } else if metadata.is_dir() { + EntryKind::Directory + } else if metadata.is_file() { + EntryKind::File + } else { + EntryKind::Other + }; + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|d| u32::try_from(d.as_secs()).ok()); + #[cfg(unix)] + let permissions = { + use std::os::unix::fs::PermissionsExt; + Some(metadata.permissions().mode()) + }; + #[cfg(not(unix))] + let permissions = None; + FileMetadata::new(kind, metadata.len(), modified, permissions) +} diff --git a/src/sftp/protocol_tests.rs b/src/sftp/protocol_tests.rs new file mode 100644 index 0000000..0c893f3 --- /dev/null +++ b/src/sftp/protocol_tests.rs @@ -0,0 +1,404 @@ +//! Loopback-only SSH/SFTP worker tests. No user hosts, credentials or shell. +use super::*; +use crate::{ + connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret}, + secrets::InMemorySecretStore, + session::{AuthKind, Session, SessionDraft, SessionId}, + ssh::SshConnector, +}; +use russh::{ + ChannelId, + server::{self, Server as _}, +}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + +struct TestServer { + directory: PathBuf, + connections: Arc, + shells: Arc, + interrupts: Arc, + unsupported: bool, +} +struct Handler { + directory: PathBuf, + shells: Arc, + interrupts: Arc, + unsupported: bool, + channels: HashMap>, +} +impl server::Server for TestServer { + type Handler = Handler; + fn new_client(&mut self, _: Option) -> Handler { + self.connections.fetch_add(1, Ordering::SeqCst); + Handler { + directory: self.directory.clone(), + shells: self.shells.clone(), + interrupts: self.interrupts.clone(), + unsupported: self.unsupported, + channels: HashMap::new(), + } + } +} +impl server::Handler for Handler { + type Error = anyhow::Error; + async fn auth_password( + &mut self, + user: &str, + password: &str, + ) -> Result { + Ok(if user == "tester" && password == "fixture-password" { + server::Auth::Accept + } else { + server::Auth::reject() + }) + } + async fn channel_open_session( + &mut self, + channel: russh::Channel, + reply: server::ChannelOpenHandle, + _: &mut server::Session, + ) -> Result<(), Self::Error> { + self.channels.insert(channel.id(), channel); + reply.accept().await; + Ok(()) + } + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut server::Session, + ) -> Result<(), Self::Error> { + self.shells.fetch_add(1, Ordering::SeqCst); + session.channel_failure(channel)?; + Ok(()) + } + async fn subsystem_request( + &mut self, + channel: ChannelId, + name: &str, + session: &mut server::Session, + ) -> Result<(), Self::Error> { + if self.unsupported || name != "sftp" { + session.channel_failure(channel)?; + session.close(channel)?; + return Ok(()); + } + session.channel_success(channel)?; + let stream = self.channels.remove(&channel).unwrap().into_stream(); + let directory = self.directory.clone(); + let interrupts = self.interrupts.clone(); + tokio::spawn(async move { + let executable = if cfg!(target_os = "macos") { + "/usr/libexec/sftp-server" + } else { + "/usr/lib/openssh/sftp-server" + }; + let mut child = tokio::process::Command::new(executable) + .current_dir(directory) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + let mut output = child.stdout.take().unwrap(); + let (mut reader, mut writer) = tokio::io::split(stream); + let forward = async { + let mut writes = 0; + loop { + let size = reader.read_u32().await?; + if size > 256 * 1024 { + return Err::<(), _>(std::io::Error::other("invalid frame")); + } + let mut bytes = vec![0; size as usize]; + reader.read_exact(&mut bytes).await?; + if bytes.first() == Some(&6) { + writes += 1; + if writes == 2 + && interrupts + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| { + n.checked_sub(1) + }) + .is_ok() + { + return Err::<(), _>(std::io::Error::other("injected disconnect")); + } + } + input.write_u32(size).await?; + input.write_all(&bytes).await?; + input.flush().await?; + } + }; + tokio::select! { _ = forward=>{}, _=tokio::io::copy(&mut output,&mut writer)=>{} } + child.kill().await.ok(); + child.wait().await.ok(); + }); + Ok(()) + } +} +struct Running { + port: u16, + connections: Arc, + shells: Arc, + interrupts: Arc, + task: tokio::task::JoinHandle<()>, +} +impl Drop for Running { + fn drop(&mut self) { + self.task.abort(); + } +} +async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Option { + let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await { + Ok(l) => l, + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + eprintln!("loopback unavailable; run this test with local socket permission"); + return None; + } + Err(e) => panic!("{e}"), + }; + let port = listener.local_addr().unwrap().port(); + let connections = Arc::new(AtomicUsize::new(0)); + let shells = Arc::new(AtomicUsize::new(0)); + let interrupts = Arc::new(AtomicUsize::new(interrupts)); + let mut server = TestServer { + directory, + connections: connections.clone(), + shells: shells.clone(), + interrupts: interrupts.clone(), + unsupported, + }; + let key = russh::keys::PrivateKey::random( + &mut russh::keys::key::safe_rng(), + russh::keys::Algorithm::Ed25519, + ) + .unwrap(); + let config = Arc::new(server::Config { + methods: russh::MethodSet::from(&[russh::MethodKind::Password][..]), + keys: vec![key], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + ..Default::default() + }); + let task = tokio::spawn(async move { + server.run_on_socket(config, &listener).await.unwrap(); + }); + Some(Running { + port, + connections, + shells, + interrupts, + task, + }) +} +struct Worker { + commands: async_channel::Sender, + events: async_channel::Receiver, + thread: Option>, +} +impl Drop for Worker { + fn drop(&mut self) { + let _ = self.commands.try_send(SftpCommand::Shutdown); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} +fn worker(port: u16, data: &std::path::Path) -> Worker { + let provider = SshSftpTransportProvider::new( + SshConnector::new( + data.join("known_hosts"), + Arc::new(InMemorySecretStore::default()), + ), + data.join("upload-resume"), + ); + let session = Session::new( + SessionId(1), + SessionDraft::new( + "fixture", + "127.0.0.1", + port, + "tester", + AuthKind::Password, + None, + ), + ); + let transport = provider.create(&session); + let (commands, rx) = async_channel::unbounded(); + let (tx, events) = async_channel::unbounded(); + let thread = std::thread::spawn(move || transport.run(rx, tx).unwrap()); + Worker { + commands, + events, + thread: Some(thread), + } +} +async fn next(worker: &Worker) -> SftpEvent { + loop { + let event = tokio::time::timeout(Duration::from_secs(25), worker.events.recv()) + .await + .expect("worker timed out") + .unwrap(); + if let SftpEvent::Prompt(p) = event { + let reply = match p.kind() { + ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave, + ConnectionPromptKind::Authentication(_) => { + ConnectionPromptReply::Answers(vec![ConnectionSecret::new("fixture-password")]) + } + _ => panic!("unexpected trust change"), + }; + worker + .commands + .send(SftpCommand::PromptReply { + request_id: p.request_id(), + reply, + }) + .await + .unwrap(); + } else { + return event; + } + } +} +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() +} + +#[test] +fn worker_uses_sftp_without_shell_and_reconnects_after_interrupted_write() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let remote = temp.path().join("remote"); + std::fs::create_dir(&remote).unwrap(); + let Some(server) = server(remote.clone(), 1, false).await else { + return; + }; + let worker = worker(server.port, temp.path()); + let home = loop { + match next(&worker).await { + SftpEvent::Connected { home } => break home, + SftpEvent::Disconnected(e) => panic!("connect failed: {e}"), + _ => {} + } + }; + let source = temp.path().join("文件.bin"); + let data = vec![42; 300_007]; + std::fs::write(&source, &data).unwrap(); + worker + .commands + .send(SftpCommand::Upload( + UploadRequest::new(vec![source], home).unwrap(), + )) + .await + .unwrap(); + let mut reconnecting = false; + loop { + match next(&worker).await { + SftpEvent::Progress(p) if p.phase() == UploadPhase::Completed => { + assert_eq!(p.succeeded(), 1); + break; + } + SftpEvent::Progress(p) if p.phase() == UploadPhase::Stopped => { + panic!("stopped unexpectedly: {p:?}") + } + SftpEvent::Progress(p) if p.phase() == UploadPhase::Reconnecting => { + reconnecting = true + } + SftpEvent::Question(q) => panic!("unexpected question: {q:?}"), + _ => {} + } + } + assert!(reconnecting); + assert_eq!(server.connections.load(Ordering::SeqCst), 2); + assert_eq!(server.shells.load(Ordering::SeqCst), 0); + assert_eq!(std::fs::read(remote.join("文件.bin")).unwrap(), data); + }); +} + +#[test] +fn unsupported_sftp_does_not_launch_shell_or_loop_authentication() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let Some(server) = server(temp.path().into(), 0, true).await else { + return; + }; + let worker = worker(server.port, temp.path()); + loop { + match next(&worker).await { + SftpEvent::Disconnected(_) => break, + SftpEvent::Connected { .. } => panic!("unsupported subsystem connected"), + _ => {} + } + } + assert_eq!(server.connections.load(Ordering::SeqCst), 1); + assert_eq!(server.shells.load(Ordering::SeqCst), 0); + }); +} + +#[test] +fn reconnect_exhaustion_waits_for_manual_resume_and_resets_budget() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let remote = temp.path().join("remote"); + std::fs::create_dir(&remote).unwrap(); + let Some(server) = server(remote.clone(), 4, false).await else { + return; + }; + let worker = worker(server.port, temp.path()); + let home = loop { + if let SftpEvent::Connected { home } = next(&worker).await { + break home; + } + }; + let source = temp.path().join("file"); + std::fs::write(&source, vec![1; 200_000]).unwrap(); + worker + .commands + .send(SftpCommand::Upload( + UploadRequest::new(vec![source], home).unwrap(), + )) + .await + .unwrap(); + loop { + match next(&worker).await { + SftpEvent::Progress(p) if p.phase() == UploadPhase::Stopped => break, + SftpEvent::Progress(p) if p.phase() == UploadPhase::Completed => { + panic!("exhaustion should stop") + } + SftpEvent::Question(q) => panic!("unexpected question {q:?}"), + _ => {} + } + } + assert_eq!(server.connections.load(Ordering::SeqCst), 4); + // The next manual run has its own three-retry budget; interrupt once again. + server.interrupts.store(1, Ordering::SeqCst); + worker.commands.send(SftpCommand::Resume).await.unwrap(); + loop { + match next(&worker).await { + SftpEvent::Progress(p) if p.phase() == UploadPhase::Completed => break, + SftpEvent::Progress(p) if p.phase() == UploadPhase::Stopped => { + panic!("manual retry failed") + } + SftpEvent::Question(q) => panic!("unexpected question {q:?}"), + _ => {} + } + } + assert_eq!(server.connections.load(Ordering::SeqCst), 6); + assert_eq!( + std::fs::read(remote.join("file")).unwrap(), + vec![1; 200_000] + ); + }); +} diff --git a/src/sftp/tests.rs b/src/sftp/tests.rs new file mode 100644 index 0000000..3b4b235 --- /dev/null +++ b/src/sftp/tests.rs @@ -0,0 +1,857 @@ +use super::{ + client::RemoteFs, + control::UploadControl, + journal::{Journal, SourceMetadata}, + upload::UploadBatch, + *, +}; +use anyhow::{Result, bail}; +use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + path::Path, + sync::{Arc, Mutex}, +}; +use tokio::sync::watch; + +#[derive(Clone)] +struct Node { + metadata: FileMetadata, + data: Vec, + link: String, +} +#[derive(Clone, Copy, PartialEq)] +enum Fault { + Write(usize), + Rename(usize), + Close, + RejectRename(usize), + ChangeTargetOnClose, + RemoveTargetOnClose, +} +struct Remote { + nodes: RefCell>, + fault: Cell>, + in_flight: Cell, + max_in_flight: Cell, + writes: Cell, + renames: Cell, + atomic: bool, + denied: RefCell>, +} +impl Remote { + fn new(atomic: bool) -> Self { + Self { + nodes: RefCell::new(BTreeMap::new()), + fault: Cell::new(None), + in_flight: Cell::new(0), + max_in_flight: Cell::new(0), + writes: Cell::new(0), + renames: Cell::new(0), + atomic, + denied: RefCell::new(None), + } + } + fn file(&self, path: &str, bytes: &[u8]) { + self.nodes.borrow_mut().insert( + path.into(), + Node { + metadata: FileMetadata::new( + EntryKind::File, + bytes.len() as u64, + Some(100), + Some(0o640), + ), + data: bytes.to_vec(), + link: String::new(), + }, + ); + } + fn bytes(&self, path: &str) -> Vec { + self.nodes.borrow()[path].data.clone() + } + fn fail(&self, fault: Fault) -> Result<()> { + if self.fault.get() == Some(fault) { + self.fault.set(None); + return Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "injected lost reply", + ) + .into()); + } + Ok(()) + } +} +struct WriteGuard<'a>(&'a Cell); +impl Drop for WriteGuard<'_> { + fn drop(&mut self) { + self.0.set(self.0.get().saturating_sub(1)); + } +} +impl RemoteFs for Remote { + async fn metadata(&self, path: &RemotePath) -> Result> { + Ok(self + .nodes + .borrow() + .get(path.as_str()) + .map(|n| n.metadata.clone())) + } + async fn open(&self, path: &RemotePath, create: bool) -> Result { + if create { + if self.nodes.borrow().contains_key(path.as_str()) { + bail!("exclusive create failed"); + } + self.file(path.as_str(), &[]); + } else if !self.nodes.borrow().contains_key(path.as_str()) { + bail!("file absent"); + } + Ok(path.to_string()) + } + async fn write(&self, handle: &str, offset: u64, bytes: Vec) -> Result<()> { + let in_flight = self.in_flight.get() + 1; + self.in_flight.set(in_flight); + self.max_in_flight + .set(self.max_in_flight.get().max(in_flight)); + let _guard = WriteGuard(&self.in_flight); + tokio::task::yield_now().await; + let mut nodes = self.nodes.borrow_mut(); + let node = nodes.get_mut(handle).unwrap(); + let end = offset as usize + bytes.len(); + node.data.resize(end.max(node.data.len()), 0); + node.data[offset as usize..end].copy_from_slice(&bytes); + node.metadata = FileMetadata::new( + EntryKind::File, + node.data.len() as u64, + node.metadata.modified(), + node.metadata.permissions(), + ); + self.writes.set(self.writes.get() + 1); + self.fail(Fault::Write(self.writes.get())) + } + async fn sync(&self, _: &str) -> Result<()> { + Ok(()) + } + async fn close(&self, _: &str) -> Result<()> { + if self.fault.get() == Some(Fault::ChangeTargetOnClose) { + self.fault.set(None); + self.file("/dest/file", b"external mutation"); + } + if self.fault.get() == Some(Fault::RemoveTargetOnClose) { + self.fault.set(None); + self.nodes.borrow_mut().remove("/dest/file"); + } + self.fail(Fault::Close) + } + async fn attributes( + &self, + handle: &str, + modified: Option, + permissions: Option, + ) -> Result<()> { + let mut nodes = self.nodes.borrow_mut(); + let n = nodes.get_mut(handle).unwrap(); + n.metadata = FileMetadata::new( + EntryKind::File, + n.data.len() as u64, + modified, + permissions.or(Some(0o644)), + ); + Ok(()) + } + async fn mkdir(&self, path: &RemotePath) -> Result<()> { + if self.denied.borrow().as_deref() == Some(path.as_str()) { + bail!("permission denied"); + } + self.nodes.borrow_mut().insert( + path.to_string(), + Node { + metadata: FileMetadata::new(EntryKind::Directory, 0, None, Some(0o755)), + data: vec![], + link: String::new(), + }, + ); + Ok(()) + } + async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()> { + self.nodes.borrow_mut().insert( + path.to_string(), + Node { + metadata: FileMetadata::new(EntryKind::Symlink, 0, None, None), + data: vec![], + link: target.into(), + }, + ); + Ok(()) + } + async fn readlink(&self, path: &RemotePath) -> Result { + Ok(self.nodes.borrow()[path.as_str()].link.clone()) + } + async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> { + if self.fault.get() == Some(Fault::RejectRename(self.renames.get() + 1)) { + self.fault.set(None); + bail!("publish denied"); + } + let mut nodes = self.nodes.borrow_mut(); + if !replace && nodes.contains_key(to.as_str()) { + bail!("target exists"); + } + let node = nodes + .remove(from.as_str()) + .ok_or_else(|| anyhow::anyhow!("rename source absent"))?; + nodes.insert(to.to_string(), node); + self.renames.set(self.renames.get() + 1); + self.fail(Fault::Rename(self.renames.get())) + } + async fn remove(&self, path: &RemotePath) -> Result<()> { + self.nodes.borrow_mut().remove(path.as_str()); + Ok(()) + } + fn atomic_replace(&self) -> bool { + self.atomic + } +} +struct Answers { + control: UploadControl, + cancel: watch::Sender, + questions: Arc>>, + progress: Arc>>, + task: tokio::task::JoinHandle<()>, +} +impl Answers { + fn new(choices: Vec) -> Self { + let (events, receiver) = async_channel::unbounded(); + let (answers, rx) = async_channel::unbounded(); + let (cancel, watch) = watch::channel(false); + let questions = Arc::new(Mutex::new(vec![])); + let seen_questions = questions.clone(); + let progress = Arc::new(Mutex::new(vec![])); + let seen_progress = progress.clone(); + let task = tokio::spawn(async move { + let mut choices = choices.into_iter(); + while let Ok(event) = receiver.recv().await { + match event { + SftpEvent::Question(q) => { + seen_questions.lock().unwrap().push(q.kind()); + let _ = answers + .send(( + q.id(), + UploadAnswer::new( + choices.next().unwrap_or(UploadChoice::Cancel), + false, + ), + )) + .await; + } + SftpEvent::Progress(snapshot) => { + seen_progress.lock().unwrap().push(snapshot); + } + _ => {} + } + } + }); + Self { + control: UploadControl { + events, + cancel: watch, + answers: rx, + }, + cancel, + questions, + progress, + task, + } + } +} +impl Drop for Answers { + fn drop(&mut self) { + self.task.abort(); + } +} +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() +} +async fn batch(source: &Path, journal: Journal, control: &UploadControl) -> UploadBatch { + let request = + UploadRequest::new(vec![source.into()], RemotePath::new("/dest").unwrap()).unwrap(); + UploadBatch::scan( + &request, + &format!("test@{}", source.display()), + "host-key", + journal, + control, + ) + .await + .unwrap() +} +async fn run( + batch: &mut UploadBatch, + remote: &impl RemoteFs, + control: &UploadControl, +) -> Result<()> { + tokio::time::timeout( + std::time::Duration::from_secs(10), + batch.run(remote, control), + ) + .await + .expect("upload did not finish") +} + +#[test] +fn recursive_upload_deduplicates_sources_and_preserves_empty_directories_and_links() { + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let root = tmp.path().join("中文 目录"); + std::fs::create_dir_all(root.join("空/嵌套")).unwrap(); + std::fs::write(root.join("零 字节"), []).unwrap(); + let data = vec![47; 2 * 1024 * 1024 + 13]; + std::fs::write(root.join("large.bin"), &data).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink("../不存在", root.join("链接")).unwrap(); + let answers = Answers::new(vec![]); + let request = UploadRequest::new( + vec![root.clone(), root.join("large.bin"), root.clone()], + RemotePath::new("/dest").unwrap(), + ) + .unwrap(); + let mut batch = UploadBatch::scan( + &request, + "recursive", + "key", + Journal::new(tmp.path().join("journal")), + &answers.control, + ) + .await + .unwrap(); + let remote = Remote::new(true); + run(&mut batch, &remote, &answers.control).await.unwrap(); + assert_eq!(remote.bytes("/dest/中文 目录/large.bin"), data); + assert_eq!(remote.bytes("/dest/中文 目录/零 字节"), Vec::::new()); + assert!( + remote + .metadata(&RemotePath::new("/dest/中文 目录/空/嵌套").unwrap()) + .await + .unwrap() + .is_some() + ); + #[cfg(unix)] + assert_eq!( + remote + .readlink(&RemotePath::new("/dest/中文 目录/链接").unwrap()) + .await + .unwrap(), + "../不存在" + ); + assert_eq!(batch.progress.failed(), 0); + assert!(answers.questions.lock().unwrap().is_empty()); + assert!( + std::fs::read_dir(tmp.path().join("journal")) + .unwrap() + .next() + .is_none() + ); + }); +} + +#[test] +fn winscp_style_upload_finishes_without_a_verification_phase() { + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("file"); + let data = vec![73; 2 * 1024 * 1024 + 17]; + std::fs::write(&source, &data).unwrap(); + let answers = Answers::new(vec![]); + let mut batch = batch( + &source, + Journal::new(tmp.path().join("journal")), + &answers.control, + ) + .await; + let remote = Remote::new(true); + + run(&mut batch, &remote, &answers.control).await.unwrap(); + tokio::task::yield_now().await; + + assert_eq!(remote.bytes("/dest/file"), data); + assert!(remote.max_in_flight.get() > 1); + assert_eq!(batch.progress.succeeded(), 1); + let progress = answers.progress.lock().unwrap(); + assert!(progress.iter().all(|snapshot| matches!( + snapshot.phase(), + UploadPhase::Uploading | UploadPhase::Completed + ))); + }); +} + +#[test] +fn resume_uses_remote_filepart_size_and_preserves_old_target() { + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("file"); + let data = vec![71; 150_000]; + std::fs::write(&source, &data).unwrap(); + let journal = Journal::new(tmp.path().join("journal")); + let remote = Remote::new(false); + remote.file("/dest/file", b"old file intact"); + remote.fault.set(Some(Fault::Write(2))); + let answers = Answers::new(vec![UploadChoice::Overwrite]); + let mut first = batch(&source, journal.clone(), &answers.control).await; + assert!(super::client::is_network_error( + &run(&mut first, &remote, &answers.control) + .await + .unwrap_err() + )); + assert_eq!(remote.bytes("/dest/file"), b"old file intact"); + let endpoint = format!("test@{}", source.display()); + let target = RemotePath::new("/dest/file").unwrap(); + let record = journal + .load(&endpoint, &source, &target) + .await + .unwrap() + .unwrap(); + remote.file(record.temporary.as_str(), &data[..65_536]); + let answers = Answers::new(vec![UploadChoice::Resume]); + let mut second = batch(&source, journal.clone(), &answers.control).await; + run(&mut second, &remote, &answers.control).await.unwrap(); + assert_eq!(remote.bytes("/dest/file"), data); + assert!( + journal + .load(&endpoint, &source, &target) + .await + .unwrap() + .is_none() + ); + assert_eq!( + remote + .metadata(&target) + .await + .unwrap() + .unwrap() + .permissions(), + Some(0o640) + ); + assert_eq!(second.progress.succeeded(), 1); + }); +} + +#[test] +fn filepart_larger_than_source_requires_explicit_restart() { + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("file"); + std::fs::write(&source, vec![1; 100_000]).unwrap(); + let journal = Journal::new(tmp.path().join("journal")); + let remote = Remote::new(true); + remote.fault.set(Some(Fault::Write(2))); + let answers = Answers::new(vec![]); + let mut first = batch(&source, journal.clone(), &answers.control).await; + assert!(run(&mut first, &remote, &answers.control).await.is_err()); + std::fs::write(&source, vec![2; 32_000]).unwrap(); + let target = RemotePath::new("/dest/file").unwrap(); + let answers = Answers::new(vec![UploadChoice::Resume, UploadChoice::Skip]); + let mut next = batch(&source, journal, &answers.control).await; + run(&mut next, &remote, &answers.control).await.unwrap(); + assert!(remote.metadata(&target).await.unwrap().is_none()); + assert_eq!( + *answers.questions.lock().unwrap(), + vec![ + UploadQuestionKind::Resume, + UploadQuestionKind::InvalidResume + ] + ); + assert_eq!(next.progress.skipped(), 1); + }); +} + +#[test] +fn winscp_style_resume_does_not_prevalidate_the_source_version() { + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("file"); + let data = vec![1; 100_000]; + std::fs::write(&source, &data).unwrap(); + let journal = Journal::new(tmp.path().join("journal")); + let remote = Remote::new(true); + remote.fault.set(Some(Fault::Write(2))); + let answers = Answers::new(vec![]); + let mut first = batch(&source, journal.clone(), &answers.control).await; + assert!(run(&mut first, &remote, &answers.control).await.is_err()); + let endpoint = format!("test@{}", source.display()); + let target = RemotePath::new("/dest/file").unwrap(); + let record = journal + .load(&endpoint, &source, &target) + .await + .unwrap() + .unwrap(); + let old_prefix = remote.bytes(record.temporary.as_str()); + std::fs::write(&source, vec![2; 100_000]).unwrap(); + let answers = Answers::new(vec![UploadChoice::Resume]); + let mut resumed = batch(&source, journal, &answers.control).await; + + run(&mut resumed, &remote, &answers.control).await.unwrap(); + + let mut expected = vec![2; 100_000]; + expected[..old_prefix.len()].copy_from_slice(&old_prefix); + assert_eq!(remote.bytes("/dest/file"), expected); + assert_eq!( + *answers.questions.lock().unwrap(), + vec![UploadQuestionKind::Resume] + ); + assert_eq!(resumed.progress.succeeded(), 1); + }); +} + +#[test] +fn winscp_style_resume_detects_filepart_without_a_local_record() { + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("file"); + let data = vec![3; 100_000]; + std::fs::write(&source, &data).unwrap(); + let remote = Remote::new(true); + remote.file("/dest/file.filepart", &data[..32_768]); + let answers = Answers::new(vec![UploadChoice::Resume]); + let mut upload = batch( + &source, + Journal::new(tmp.path().join("journal")), + &answers.control, + ) + .await; + + run(&mut upload, &remote, &answers.control).await.unwrap(); + + assert_eq!(remote.bytes("/dest/file"), data); + assert_eq!( + *answers.questions.lock().unwrap(), + vec![UploadQuestionKind::Resume] + ); + assert_eq!(upload.progress.succeeded(), 1); + }); +} + +#[test] +fn lost_replies_at_backup_publish_and_close_are_reconciled() { + runtime().block_on(async { + for (atomic, fault) in [ + (true, Fault::Rename(1)), + (false, Fault::Rename(1)), + (false, Fault::Rename(2)), + (false, Fault::Close), + ] { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("file"); + std::fs::write(&source, b"new contents").unwrap(); + let journal = Journal::new(tmp.path().join("journal")); + let remote = Remote::new(atomic); + remote.file("/dest/file", b"original"); + remote.fault.set(Some(fault)); + let answers = Answers::new(vec![UploadChoice::Overwrite]); + let mut first = batch(&source, journal.clone(), &answers.control).await; + assert!(run(&mut first, &remote, &answers.control).await.is_err()); + let answers = Answers::new(vec![UploadChoice::Resume]); + let mut resumed = batch(&source, journal.clone(), &answers.control).await; + run(&mut resumed, &remote, &answers.control).await.unwrap(); + assert_eq!(remote.bytes("/dest/file"), b"new contents"); + assert_eq!(remote.nodes.borrow().len(), 1); + assert_eq!(resumed.progress.succeeded(), 1); + } + }); +} + +#[test] +fn cancellation_retains_journal_and_skip_does_not_overwrite() { + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("file"); + std::fs::write(&source, b"new").unwrap(); + let remote = Remote::new(true); + remote.file("/dest/file", b"old"); + let answers = Answers::new(vec![UploadChoice::Skip]); + let journal = Journal::new(tmp.path().join("journal")); + let mut skipped = batch(&source, journal.clone(), &answers.control).await; + run(&mut skipped, &remote, &answers.control).await.unwrap(); + assert_eq!(remote.bytes("/dest/file"), b"old"); + assert_eq!(skipped.progress.skipped(), 1); + let answers = Answers::new(vec![UploadChoice::Overwrite]); + let mut canceled = batch(&source, journal, &answers.control).await; + answers.cancel.send_replace(true); + assert!( + run(&mut canceled, &remote, &answers.control) + .await + .unwrap_err() + .is::() + ); + assert_eq!(remote.bytes("/dest/file"), b"old"); + }); +} + +#[test] +fn directory_permission_error_is_reported_as_failure() { + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("blocked"); + std::fs::create_dir(&source).unwrap(); + let remote = Remote::new(true); + *remote.denied.borrow_mut() = Some("/dest/blocked".into()); + let answers = Answers::new(vec![UploadChoice::Skip]); + let mut batch = batch( + &source, + Journal::new(tmp.path().join("journal")), + &answers.control, + ) + .await; + run(&mut batch, &remote, &answers.control).await.unwrap(); + assert_eq!(batch.progress.failed(), 1); + assert_eq!(batch.progress.succeeded(), 0); + assert!(batch.progress.details()[0].contains("permission denied")); + }); +} + +#[test] +fn paths_and_journals_reject_unsafe_identity() { + runtime().block_on(async { + assert!(RemotePath::new("bad\0name").is_err()); + assert!(RemotePath::new("/dest").unwrap().join("../file").is_err()); + assert_eq!( + RemotePath::new("/目录\\name/file") + .unwrap() + .parent() + .as_str(), + "/目录\\name" + ); + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = tmp.path().join("file"); + std::fs::write(&source, b"data").unwrap(); + let source_metadata = SourceMetadata::read(&source).await.unwrap(); + assert_eq!(source_metadata.size, 4); + let mut record = super::journal::ResumeRecord::new( + "endpoint", + "key", + &source, + RemotePath::new("/dest/file").unwrap(), + None, + ) + .unwrap(); + record.source_metadata = Some(source_metadata); + assert_eq!(record.version, 2); + assert_eq!(record.temporary.as_str(), "/dest/file.filepart"); + assert!( + record + .validate("endpoint", "changed key", &source, &record.target) + .is_err() + ); + record.temporary = RemotePath::new("/other/.shellr-evil.filepart").unwrap(); + assert!( + record + .validate("endpoint", "key", &source, &record.target) + .is_err() + ); + }); +} + +#[cfg(target_os = "linux")] +#[test] +fn local_listing_rejects_unrepresentable_names() { + use std::os::unix::ffi::OsStringExt as _; + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + std::fs::write( + tmp.path().join(std::ffi::OsString::from_vec(vec![0xff])), + [], + ) + .unwrap(); + assert!(SystemLocalDirectoryProvider.list(tmp.path()).is_err()); +} + +#[cfg(unix)] +#[test] +fn openssh_protocol_uploads_offsets_links_metadata_and_replaces_readonly_targets() { + runtime().block_on(async { + use std::os::unix::fs::PermissionsExt as _; + let temp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let dest = temp.path().join("remote"); + let source = temp.path().join("中文 目录"); + std::fs::create_dir_all(source.join("空目录/嵌套")).unwrap(); + std::fs::create_dir(&dest).unwrap(); + let data = vec![28; 1024 * 1024 + 7]; + std::fs::write(source.join("文件.bin"), &data).unwrap(); + std::fs::write(source.join("零字节"), []).unwrap(); + std::os::unix::fs::symlink("文件.bin", source.join("链接")).unwrap(); + let (client, mut process) = super::client::SftpClient::local_test_server(&dest) + .await + .unwrap(); + let target = client + .canonicalize(&RemotePath::new(".").unwrap()) + .await + .unwrap(); + let answers = Answers::new(vec![]); + let request = UploadRequest::new(vec![source.clone()], target.clone()).unwrap(); + let journal = Journal::new(temp.path().join("journal")); + let mut upload = UploadBatch::scan( + &request, + "local-protocol", + client.fingerprint(), + journal.clone(), + &answers.control, + ) + .await + .unwrap(); + run(&mut upload, &client, &answers.control).await.unwrap(); + assert_eq!( + std::fs::read(dest.join("中文 目录/文件.bin")).unwrap(), + data + ); + assert!(dest.join("中文 目录/空目录/嵌套").is_dir()); + assert_eq!( + std::fs::read_link(dest.join("中文 目录/链接")).unwrap(), + Path::new("文件.bin") + ); + assert_eq!( + std::fs::metadata(dest.join("中文 目录/零字节")) + .unwrap() + .len(), + 0 + ); + let listing = client + .list(&target.join("中文 目录").unwrap()) + .await + .unwrap(); + assert_eq!(listing.entries().len(), 4); + let file = source.join("文件.bin"); + let dest_file = dest.join("中文 目录/文件.bin"); + std::fs::set_permissions(&dest_file, std::fs::Permissions::from_mode(0o400)).unwrap(); + std::fs::write(&file, b"replaced").unwrap(); + let request = + UploadRequest::new(vec![file.clone()], target.join("中文 目录").unwrap()).unwrap(); + let answers = Answers::new(vec![UploadChoice::Overwrite]); + let mut upload = UploadBatch::scan( + &request, + "local-protocol", + client.fingerprint(), + journal, + &answers.control, + ) + .await + .unwrap(); + run(&mut upload, &client, &answers.control).await.unwrap(); + assert_eq!(std::fs::read(&dest_file).unwrap(), b"replaced"); + assert_eq!( + std::fs::metadata(&dest_file).unwrap().permissions().mode() & 0o777, + 0o400 + ); + let local_time = SourceMetadata::read(&file).await.unwrap().modified_ns / 1_000_000_000; + assert_eq!( + u128::from( + client + .metadata(&target.join("中文 目录").unwrap().join("文件.bin").unwrap()) + .await + .unwrap() + .unwrap() + .modified() + .unwrap() + ), + local_time + ); + drop(client); + process.kill().await.ok(); + process.wait().await.unwrap(); + }); +} + +#[test] +fn failed_compatibility_publish_restores_old_target_and_reports_failure() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("file"); + std::fs::write(&source, b"new").unwrap(); + let remote = Remote::new(false); + remote.file("/dest/file", b"original"); + remote.fault.set(Some(Fault::RejectRename(2))); + let answers = Answers::new(vec![UploadChoice::Overwrite, UploadChoice::Skip]); + let mut upload = batch( + &source, + Journal::new(temp.path().join("journal")), + &answers.control, + ) + .await; + run(&mut upload, &remote, &answers.control).await.unwrap(); + assert_eq!(remote.bytes("/dest/file"), b"original"); + assert_eq!(upload.progress.failed(), 1); + assert_eq!(upload.progress.succeeded(), 0); + assert!( + remote + .nodes + .borrow() + .keys() + .any(|p| p.ends_with(".filepart")) + ); + assert!(!remote.nodes.borrow().keys().any(|p| p.ends_with(".backup"))); + }); +} + +#[test] +fn target_changed_during_upload_requires_another_conflict_answer() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("file"); + std::fs::write(&source, b"new").unwrap(); + let remote = Remote::new(true); + remote.file("/dest/file", b"original"); + remote.fault.set(Some(Fault::ChangeTargetOnClose)); + let answers = Answers::new(vec![UploadChoice::Overwrite, UploadChoice::Skip]); + let mut upload = batch( + &source, + Journal::new(temp.path().join("journal")), + &answers.control, + ) + .await; + run(&mut upload, &remote, &answers.control).await.unwrap(); + assert_eq!(remote.bytes("/dest/file"), b"external mutation"); + assert_eq!(upload.progress.skipped(), 1); + assert_eq!( + *answers.questions.lock().unwrap(), + vec![UploadQuestionKind::Conflict, UploadQuestionKind::Conflict] + ); + assert_eq!(remote.nodes.borrow().len(), 1); + }); +} + +#[test] +fn cancel_then_reset_allows_new_commands_without_false_cancellation() { + runtime().block_on(async { + let answers = Answers::new(vec![]); + answers.cancel.send_replace(false); + assert_eq!(answers.control.run(async { Ok(42) }).await.unwrap(), 42); + answers.cancel.send_replace(true); + assert!(answers.control.run(async { Ok(42) }).await.is_err()); + answers.cancel.send_replace(false); + assert_eq!(answers.control.run(async { Ok(43) }).await.unwrap(), 43); + }); +} + +#[test] +fn target_removed_during_upload_still_requires_confirmation() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("file"); + std::fs::write(&source, b"new").unwrap(); + let remote = Remote::new(true); + remote.file("/dest/file", b"old"); + remote.fault.set(Some(Fault::RemoveTargetOnClose)); + let answers = Answers::new(vec![UploadChoice::Overwrite, UploadChoice::Skip]); + let mut upload = batch( + &source, + Journal::new(temp.path().join("journal")), + &answers.control, + ) + .await; + run(&mut upload, &remote, &answers.control).await.unwrap(); + assert!(remote.nodes.borrow().is_empty()); + assert_eq!( + *answers.questions.lock().unwrap(), + vec![UploadQuestionKind::Conflict, UploadQuestionKind::Conflict] + ); + assert_eq!(upload.progress.skipped(), 1); + }); +} diff --git a/src/sftp/transport.rs b/src/sftp/transport.rs new file mode 100644 index 0000000..c4ae0bc --- /dev/null +++ b/src/sftp/transport.rs @@ -0,0 +1,61 @@ +use super::{ + DirectoryListing, RemotePath, UploadAnswer, UploadProgress, UploadQuestion, UploadRequest, +}; +use crate::{ + connection::{ConnectionPrompt, ConnectionPromptReply}, + session::Session, +}; +use anyhow::Result; +use async_channel::{Receiver, Sender}; +use std::sync::Arc; + +#[derive(Debug)] +pub enum SftpCommand { + List { + request_id: u64, + path: RemotePath, + }, + Upload(UploadRequest), + PromptReply { + request_id: u64, + reply: ConnectionPromptReply, + }, + Answer { + request_id: u64, + answer: UploadAnswer, + }, + Cancel, + Resume, + Discard, + Disconnect, + Shutdown, +} +#[derive(Clone, Debug)] +pub enum SftpEvent { + Connecting, + Connected { + home: RemotePath, + }, + Disconnected(String), + Prompt(ConnectionPrompt), + Listed { + request_id: u64, + result: Result, + }, + Progress(UploadProgress), + Question(UploadQuestion), + Notice(String), + Idle, +} +/// One worker lifetime; the caller runs it on its own thread. +pub trait SftpTransport: Send + 'static { + fn run( + self: Box, + commands: Receiver, + events: Sender, + ) -> Result<()>; +} +pub trait SftpTransportProvider: Send + Sync + 'static { + fn create(&self, session: &Session) -> Box; +} +pub type SharedSftpTransportProvider = Arc; diff --git a/src/sftp/upload.rs b/src/sftp/upload.rs new file mode 100644 index 0000000..bceb574 --- /dev/null +++ b/src/sftp/upload.rs @@ -0,0 +1,723 @@ +use super::{ + EntryKind, FileMetadata, RemotePath, SftpEvent, UploadChoice, UploadPhase, UploadProgress, + UploadQuestionKind, UploadRequest, + client::RemoteFs, + control::UploadControl, + journal::{Journal, PublishPhase, ResumeRecord, SourceMetadata}, + model::local_metadata, +}; +use anyhow::{Result, anyhow, bail}; +use futures::{StreamExt as _, stream::FuturesUnordered}; +use std::{ + collections::HashSet, + path::PathBuf, + time::{Duration, Instant}, +}; +use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _}; +const CHUNK: usize = 32 * 1024; +const MAX_IN_FLIGHT: usize = 16; + +#[derive(Clone)] +struct UploadItem { + source: PathBuf, + target: RemotePath, + metadata: FileMetadata, + error: Option, +} +pub(crate) struct UploadBatch { + items: Vec, + cursor: usize, + pub progress: UploadProgress, + endpoint: String, + host_key: String, + journal: Journal, + all_conflicts: Option, + approved_resumes: HashSet, + blocked_directories: Vec, + started: Instant, + sent_bytes: u64, + completed_bytes: u64, + last_progress: Instant, +} +impl UploadBatch { + pub async fn scan( + request: &UploadRequest, + endpoint: &str, + host_key: &str, + journal: Journal, + control: &UploadControl, + ) -> Result { + let mut sources = Vec::new(); + for source in request.sources() { + let absolute = std::path::absolute(source)?; + // Resolve parent aliases while preserving the leaf symlink itself. + let path = if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name()) + { + match tokio::fs::canonicalize(parent).await { + Ok(parent) => parent.join(name), + Err(_) => absolute, + } + } else { + absolute + }; + sources.push(path); + } + sources.sort(); + sources.dedup(); + let mut roots: Vec = Vec::new(); + for source in sources { + if !roots.iter().any(|parent| source.starts_with(parent)) { + roots.push(source); + } + } + let mut stack = Vec::new(); + for source in roots.into_iter().rev() { + let name = source + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow!("来源文件名不是有效的 UTF-8"))?; + let target = request.destination().join(name)?; + stack.push((source, target)); + } + let mut items = Vec::new(); + let mut total_bytes: u64 = 0; + while let Some((source, target)) = stack.pop() { + control.check()?; + let metadata = match tokio::fs::symlink_metadata(&source).await { + Ok(metadata) => local_metadata(&metadata), + Err(error) => { + items.push(UploadItem { + source, + target, + metadata: FileMetadata::new(EntryKind::Other, 0, None, None), + error: Some(error.to_string()), + }); + continue; + } + }; + let mut scan_error = None; + if metadata.kind() == EntryKind::Directory { + let read: Result> = async { + let mut dir = tokio::fs::read_dir(&source).await?; + let mut children = Vec::new(); + while let Some(entry) = dir.next_entry().await? { + let name = entry + .file_name() + .into_string() + .map_err(|_| anyhow!("目录包含无法用 UTF-8 表示的文件名"))?; + children.push((entry.path(), target.join(&name)?)); + } + children.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(children) + } + .await; + match read { + Ok(children) => stack.extend(children.into_iter().rev()), + Err(e) => scan_error = Some(e.to_string()), + } + } else if metadata.kind() == EntryKind::File { + total_bytes = total_bytes.saturating_add(metadata.size()); + } + items.push(UploadItem { + source, + target, + metadata, + error: scan_error, + }); + } + let progress = UploadProgress { + total: items.len(), + total_bytes, + ..UploadProgress::default() + }; + Ok(Self { + items, + cursor: 0, + progress, + endpoint: endpoint.into(), + host_key: host_key.into(), + journal, + all_conflicts: None, + approved_resumes: HashSet::new(), + blocked_directories: Vec::new(), + started: Instant::now(), + sent_bytes: 0, + completed_bytes: 0, + last_progress: Instant::now(), + }) + } + pub fn verify_host(&self, fingerprint: &str) -> Result<()> { + if self.host_key != fingerprint { + bail!("服务器主机指纹已改变,已停止续传"); + } + Ok(()) + } + pub fn is_complete(&self) -> bool { + self.cursor >= self.items.len() + } + pub fn emit(&self, control: &UploadControl) { + let _ = control + .events + .try_send(SftpEvent::Progress(self.progress.clone())); + } + pub fn phase(&mut self, phase: UploadPhase, control: &UploadControl) { + self.progress.phase = phase; + self.emit(control); + } + pub async fn run(&mut self, fs: &F, control: &UploadControl) -> Result<()> { + self.phase(UploadPhase::Uploading, control); + while self.cursor < self.items.len() { + control.check()?; + let item = self.items[self.cursor].clone(); + self.progress.current = item.target.to_string(); + self.emit(control); + let _target_guard = TargetGuard::acquire(&self.endpoint, &item.target)?; + let result = if self + .blocked_directories + .iter() + .any(|p| item.target.as_str().starts_with(&format!("{p}/"))) + { + Err(anyhow!("父目录未创建,无法上传")) + } else if let Some(error) = &item.error { + Err(anyhow!("{error}")) + } else { + self.upload_item(fs, &item, control).await + }; + match result { + Ok(true) => { + self.progress.succeeded += 1; + self.progress + .details + .push(format!("已上传:{}", item.target)); + if item.metadata.kind() == EntryKind::File { + self.completed_bytes = + self.completed_bytes.saturating_add(item.metadata.size()); + } + } + Ok(false) => { + self.progress.skipped += 1; + self.progress + .details + .push(format!("已跳过:{}", item.target)); + } + Err(error) + if error.is::() + || super::client::is_network_error(&error) => + { + return Err(error); + } + Err(error) => { + self.phase(UploadPhase::Waiting, control); + let answer = control + .ask( + UploadQuestionKind::Error, + item.target.as_str(), + &format!("无法上传:{error:#}"), + ) + .await?; + if answer.choice() == UploadChoice::Retry { + if item.error.is_some() { + let request = UploadRequest::new( + vec![item.source.clone()], + item.target.parent(), + )?; + let scanned = Self::scan( + &request, + &self.endpoint, + &self.host_key, + self.journal.clone(), + control, + ) + .await?; + self.progress.total = + self.progress.total.saturating_sub(1) + scanned.items.len(); + self.progress.total_bytes = self + .progress + .total_bytes + .saturating_sub(item.metadata.size()) + + scanned.progress.total_bytes; + self.items.splice(self.cursor..=self.cursor, scanned.items); + } + continue; + } + self.progress.failed += 1; + self.progress + .details + .push(format!("失败:{}:{error:#}", item.target)); + if item.metadata.kind() == EntryKind::Directory { + self.blocked_directories.push(item.target.clone()); + } + } + } + self.cursor += 1; + self.progress.completed_bytes = self.completed_bytes; + self.phase(UploadPhase::Uploading, control); + } + self.phase(UploadPhase::Completed, control); + Ok(()) + } + async fn approve( + &mut self, + target: &RemotePath, + original: &Option, + changed: bool, + control: &UploadControl, + ) -> Result { + if original.is_none() && !changed { + return Ok(true); + } + if original + .as_ref() + .is_some_and(|m| m.kind() == EntryKind::Directory) + { + bail!("目录与文件类型冲突,不会删除远程目录"); + } + if !changed && let Some(choice) = self.all_conflicts { + return Ok(choice == UploadChoice::Overwrite); + } + self.phase(UploadPhase::Waiting, control); + let message = match original { + Some(metadata) => format!( + "{}远程项目已存在({} 字节,修改时间 {:?})。覆盖会替换这个文件或链接。", + if changed { + "目标在上传期间发生变化。" + } else { + "" + }, + metadata.size(), + metadata.modified() + ), + None => "原目标在上传期间已被删除或移走。是否继续发布此文件?".into(), + }; + let answer = control + .ask(UploadQuestionKind::Conflict, target.as_str(), &message) + .await?; + if answer.apply_to_all() && !changed { + self.all_conflicts = Some(answer.choice()); + } + self.phase(UploadPhase::Uploading, control); + Ok(answer.choice() == UploadChoice::Overwrite) + } + async fn upload_item( + &mut self, + fs: &F, + item: &UploadItem, + control: &UploadControl, + ) -> Result { + let existing_record = self + .journal + .load(&self.endpoint, &item.source, &item.target) + .await?; + if item.metadata.kind() == EntryKind::Directory { + if let Some(record) = &existing_record { + record.validate(&self.endpoint, &self.host_key, &item.source, &item.target)?; + self.phase(UploadPhase::Waiting, control); + let answer = control + .ask( + UploadQuestionKind::InvalidResume, + item.target.as_str(), + "来源已变为目录,请丢弃旧文件进度后重新上传,或跳过。", + ) + .await?; + if answer.choice() != UploadChoice::Restart { + return Ok(false); + } + self.discard_record(fs, record).await?; + } + return match control.run(fs.metadata(&item.target)).await? { + Some(metadata) if metadata.kind() == EntryKind::Directory => Ok(true), + Some(_) => bail!("目标已存在且不是目录,不会跟随或替换链接"), + None => { + control.run(fs.mkdir(&item.target)).await?; + Ok(true) + } + }; + } + if item.metadata.kind() == EntryKind::Other { + bail!("不支持上传设备、套接字或其他特殊文件"); + } + let mut record = if let Some(record) = existing_record { + record.validate(&self.endpoint, &self.host_key, &item.source, &item.target)?; + if !self.approved_resumes.contains(&item.target) { + self.phase(UploadPhase::Waiting, control); + let answer = control + .ask( + UploadQuestionKind::Resume, + item.target.as_str(), + "发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。", + ) + .await?; + match answer.choice() { + UploadChoice::Skip => return Ok(false), + UploadChoice::Restart => { + self.discard_record(fs, &record).await?; + return Box::pin(self.upload_item(fs, item, control)).await; + } + _ => {} + } + } + record + } else { + let original = control.run(fs.metadata(&item.target)).await?; + if !self + .approve(&item.target, &original, false, control) + .await? + { + return Ok(false); + } + let mut record = ResumeRecord::new( + &self.endpoint, + &self.host_key, + &item.source, + item.target.clone(), + original, + )?; + if item.metadata.kind() == EntryKind::File { + record.source_metadata = + Some(control.run(SourceMetadata::read(&item.source)).await?); + if control.run(fs.metadata(&record.temporary)).await?.is_some() { + self.phase(UploadPhase::Waiting, control); + let answer = control + .ask( + UploadQuestionKind::Resume, + item.target.as_str(), + "发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。", + ) + .await?; + match answer.choice() { + UploadChoice::Skip => return Ok(false), + UploadChoice::Restart => { + control.run(fs.remove(&record.temporary)).await?; + } + _ => {} + } + self.phase(UploadPhase::Uploading, control); + } + } else { + record.link_target = Some( + tokio::fs::read_link(&item.source) + .await? + .into_os_string() + .into_string() + .map_err(|_| anyhow!("链接目标不是有效的 UTF-8"))?, + ); + } + self.journal.save(&record).await?; + record + }; + self.approved_resumes.insert(item.target.clone()); + if record.phase != PublishPhase::Writing { + return self.recover_publish(fs, &mut record, control).await; + } + let uploaded = if item.metadata.kind() == EntryKind::File { + record.source_metadata = Some(control.run(SourceMetadata::read(&item.source)).await?); + self.journal.save(&record).await?; + self.upload_file(fs, item, &mut record, control).await + } else { + let target = record + .link_target + .as_ref() + .ok_or_else(|| anyhow!("续传记录中缺少链接目标"))?; + if let Some(existing) = control.run(fs.metadata(&record.temporary)).await? { + if existing.kind() != EntryKind::Symlink + || control.run(fs.readlink(&record.temporary)).await? != *target + { + bail!("临时链接已变化,无法继续上传"); + } + } else { + control.run(fs.symlink(target, &record.temporary)).await?; + } + Ok(()) + }; + if let Err(error) = uploaded { + if error.is::() { + self.phase(UploadPhase::Waiting, control); + let answer = control + .ask( + UploadQuestionKind::InvalidResume, + item.target.as_str(), + &error.to_string(), + ) + .await?; + if answer.choice() == UploadChoice::Restart { + self.discard_record(fs, &record).await?; + return Box::pin(self.upload_item(fs, item, control)).await; + } + return Ok(false); + } + return Err(error); + } + record.phase = PublishPhase::Ready; + self.journal.save(&record).await?; + self.recover_publish(fs, &mut record, control).await + } + async fn upload_file( + &mut self, + fs: &F, + item: &UploadItem, + record: &mut ResumeRecord, + control: &UploadControl, + ) -> Result<()> { + let source_metadata = record + .source_metadata + .clone() + .ok_or_else(|| anyhow!("续传记录中缺少来源文件信息"))?; + let remote = control.run(fs.metadata(&record.temporary)).await?; + if remote.as_ref().is_some_and(|m| m.kind() != EntryKind::File) { + return Err(InvalidResume("远端临时文件类型已变化").into()); + } + let resume_offset = remote.as_ref().map(FileMetadata::size).unwrap_or(0); + if resume_offset > source_metadata.size { + return Err(InvalidResume("远端续传偏移大于本地文件").into()); + } + let handle = control + .run(fs.open(&record.temporary, remote.is_none())) + .await?; + let result: Result<()> = async { + let mut local = tokio::fs::File::open(&item.source).await?; + local.seek(std::io::SeekFrom::Start(resume_offset)).await?; + self.phase(UploadPhase::Uploading, control); + let mut next_offset = resume_offset; + let mut uploaded = resume_offset; + let mut writes = FuturesUnordered::new(); + while next_offset < source_metadata.size || !writes.is_empty() { + while next_offset < source_metadata.size && writes.len() < MAX_IN_FLIGHT { + control.check()?; + let length = (source_metadata.size - next_offset).min(CHUNK as u64) as usize; + let mut bytes = vec![0; length]; + local.read_exact(&mut bytes).await?; + let write_offset = next_offset; + next_offset += length as u64; + let handle = handle.as_str(); + writes.push(async move { + control.run(fs.write(handle, write_offset, bytes)).await?; + Ok::<_, anyhow::Error>(length as u64) + }); + } + let length = writes + .next() + .await + .ok_or_else(|| anyhow!("上传写入流水线意外结束"))??; + self.sent_bytes += length; + uploaded += length; + self.progress.completed_bytes = self.completed_bytes.saturating_add(uploaded); + self.progress.bytes_per_second = (self.sent_bytes as f64 + / self.started.elapsed().as_secs_f64().max(0.001)) + as u64; + if self.last_progress.elapsed() >= Duration::from_millis(50) { + self.emit(control); + self.last_progress = Instant::now(); + } + } + control + .run(fs.attributes( + &handle, + u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(), + record.original.as_ref().and_then(FileMetadata::permissions), + )) + .await?; + control.run(fs.sync(&handle)).await?; + Ok(()) + } + .await; + // Always close. After a broken connection, WinSCP-style resume uses the + // .filepart size reported by the server as the next source offset. + let close = fs.close(&handle).await; + result?; + close?; + Ok(()) + } + async fn matches_expected_item( + &self, + fs: &F, + path: &RemotePath, + record: &ResumeRecord, + control: &UploadControl, + ) -> Result { + let Some(metadata) = control.run(fs.metadata(path)).await? else { + return Ok(false); + }; + if let Some(target) = &record.link_target { + return Ok(metadata.kind() == EntryKind::Symlink + && control.run(fs.readlink(path)).await? == *target); + } + let Some(source_metadata) = &record.source_metadata else { + return Ok(false); + }; + Ok(metadata.kind() == EntryKind::File && metadata.size() == source_metadata.size) + } + async fn recover_publish( + &mut self, + fs: &F, + record: &mut ResumeRecord, + control: &UploadControl, + ) -> Result { + let temporary = control.run(fs.metadata(&record.temporary)).await?; + if temporary.is_none() { + if matches!( + record.phase, + PublishPhase::Publishing | PublishPhase::Published + ) && self + .matches_expected_item(fs, &record.target, record, control) + .await? + { + self.cleanup(fs, record).await?; + return Ok(true); + } + // We cannot infer success from a lost reply. A backup may still be + // the only copy of the old file; restore only into an absent target. + if fs.metadata(&record.target).await?.is_none() + && fs.metadata(&record.backup).await?.is_some() + { + fs.rename(&record.backup, &record.target, false).await?; + } + bail!("无法确认文件发布结果,已保留恢复文件;请检查远端目录"); + } + if !self + .matches_expected_item(fs, &record.temporary, record, control) + .await? + { + bail!("临时文件大小或类型已变化,未替换目标"); + } + let backup = control.run(fs.metadata(&record.backup)).await?; + let current = control.run(fs.metadata(&record.target)).await?; + if backup.is_some() { + if current.is_some() { + bail!("替换期间目标被其他操作创建,已保留目标、备份和临时文件"); + } + if backup != record.original { + bail!("恢复备份已变化,停止替换"); + } + record.phase = PublishPhase::BackedUp; + self.journal.save(record).await?; + } else if current != record.original { + if !self + .approve(&record.target, ¤t, true, control) + .await? + { + self.discard_record(fs, record).await?; + return Ok(false); + } + record.original = current; + self.journal.save(record).await?; + if let Some(source_metadata) = &record.source_metadata { + let handle = control.run(fs.open(&record.temporary, false)).await?; + let result = control + .run(fs.attributes( + &handle, + u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(), + record.original.as_ref().and_then(FileMetadata::permissions), + )) + .await; + let closed = fs.close(&handle).await; + result?; + closed?; + } + } + if backup.is_none() && record.original.is_some() && !fs.atomic_replace() { + record.phase = PublishPhase::BackingUp; + self.journal.save(record).await?; + control + .run(fs.rename(&record.target, &record.backup, false)) + .await?; + record.phase = PublishPhase::BackedUp; + self.journal.save(record).await?; + } + record.phase = PublishPhase::Publishing; + self.journal.save(record).await?; + let result = control + .run(fs.rename( + &record.temporary, + &record.target, + fs.atomic_replace() && record.original.is_some() && backup.is_none(), + )) + .await; + if let Err(error) = result { + if !super::client::is_network_error(&error) + && !error.is::() + && fs.metadata(&record.target).await?.is_none() + && fs.metadata(&record.backup).await?.is_some() + { + fs.rename(&record.backup, &record.target, false).await?; + record.phase = PublishPhase::Ready; + self.journal.save(record).await?; + } + return Err(error); + } + record.phase = PublishPhase::Published; + self.journal.save(record).await?; + self.cleanup(fs, record).await?; + Ok(true) + } + async fn cleanup(&self, fs: &F, record: &ResumeRecord) -> Result<()> { + if let Some(backup) = fs.metadata(&record.backup).await? { + if Some(backup) != record.original { + bail!("备份已变化,未清理;上传的目标文件已发布"); + } + fs.remove(&record.backup).await?; + } + self.journal.remove(record).await + } + async fn discard_record(&self, fs: &F, record: &ResumeRecord) -> Result<()> { + record.validate( + &self.endpoint, + &self.host_key, + &record.source, + &record.target, + )?; + if fs.metadata(&record.backup).await?.is_some() { + if fs.metadata(&record.target).await?.is_none() { + fs.rename(&record.backup, &record.target, false).await?; + } else { + bail!("存在恢复备份和目标文件,请先检查远端目录;未删除任何文件"); + } + } + fs.remove(&record.temporary).await?; + self.journal.remove(record).await + } + pub async fn discard(&self, fs: &F) -> Result<()> { + for item in &self.items { + if let Some(record) = self + .journal + .load(&self.endpoint, &item.source, &item.target) + .await? + { + self.discard_record(fs, &record).await?; + } + } + Ok(()) + } +} +#[derive(Debug)] +struct InvalidResume(&'static str); +impl std::fmt::Display for InvalidResume { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} +impl std::error::Error for InvalidResume {} + +/// Coordinate sibling sessions using the same endpoint and destination. +struct TargetGuard(String); +static ACTIVE_TARGETS: std::sync::Mutex>> = std::sync::Mutex::new(None); +impl TargetGuard { + fn acquire(endpoint: &str, target: &RemotePath) -> Result { + let key = format!("{endpoint}\0{target}"); + let mut active = ACTIVE_TARGETS.lock().unwrap_or_else(|e| e.into_inner()); + if !active.get_or_insert_with(HashSet::new).insert(key.clone()) { + bail!("其他会话正在上传同一目标,请稍后继续上传"); + } + Ok(Self(key)) + } +} +impl Drop for TargetGuard { + fn drop(&mut self) { + if let Some(active) = ACTIVE_TARGETS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_mut() + { + active.remove(&self.0); + } + } +} diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs new file mode 100644 index 0000000..4978ec2 --- /dev/null +++ b/src/sftp/worker.rs @@ -0,0 +1,369 @@ +use super::{ + RemotePath, SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadPhase, + client::{SftpClient, is_network_error}, + control::{Cancelled, UploadControl}, + journal::Journal, + upload::UploadBatch, +}; +use crate::{ + session::Session, + ssh::{SshConnectionConfig, SshConnector, SshPrompts}, +}; +use anyhow::Result; +use async_channel::{Receiver, Sender}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use tokio::sync::{RwLock, watch}; + +pub struct SshSftpTransportProvider { + connector: SshConnector, + resume_dir: PathBuf, +} +impl SshSftpTransportProvider { + pub fn new(connector: SshConnector, resume_dir: PathBuf) -> Self { + Self { + connector, + resume_dir, + } + } +} +impl SftpTransportProvider for SshSftpTransportProvider { + fn create(&self, session: &Session) -> Box { + Box::new(SshSftpTransport { + connector: self.connector.clone(), + config: SshConnectionConfig::from(session), + journal: Journal::new(self.resume_dir.clone()), + }) + } +} +struct SshSftpTransport { + connector: SshConnector, + config: SshConnectionConfig, + journal: Journal, +} +impl SftpTransport for SshSftpTransport { + fn run( + self: Box, + commands: Receiver, + events: Sender, + ) -> Result<()> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(self.run_async(commands, events)) + } +} +impl SshSftpTransport { + async fn connect( + &self, + prompts: Arc, + events: &Sender, + shutdown: &watch::Receiver, + shared: &RwLock>>, + ) -> Result> { + events.send(SftpEvent::Connecting).await?; + let mut stop = shutdown.clone(); + let client = tokio::select! { + result = SftpClient::connect(&self.connector, &self.config, prompts) => Arc::new(result?), + _ = stop.changed() => return Err(Cancelled.into()), + }; + let home = client.canonicalize(&RemotePath::new(".")?).await?; + *shared.write().await = Some(client.clone()); + events.send(SftpEvent::Connected { home }).await?; + Ok(client) + } + async fn run_async( + &self, + commands: Receiver, + events: Sender, + ) -> Result<()> { + let (shutdown_tx, shutdown) = watch::channel(false); + let (cancel_tx, cancel) = watch::channel(false); + let (answers_tx, answers) = async_channel::unbounded(); + let (operations_tx, operations) = async_channel::bounded(1); + let prompt_events = events.clone(); + let prompts = Arc::new(SshPrompts::new( + Arc::new(move |p| prompt_events.try_send(SftpEvent::Prompt(p)).is_ok()), + shutdown.clone(), + )); + let client: Arc>>> = Arc::new(RwLock::new(None)); + let busy = Arc::new(AtomicBool::new(false)); + let router = { + let prompts = prompts.clone(); + let events = events.clone(); + let client = client.clone(); + let busy = busy.clone(); + let cancel_tx = cancel_tx.clone(); + tokio::spawn(async move { + while let Ok(command) = commands.recv().await { + match command { + SftpCommand::PromptReply { request_id, reply } => { + prompts.respond(request_id, reply) + } + SftpCommand::Answer { request_id, answer } => { + let _ = answers_tx.send((request_id, answer)).await; + } + SftpCommand::Cancel => { + cancel_tx.send_replace(true); + prompts.cancel_all(); + } + SftpCommand::Disconnect => { + cancel_tx.send_replace(true); + prompts.cancel_all(); + *client.write().await = None; + let _ = events + .send(SftpEvent::Disconnected("SFTP 已断开".into())) + .await; + } + SftpCommand::Shutdown => break, + SftpCommand::List { request_id, path } => { + let shared = client.clone(); + let connected = client.read().await.clone(); + let events = events.clone(); + tokio::spawn(async move { + let result = match connected { + Some(connected) => match connected.list(&path).await { + Ok(listing) => Ok(listing), + Err(error) => { + if is_network_error(&error) { + let mut current = shared.write().await; + if current + .as_ref() + .is_some_and(|c| Arc::ptr_eq(c, &connected)) + { + *current = None; + let _ = events + .send(SftpEvent::Disconnected( + "SFTP 连接中断,请重新连接".into(), + )) + .await; + } + } + Err(format!("无法读取目录:{error:#}")) + } + }, + None => Err("SFTP 未连接,请重新连接".into()), + }; + let _ = events.send(SftpEvent::Listed { request_id, result }).await; + }); + } + command => { + if busy.swap(true, Ordering::AcqRel) { + let _ = events + .send(SftpEvent::Notice("已有上传批次正在处理".into())) + .await; + } else { + cancel_tx.send_replace(false); + if operations_tx.send(command).await.is_err() { + break; + } + } + } + } + } + shutdown_tx.send_replace(true); + cancel_tx.send_replace(true); + prompts.cancel_all(); + }) + }; + let control = UploadControl { + events: events.clone(), + cancel, + answers, + }; + let mut batch: Option = None; + match control + .run(self.connect(prompts.clone(), &events, &shutdown, &client)) + .await + { + Ok(connected) => *client.write().await = Some(connected), + Err(error) => { + let _ = events + .send(SftpEvent::Disconnected(error.to_string())) + .await; + } + } + let mut stop = shutdown.clone(); + while !*stop.borrow() { + let operation = tokio::select! { operation = operations.recv() => match operation { Ok(operation) => operation, Err(_) => break }, _ = stop.changed() => break }; + if client.read().await.is_none() { + match control + .run(self.connect(prompts.clone(), &events, &shutdown, &client)) + .await + { + Ok(connected) => *client.write().await = Some(connected), + Err(error) => { + events + .send(SftpEvent::Disconnected(error.to_string())) + .await?; + events.send(SftpEvent::Idle).await?; + busy.store(false, Ordering::Release); + continue; + } + } + } + let connected = client.read().await.clone().expect("connected above"); + let prepared: Result<()> = async { + if let SftpCommand::Upload(request) = &operation { + batch = None; + events + .send(SftpEvent::Progress(super::UploadProgress::default())) + .await?; + let target = control + .run(connected.canonicalize(request.destination())) + .await?; + let request = super::UploadRequest::new(request.sources().to_vec(), target)?; + batch = Some( + control + .run(UploadBatch::scan( + &request, + &self.config.endpoint(), + connected.fingerprint(), + self.journal.clone(), + &control, + )) + .await?, + ); + } + if let Some(batch) = &batch { + batch.verify_host(connected.fingerprint())?; + } + if matches!(operation, SftpCommand::Discard) { + if let Some(current) = &batch { + current.discard(connected.as_ref()).await?; + } + batch = None; + events + .send(SftpEvent::Progress(super::UploadProgress { + phase: UploadPhase::Completed, + ..Default::default() + })) + .await?; + } + Ok(()) + } + .await; + if let Err(error) = prepared { + events.send(SftpEvent::Notice(error.to_string())).await?; + if let Some(batch) = &mut batch { + batch.phase(UploadPhase::Stopped, &control); + } else { + events + .send(SftpEvent::Progress(super::UploadProgress { + phase: UploadPhase::Stopped, + details: vec![error.to_string()], + ..Default::default() + })) + .await?; + } + busy.store(false, Ordering::Release); + continue; + } + if !matches!(operation, SftpCommand::Discard) + && let Some(batch) = &mut batch + { + let mut reconnects = 0; + loop { + let connected = client.read().await.clone(); + let result = match connected { + Some(connected) => batch.run(connected.as_ref(), &control).await, + None => { + if control.check().is_err() { + Err(Cancelled.into()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "SFTP 未连接", + ) + .into()) + } + } + }; + match result { + Ok(()) => break, + Err(error) if is_network_error(&error) => { + *client.write().await = None; + events + .send(SftpEvent::Disconnected("连接中断,上传进度已保留".into())) + .await?; + let mut recovered = false; + while reconnects < 3 { + batch.progress.current = format!( + "将在 {} 秒后重连({}/3)", + [1, 3, 10][reconnects], + reconnects + 1 + ); + batch.phase(UploadPhase::Reconnecting, &control); + let delay = Duration::from_secs([1, 3, 10][reconnects]); + reconnects += 1; + if control + .run(async { + tokio::time::sleep(delay).await; + Ok(()) + }) + .await + .is_err() + { + break; + } + match control + .run(self.connect(prompts.clone(), &events, &shutdown, &client)) + .await + { + Ok(next) => match batch.verify_host(next.fingerprint()) { + Ok(()) => { + *client.write().await = Some(next); + recovered = true; + break; + } + Err(error) => { + events + .send(SftpEvent::Notice(error.to_string())) + .await?; + break; + } + }, + Err(error) => { + events + .send(SftpEvent::Disconnected(error.to_string())) + .await?; + // Authentication/trust failures must require user action. + if !is_network_error(&error) { + break; + } + } + } + } + if recovered { + continue; + } + batch.phase(UploadPhase::Stopped, &control); + break; + } + Err(error) => { + if !error.is::() { + events.send(SftpEvent::Notice(error.to_string())).await?; + } + batch.phase(UploadPhase::Stopped, &control); + break; + } + } + } + if batch.is_complete() { + batch.phase(UploadPhase::Completed, &control); + } + } + busy.store(false, Ordering::Release); + events.send(SftpEvent::Idle).await?; + } + router.abort(); + *client.write().await = None; + Ok(()) + } +} diff --git a/src/ssh/connection.rs b/src/ssh/connection.rs new file mode 100644 index 0000000..e0179da --- /dev/null +++ b/src/ssh/connection.rs @@ -0,0 +1,681 @@ +//! SSH connection and authentication shared by terminals and SFTP. +use crate::{ + connection::{ + ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply, + }, + secrets::{SecretRef, SharedSecretStore}, + session::{AuthKind, Session}, +}; +use anyhow::{Context as _, Result, anyhow, bail}; +use russh::keys::{ + HashAlg, PrivateKeyWithHashAlg, PublicKey, + agent::client::AgentClient, + known_hosts::{known_host_keys_path, learn_known_hosts_path}, + load_secret_key, parse_public_key_base64, +}; +use russh::{ + MethodKind, MethodSet, + client::{self, AuthResult, KeyboardInteractiveAuthResponse}, +}; +use std::{ + collections::HashMap, + future::Future, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; +use tokio::sync::{oneshot, watch}; +use zeroize::Zeroizing; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); +const AUTH_RETRIES: usize = 3; +static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone)] +pub struct SshConnectionConfig { + host: String, + port: u16, + user: String, + auth: AuthKind, + key_path: Option, +} + +impl From<&Session> for SshConnectionConfig { + fn from(session: &Session) -> Self { + Self { + host: session.host.to_string(), + port: session.port, + user: session.user.to_string(), + auth: session.auth, + key_path: session + .key_path + .as_ref() + .map(|path| PathBuf::from(path.as_ref())), + } + } +} + +/// Cloneable trust and credential service. Every clone shares the trust-file lock. +#[derive(Clone)] +pub struct SshConnector { + known_hosts_path: PathBuf, + known_hosts_lock: Arc>, + secrets: SharedSecretStore, +} +impl SshConnector { + pub fn new(path: impl Into, secrets: SharedSecretStore) -> Self { + Self { + known_hosts_path: path.into(), + known_hosts_lock: Arc::new(Mutex::new(())), + secrets, + } + } + /// Must run on a worker: keychain and private-key reads are blocking. + pub async fn connect( + &self, + config: &SshConnectionConfig, + broker: Arc, + ) -> Result<(client::Handle, String)> { + let fingerprint = Arc::new(Mutex::new(String::new())); + let handler = SshClientHandler { + host: config.host.clone(), + port: config.port, + known_hosts_path: self.known_hosts_path.clone(), + known_hosts_lock: self.known_hosts_lock.clone(), + broker: broker.clone(), + fingerprint: fingerprint.clone(), + }; + let connect = client::connect( + Arc::new(client::Config { + keepalive_interval: Some(KEEPALIVE_INTERVAL), + keepalive_max: 3, + nodelay: true, + ..Default::default() + }), + (config.host.as_str(), config.port), + handler, + ); + let mut shutdown = broker.shutdown_receiver(); + let mut handle = tokio::select! { + result = timeout_excluding_prompts(connect, broker.prompt_activity_receiver(), CONNECT_TIMEOUT) => result?.map_err(|e| e.context(safe_connect_error()))?, + _ = shutdown.changed() => bail!("连接已取消"), + }; + tokio::select! { + result = authenticate(&mut handle, config, &self.secrets, &broker) => result?, + _ = shutdown.changed() => bail!("连接已取消"), + } + let fingerprint = fingerprint + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + Ok((handle, fingerprint)) + } +} +impl SshConnectionConfig { + pub fn endpoint(&self) -> String { + format!("{}@{}:{}", self.user, self.host, self.port) + } +} + +pub struct SshPrompts { + pending: Mutex>>, + events: Arc bool + Send + Sync>, + shutdown: watch::Receiver, + prompt_activity: watch::Sender, +} + +impl SshPrompts { + pub fn new( + events: Arc bool + Send + Sync>, + shutdown: watch::Receiver, + ) -> Self { + let (prompt_activity, _) = watch::channel(false); + Self { + pending: Mutex::new(HashMap::new()), + events, + shutdown, + prompt_activity, + } + } + + pub fn shutdown_receiver(&self) -> watch::Receiver { + self.shutdown.clone() + } + + fn prompt_activity_receiver(&self) -> watch::Receiver { + self.prompt_activity.subscribe() + } + + async fn ask(&self, kind: ConnectionPromptKind) -> Result { + let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(request_id, sender); + let _ = self.prompt_activity.send(true); + let result = async { + if !(self.events)(ConnectionPrompt::new(request_id, kind)) { + bail!("连接视图已关闭"); + } + let mut shutdown = self.shutdown.clone(); + tokio::select! { + reply = receiver => reply.map_err(|_| anyhow!("认证请求已取消")), + _ = shutdown.changed() => bail!("连接已取消"), + } + } + .await; + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&request_id); + let _ = self.prompt_activity.send(false); + result + } + + async fn emit(&self, kind: ConnectionPromptKind) { + let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed); + (self.events)(ConnectionPrompt::new(request_id, kind)); + } + + pub fn respond(&self, request_id: u64, reply: ConnectionPromptReply) { + if let Some(sender) = self + .pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&request_id) + { + let _ = sender.send(reply); + } + } + + pub fn cancel_all(&self) { + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + } +} + +/// Apply the network handshake timeout without counting time spent waiting +/// for an explicit answer from the user to a host-trust prompt. +async fn timeout_excluding_prompts( + future: F, + mut prompt_activity: watch::Receiver, + timeout: Duration, +) -> Result +where + F: Future, +{ + tokio::pin!(future); + let mut remaining = timeout; + loop { + if *prompt_activity.borrow() { + tokio::select! { + result = &mut future => return Ok(result), + changed = prompt_activity.changed() => { + if changed.is_err() { + bail!("连接已取消"); + } + } + } + continue; + } + + let started = tokio::time::Instant::now(); + tokio::select! { + result = &mut future => return Ok(result), + _ = tokio::time::sleep(remaining) => return Err(russh::Error::ConnectionTimeout.into()), + changed = prompt_activity.changed() => { + if changed.is_err() { + bail!("连接已取消"); + } + remaining = remaining.saturating_sub(started.elapsed()); + if remaining.is_zero() && !*prompt_activity.borrow() { + return Err(russh::Error::ConnectionTimeout.into()); + } + } + } + } +} + +pub type SshHandle = client::Handle; + +pub struct SshClientHandler { + host: String, + port: u16, + known_hosts_path: PathBuf, + known_hosts_lock: Arc>, + broker: Arc, + fingerprint: Arc>, +} + +impl client::Handler for SshClientHandler { + type Error = anyhow::Error; + + async fn check_server_key( + &mut self, + server_public_key: &russh::keys::PublicKeyOrCertificate, + ) -> Result { + let key = server_public_key.public_key(); + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); + *self.fingerprint.lock().unwrap_or_else(|e| e.into_inner()) = fingerprint.clone(); + let algorithm = key.algorithm().to_string(); + let known = { + let _guard = self + .known_hosts_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + read_known_keys(&self.host, self.port, &self.known_hosts_path)? + }; + if known.iter().any(|(_, saved)| saved == &key) { + return Ok(true); + } + if !known.is_empty() { + let old = known + .iter() + .map(|(_, saved)| saved.fingerprint(HashAlg::Sha256).to_string()) + .collect(); + self.broker + .emit(ConnectionPromptKind::host_key_changed( + &self.host, + self.port, + algorithm, + old, + fingerprint, + &self.known_hosts_path, + )) + .await; + return Ok(false); + } + + let reply = self + .broker + .ask(ConnectionPromptKind::unknown_host( + &self.host, + self.port, + algorithm, + fingerprint, + )) + .await?; + if !matches!(reply, ConnectionPromptReply::TrustAndSave) { + return Ok(false); + } + let _guard = self + .known_hosts_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + let known = read_known_keys(&self.host, self.port, &self.known_hosts_path)?; + if known.iter().any(|(_, saved)| saved == &key) { + return Ok(true); + } + if !known.is_empty() { + bail!("保存主机密钥时发现信任文件已发生变化"); + } + learn_known_hosts_path(&self.host, self.port, &key, &self.known_hosts_path) + .map_err(|_| anyhow!("无法写入主机信任文件:{}", self.known_hosts_path.display()))?; + Ok(true) + } +} + +fn read_known_keys(host: &str, port: u16, path: &Path) -> Result> { + if path.exists() { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("无法读取主机信任文件:{}", path.display()))?; + for line in contents.lines().map(str::trim) { + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split_whitespace(); + let valid = fields.next().is_some() + && fields.next().is_some() + && fields + .next() + .is_some_and(|encoded| parse_public_key_base64(encoded).is_ok()); + if !valid { + bail!("主机信任文件已损坏:{}", path.display()); + } + } + } + known_host_keys_path(host, port, path) + .map_err(|_| anyhow!("主机信任文件已损坏或无法读取:{}", path.display())) +} + +async fn authenticate( + handle: &mut client::Handle, + config: &SshConnectionConfig, + secrets: &SharedSecretStore, + broker: &SshPrompts, +) -> Result<()> +where + H::Error: From, +{ + let first = handle + .authenticate_none(&config.user) + .await + .map_err(|_| anyhow!("无法查询服务器支持的认证方式"))?; + if first.success() { + return Ok(()); + } + let mut methods = remaining_methods(first); + let mut partial = false; + + if config.auth == AuthKind::Auto + && methods.contains(&MethodKind::PublicKey) + && let Some(result) = try_agent(handle, &config.user).await? + { + if result.success() { + return Ok(()); + } + partial = is_partial(&result); + methods = remaining_methods(result); + } + + if matches!(config.auth, AuthKind::Auto | AuthKind::Key) + && methods.contains(&MethodKind::PublicKey) + { + let paths = key_paths(config)?; + for path in paths { + let Some(key) = load_private_key(&path, secrets, broker).await? else { + continue; + }; + let hash = handle + .best_supported_rsa_hash() + .await + .map_err(|_| anyhow!("无法协商 RSA 签名算法"))? + .flatten(); + let result = handle + .authenticate_publickey( + &config.user, + PrivateKeyWithHashAlg::new(Arc::new(key), hash), + ) + .await + .map_err(|_| anyhow!("私钥认证失败"))?; + if result.success() { + return Ok(()); + } + partial = is_partial(&result); + methods = remaining_methods(result); + if partial { + break; + } + } + if config.auth == AuthKind::Key && !partial { + bail!("服务器未接受指定的私钥"); + } + } + + if matches!(config.auth, AuthKind::Auto | AuthKind::Password) || partial { + if methods.contains(&MethodKind::Password) { + // Try what the session has saved before bothering anyone. + let mut saved_rejected = false; + if let Some(saved) = saved_secret(secrets, &password_secret(config)) { + let result = handle + .authenticate_password(&config.user, saved.to_string()) + .await + .map_err(|_| anyhow!("密码认证失败"))?; + if result.success() { + return Ok(()); + } + // The entry stays: the user typed it into the session dialog, + // and deleting it behind their back would be baffling. This + // connection just falls back to asking, and says why. + saved_rejected = true; + partial = is_partial(&result); + methods = remaining_methods(result); + } + let instructions = if saved_rejected { + "已保存的密码被服务器拒绝,请重新输入" + } else { + "请输入登录密码" + }; + if !partial && methods.contains(&MethodKind::Password) { + for _ in 0..AUTH_RETRIES { + let answer = ask_one_secret(broker, "SSH 登录", instructions, "密码").await?; + let result = handle + .authenticate_password(&config.user, answer.into_inner()) + .await + .map_err(|_| anyhow!("密码认证失败"))?; + if result.success() { + return Ok(()); + } + partial = is_partial(&result); + methods = remaining_methods(result); + if partial || !methods.contains(&MethodKind::Password) { + break; + } + } + } + } + if methods.contains(&MethodKind::KeyboardInteractive) { + for _ in 0..AUTH_RETRIES { + if keyboard_interactive(handle, &config.user, broker).await? { + return Ok(()); + } + } + } + } + bail!("认证失败:服务器未接受可用的认证方式") +} + +async fn try_agent( + handle: &mut client::Handle, + user: &str, +) -> Result> +where + H::Error: From, +{ + #[cfg(unix)] + { + let Ok(mut agent) = AgentClient::connect_env().await else { + return Ok(None); + }; + let Ok(identities) = agent.request_identities().await else { + return Ok(None); + }; + let hash = handle + .best_supported_rsa_hash() + .await + .map_err(|_| anyhow!("无法协商 SSH Agent 签名算法"))? + .flatten(); + let mut last = None; + for identity in identities { + let key = identity.public_key().into_owned(); + match handle + .authenticate_publickey_with(user, key, hash, &mut agent) + .await + { + Ok(result) if result.success() => return Ok(Some(result)), + Ok(result) => last = Some(result), + Err(_) => continue, + } + } + Ok(last) + } + #[cfg(not(unix))] + { + let _ = (handle, user); + Ok(None) + } +} + +fn key_paths(config: &SshConnectionConfig) -> Result> { + if config.auth == AuthKind::Key { + return config + .key_path + .clone() + .map(|path| vec![path]) + .ok_or_else(|| anyhow!("私钥认证需要选择私钥文件")); + } + let Some(home) = dirs::home_dir() else { + return Ok(Vec::new()); + }; + Ok(["id_ed25519", "id_ecdsa", "id_rsa"] + .into_iter() + .map(|name| home.join(".ssh").join(name)) + .collect()) +} + +async fn load_private_key( + path: &Path, + secrets: &SharedSecretStore, + broker: &SshPrompts, +) -> Result> { + if !path.exists() { + return Ok(None); + } + match load_secret_key(path, None) { + Ok(key) => return Ok(Some(key)), + Err(russh::keys::Error::KeyIsEncrypted) => {} + Err(_) => bail!("无法读取私钥文件:{}", path.display()), + } + // Passphrases are saved per key file, so one saved answer unlocks the same + // key for every session that uses it. + let mut saved_rejected = false; + if let Some(saved) = saved_secret(secrets, &SecretRef::passphrase(path)) { + if let Ok(key) = load_secret_key(path, Some(saved.as_str())) { + return Ok(Some(key)); + } + saved_rejected = true; + } + let instructions = if saved_rejected { + format!("已保存的口令无法解开 {},请重新输入", path.display()) + } else { + format!("请输入 {} 的口令", path.display()) + }; + for _ in 0..AUTH_RETRIES { + let answer = ask_one_secret(broker, "私钥口令", &instructions, "口令").await?; + if let Ok(key) = load_secret_key(path, Some(answer.expose())) { + return Ok(Some(key)); + } + } + bail!("私钥口令错误次数过多") +} + +/// Where this connection's password lives in the system keychain. +fn password_secret(config: &SshConnectionConfig) -> SecretRef { + SecretRef::password(&config.user, &config.host, config.port) +} + +/// Read a saved secret. A keychain that errors, is locked, or holds an empty +/// value counts as nothing saved: the connection then falls back to asking, +/// which is always better than refusing to connect. +/// +/// This blocks. It only ever runs on the SSH worker thread, whose runtime +/// serves this one connection, and only while authentication is already +/// waiting on a person. +fn saved_secret(secrets: &SharedSecretStore, secret: &SecretRef) -> Option> { + secrets + .get(secret) + .ok() + .flatten() + .filter(|value| !value.is_empty()) +} + +async fn ask_one_secret( + broker: &SshPrompts, + title: &str, + instructions: &str, + label: &str, +) -> Result { + let reply = broker + .ask(ConnectionPromptKind::authentication( + title, + instructions, + vec![ConnectionPromptField::new(label, false)], + )) + .await?; + match reply { + ConnectionPromptReply::Answers(mut answers) if answers.len() == 1 => Ok(answers.remove(0)), + ConnectionPromptReply::Cancel => bail!("认证已取消"), + _ => bail!("认证回复无效"), + } +} + +async fn keyboard_interactive( + handle: &mut client::Handle, + user: &str, + broker: &SshPrompts, +) -> Result +where + H::Error: From, +{ + let mut response = handle + .authenticate_keyboard_interactive_start(user, None) + .await + .map_err(|_| anyhow!("无法开始交互式认证"))?; + loop { + match response { + KeyboardInteractiveAuthResponse::Success => return Ok(true), + KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false), + KeyboardInteractiveAuthResponse::InfoRequest { + name, + instructions, + prompts, + } => { + let fields = prompts + .into_iter() + .map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo)) + .collect(); + let reply = broker + .ask(ConnectionPromptKind::authentication( + name, + instructions, + fields, + )) + .await?; + let ConnectionPromptReply::Answers(answers) = reply else { + bail!("交互式认证已取消") + }; + response = handle + .authenticate_keyboard_interactive_respond( + answers + .into_iter() + .map(|answer| answer.into_inner()) + .collect(), + ) + .await + .map_err(|_| anyhow!("交互式认证失败"))?; + } + } + } +} + +fn remaining_methods(result: AuthResult) -> MethodSet { + match result { + AuthResult::Success => MethodSet::empty(), + AuthResult::Failure { + remaining_methods, .. + } => remaining_methods, + } +} + +fn is_partial(result: &AuthResult) -> bool { + matches!( + result, + AuthResult::Failure { + partial_success: true, + .. + } + ) +} + +fn safe_connect_error() -> &'static str { + "无法建立 SSH 连接,请检查主机、端口和主机密钥" +} + +#[cfg(test)] +mod tests { + use super::read_known_keys; + #[test] + fn malformed_known_hosts_is_blocked() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("known_hosts"); + std::fs::write(&path, "this is not a key\n").unwrap(); + let error = read_known_keys("example.test", 22, &path).unwrap_err(); + assert!(error.to_string().contains("已损坏")); + } +} diff --git a/src/ssh/mod.rs b/src/ssh/mod.rs index 814f9b9..7f58151 100644 --- a/src/ssh/mod.rs +++ b/src/ssh/mod.rs @@ -1,6 +1,6 @@ -//! Real SSH transport. SFTP intentionally remains a separate mock capability. - +//! Shared SSH connection service and remote terminal adapter. +mod connection; mod probe; mod transport; - +pub use connection::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts}; pub use transport::SshTerminalTransportProvider; diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index e9ff17f..17ce9ac 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -1,115 +1,67 @@ -use std::collections::HashMap; -use std::future::Future; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, mpsc}; -use std::thread; -use std::time::Duration; - +use super::{ + connection::{SshConnectionConfig, SshConnector, SshPrompts}, + probe::{HostOsProbe, ProbeOutcome}, +}; +use crate::{ + secrets::SharedSecretStore, + session::Session, + terminal::{ + RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize, + TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, + TerminalTransportFactory, + }, +}; use anyhow::{Context as _, Result, anyhow, bail}; use async_channel::Sender; -use russh::ChannelMsg; -use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse}; -use russh::keys::agent::client::AgentClient; -use russh::keys::known_hosts::{known_host_keys_path, learn_known_hosts_path}; -use russh::keys::{ - HashAlg, PrivateKeyWithHashAlg, PublicKey, load_secret_key, parse_public_key_base64, +use russh::{ChannelMsg, client}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::Duration, }; -use russh::{MethodKind, MethodSet}; -use tokio::sync::{mpsc as tokio_mpsc, oneshot, watch}; -use zeroize::Zeroizing; - -use super::probe::{HostOsProbe, ProbeOutcome}; -use crate::secrets::{SecretRef, SharedSecretStore}; -use crate::session::{AuthKind, Session}; -use crate::terminal::{ - RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalPrompt, - TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSize, TerminalTransport, - TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, -}; - -const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); -const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); +use tokio::sync::{mpsc as tokio_mpsc, watch}; const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); -const AUTH_RETRIES: usize = 3; -static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1); -/// Production remote provider. Its write lock is shared by every connection, -/// making host-key checks and appends to shellr's trust file serial. +/// Production remote-terminal adapter for the shared SSH connector. pub struct SshTerminalTransportProvider { - known_hosts_path: PathBuf, - known_hosts_lock: Arc>, - secrets: SharedSecretStore, + connector: SshConnector, } - impl SshTerminalTransportProvider { pub fn new(path: impl Into, secrets: SharedSecretStore) -> Self { - Self { - known_hosts_path: path.into(), - known_hosts_lock: Arc::new(Mutex::new(())), - secrets, - } + Self::with_connector(SshConnector::new(path, secrets)) + } + pub fn with_connector(connector: SshConnector) -> Self { + Self { connector } } } - impl RemoteTerminalTransportProvider for SshTerminalTransportProvider { fn factory_for(&self, session: &Session) -> SharedTerminalTransportFactory { Arc::new(SshTerminalTransportFactory { config: SshConnectionConfig::from(session), - known_hosts_path: self.known_hosts_path.clone(), - known_hosts_lock: self.known_hosts_lock.clone(), - secrets: self.secrets.clone(), + connector: self.connector.clone(), }) } } - -#[derive(Clone)] -struct SshConnectionConfig { - host: String, - port: u16, - user: String, - auth: AuthKind, - key_path: Option, -} - -impl From<&Session> for SshConnectionConfig { - fn from(session: &Session) -> Self { - Self { - host: session.host.to_string(), - port: session.port, - user: session.user.to_string(), - auth: session.auth, - key_path: session - .key_path - .as_ref() - .map(|path| PathBuf::from(path.as_ref())), - } - } -} - struct SshTerminalTransportFactory { config: SshConnectionConfig, - known_hosts_path: PathBuf, - known_hosts_lock: Arc>, - secrets: SharedSecretStore, + connector: SshConnector, } - impl TerminalTransportFactory for SshTerminalTransportFactory { fn create(&self) -> Box { Box::new(SshTerminalTransport { config: self.config.clone(), - known_hosts_path: self.known_hosts_path.clone(), - known_hosts_lock: self.known_hosts_lock.clone(), - secrets: self.secrets.clone(), + connector: self.connector.clone(), }) } } - struct SshTerminalTransport { config: SshConnectionConfig, - known_hosts_path: PathBuf, - known_hosts_lock: Arc>, - secrets: SharedSecretStore, + connector: SshConnector, } impl TerminalTransport for SshTerminalTransport { @@ -159,7 +111,15 @@ impl SshTerminalTransport { ) -> Result<()> { let (io_tx, mut io_rx) = tokio_mpsc::unbounded_channel(); let (shutdown_tx, shutdown_rx) = watch::channel(false); - let broker = Arc::new(PromptBroker::new(events.clone(), shutdown_rx)); + let prompt_events = events.clone(); + let broker = Arc::new(SshPrompts::new( + Arc::new(move |prompt| { + prompt_events + .try_send(TerminalTransportEvent::Prompt(prompt)) + .is_ok() + }), + shutdown_rx, + )); let router_broker = broker.clone(); let router = tokio::spawn(async move { while let Some(command) = bridge_commands.recv().await { @@ -182,41 +142,8 @@ impl SshTerminalTransport { } }); - let config = client::Config { - keepalive_interval: Some(KEEPALIVE_INTERVAL), - keepalive_max: 3, - nodelay: true, - ..Default::default() - }; - let handler = SshClientHandler { - host: self.config.host.clone(), - port: self.config.port, - known_hosts_path: self.known_hosts_path.clone(), - known_hosts_lock: self.known_hosts_lock.clone(), - broker: broker.clone(), - }; - - let connect = client::connect( - Arc::new(config), - (self.config.host.as_str(), self.config.port), - handler, - ); + let (handle, _) = self.connector.connect(&self.config, broker.clone()).await?; let mut shutdown = broker.shutdown_receiver(); - let mut handle = tokio::select! { - result = timeout_excluding_prompts( - connect, - broker.prompt_activity_receiver(), - CONNECT_TIMEOUT, - ) => { - result?.map_err(|error| anyhow!(safe_connect_error(&error)))? - } - _ = shutdown.changed() => bail!("连接已取消"), - }; - - tokio::select! { - result = authenticate(&mut handle, &self.config, &self.secrets, &broker) => result?, - _ = shutdown.changed() => bail!("连接已取消"), - } let mut channel = tokio::select! { result = handle.channel_open_session() => { result.map_err(|_| anyhow!("无法创建 SSH 会话通道"))? @@ -346,442 +273,6 @@ fn pixel_dimension(cells: usize, cell_size: u16) -> u32 { .min(u32::MAX as usize) as u32 } -struct PromptBroker { - pending: Mutex>>, - events: Sender, - shutdown: watch::Receiver, - prompt_activity: watch::Sender, -} - -impl PromptBroker { - fn new(events: Sender, shutdown: watch::Receiver) -> Self { - let (prompt_activity, _) = watch::channel(false); - Self { - pending: Mutex::new(HashMap::new()), - events, - shutdown, - prompt_activity, - } - } - - fn shutdown_receiver(&self) -> watch::Receiver { - self.shutdown.clone() - } - - fn prompt_activity_receiver(&self) -> watch::Receiver { - self.prompt_activity.subscribe() - } - - async fn ask(&self, kind: TerminalPromptKind) -> Result { - let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed); - let (sender, receiver) = oneshot::channel(); - self.pending - .lock() - .unwrap_or_else(|error| error.into_inner()) - .insert(request_id, sender); - let _ = self.prompt_activity.send(true); - let result = async { - self.events - .send(TerminalTransportEvent::Prompt(TerminalPrompt::new( - request_id, kind, - ))) - .await - .map_err(|_| anyhow!("终端标签页已关闭"))?; - let mut shutdown = self.shutdown.clone(); - tokio::select! { - reply = receiver => reply.map_err(|_| anyhow!("认证请求已取消")), - _ = shutdown.changed() => bail!("连接已取消"), - } - } - .await; - self.pending - .lock() - .unwrap_or_else(|error| error.into_inner()) - .remove(&request_id); - let _ = self.prompt_activity.send(false); - result - } - - async fn emit(&self, kind: TerminalPromptKind) { - let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed); - let _ = self - .events - .send(TerminalTransportEvent::Prompt(TerminalPrompt::new( - request_id, kind, - ))) - .await; - } - - fn respond(&self, request_id: u64, reply: TerminalPromptReply) { - if let Some(sender) = self - .pending - .lock() - .unwrap_or_else(|error| error.into_inner()) - .remove(&request_id) - { - let _ = sender.send(reply); - } - } - - fn cancel_all(&self) { - self.pending - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clear(); - } -} - -/// Apply the network handshake timeout without counting time spent waiting -/// for an explicit answer from the user to a host-trust prompt. -async fn timeout_excluding_prompts( - future: F, - mut prompt_activity: watch::Receiver, - timeout: Duration, -) -> Result -where - F: Future, -{ - tokio::pin!(future); - let mut remaining = timeout; - loop { - if *prompt_activity.borrow() { - tokio::select! { - result = &mut future => return Ok(result), - changed = prompt_activity.changed() => { - if changed.is_err() { - bail!("连接已取消"); - } - } - } - continue; - } - - let started = tokio::time::Instant::now(); - tokio::select! { - result = &mut future => return Ok(result), - _ = tokio::time::sleep(remaining) => bail!("连接超时(15 秒)"), - changed = prompt_activity.changed() => { - if changed.is_err() { - bail!("连接已取消"); - } - remaining = remaining.saturating_sub(started.elapsed()); - if remaining.is_zero() && !*prompt_activity.borrow() { - bail!("连接超时(15 秒)"); - } - } - } - } -} - -struct SshClientHandler { - host: String, - port: u16, - known_hosts_path: PathBuf, - known_hosts_lock: Arc>, - broker: Arc, -} - -impl client::Handler for SshClientHandler { - type Error = anyhow::Error; - - async fn check_server_key( - &mut self, - server_public_key: &russh::keys::PublicKeyOrCertificate, - ) -> Result { - let key = server_public_key.public_key(); - let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); - let algorithm = key.algorithm().to_string(); - let known = { - let _guard = self - .known_hosts_lock - .lock() - .unwrap_or_else(|error| error.into_inner()); - read_known_keys(&self.host, self.port, &self.known_hosts_path)? - }; - if known.iter().any(|(_, saved)| saved == &key) { - return Ok(true); - } - if !known.is_empty() { - let old = known - .iter() - .map(|(_, saved)| saved.fingerprint(HashAlg::Sha256).to_string()) - .collect(); - self.broker - .emit(TerminalPromptKind::host_key_changed( - &self.host, - self.port, - algorithm, - old, - fingerprint, - &self.known_hosts_path, - )) - .await; - return Ok(false); - } - - let reply = self - .broker - .ask(TerminalPromptKind::unknown_host( - &self.host, - self.port, - algorithm, - fingerprint, - )) - .await?; - if !matches!(reply, TerminalPromptReply::TrustAndSave) { - return Ok(false); - } - let _guard = self - .known_hosts_lock - .lock() - .unwrap_or_else(|error| error.into_inner()); - let known = read_known_keys(&self.host, self.port, &self.known_hosts_path)?; - if known.iter().any(|(_, saved)| saved == &key) { - return Ok(true); - } - if !known.is_empty() { - bail!("保存主机密钥时发现信任文件已发生变化"); - } - learn_known_hosts_path(&self.host, self.port, &key, &self.known_hosts_path) - .map_err(|_| anyhow!("无法写入主机信任文件:{}", self.known_hosts_path.display()))?; - Ok(true) - } -} - -fn read_known_keys(host: &str, port: u16, path: &Path) -> Result> { - if path.exists() { - let contents = std::fs::read_to_string(path) - .with_context(|| format!("无法读取主机信任文件:{}", path.display()))?; - for line in contents.lines().map(str::trim) { - if line.is_empty() || line.starts_with('#') { - continue; - } - let mut fields = line.split_whitespace(); - let valid = fields.next().is_some() - && fields.next().is_some() - && fields - .next() - .is_some_and(|encoded| parse_public_key_base64(encoded).is_ok()); - if !valid { - bail!("主机信任文件已损坏:{}", path.display()); - } - } - } - known_host_keys_path(host, port, path) - .map_err(|_| anyhow!("主机信任文件已损坏或无法读取:{}", path.display())) -} - -async fn authenticate( - handle: &mut client::Handle, - config: &SshConnectionConfig, - secrets: &SharedSecretStore, - broker: &PromptBroker, -) -> Result<()> -where - H::Error: From, -{ - let first = handle - .authenticate_none(&config.user) - .await - .map_err(|_| anyhow!("无法查询服务器支持的认证方式"))?; - if first.success() { - return Ok(()); - } - let mut methods = remaining_methods(first); - let mut partial = false; - - if config.auth == AuthKind::Auto - && methods.contains(&MethodKind::PublicKey) - && let Some(result) = try_agent(handle, &config.user).await? - { - if result.success() { - return Ok(()); - } - partial = is_partial(&result); - methods = remaining_methods(result); - } - - if matches!(config.auth, AuthKind::Auto | AuthKind::Key) - && methods.contains(&MethodKind::PublicKey) - { - let paths = key_paths(config)?; - for path in paths { - let Some(key) = load_private_key(&path, secrets, broker).await? else { - continue; - }; - let hash = handle - .best_supported_rsa_hash() - .await - .map_err(|_| anyhow!("无法协商 RSA 签名算法"))? - .flatten(); - let result = handle - .authenticate_publickey( - &config.user, - PrivateKeyWithHashAlg::new(Arc::new(key), hash), - ) - .await - .map_err(|_| anyhow!("私钥认证失败"))?; - if result.success() { - return Ok(()); - } - partial = is_partial(&result); - methods = remaining_methods(result); - if partial { - break; - } - } - if config.auth == AuthKind::Key && !partial { - bail!("服务器未接受指定的私钥"); - } - } - - if matches!(config.auth, AuthKind::Auto | AuthKind::Password) || partial { - if methods.contains(&MethodKind::Password) { - // Try what the session has saved before bothering anyone. - let mut saved_rejected = false; - if let Some(saved) = saved_secret(secrets, &password_secret(config)) { - let result = handle - .authenticate_password(&config.user, saved.to_string()) - .await - .map_err(|_| anyhow!("密码认证失败"))?; - if result.success() { - return Ok(()); - } - // The entry stays: the user typed it into the session dialog, - // and deleting it behind their back would be baffling. This - // connection just falls back to asking, and says why. - saved_rejected = true; - partial = is_partial(&result); - methods = remaining_methods(result); - } - let instructions = if saved_rejected { - "已保存的密码被服务器拒绝,请重新输入" - } else { - "请输入登录密码" - }; - if !partial && methods.contains(&MethodKind::Password) { - for _ in 0..AUTH_RETRIES { - let answer = ask_one_secret(broker, "SSH 登录", instructions, "密码").await?; - let result = handle - .authenticate_password(&config.user, answer.into_inner()) - .await - .map_err(|_| anyhow!("密码认证失败"))?; - if result.success() { - return Ok(()); - } - partial = is_partial(&result); - methods = remaining_methods(result); - if partial || !methods.contains(&MethodKind::Password) { - break; - } - } - } - } - if methods.contains(&MethodKind::KeyboardInteractive) { - for _ in 0..AUTH_RETRIES { - if keyboard_interactive(handle, &config.user, broker).await? { - return Ok(()); - } - } - } - } - bail!("认证失败:服务器未接受可用的认证方式") -} - -async fn try_agent( - handle: &mut client::Handle, - user: &str, -) -> Result> -where - H::Error: From, -{ - #[cfg(unix)] - { - let Ok(mut agent) = AgentClient::connect_env().await else { - return Ok(None); - }; - let Ok(identities) = agent.request_identities().await else { - return Ok(None); - }; - let hash = handle - .best_supported_rsa_hash() - .await - .map_err(|_| anyhow!("无法协商 SSH Agent 签名算法"))? - .flatten(); - let mut last = None; - for identity in identities { - let key = identity.public_key().into_owned(); - match handle - .authenticate_publickey_with(user, key, hash, &mut agent) - .await - { - Ok(result) if result.success() => return Ok(Some(result)), - Ok(result) => last = Some(result), - Err(_) => continue, - } - } - Ok(last) - } - #[cfg(not(unix))] - { - let _ = (handle, user); - Ok(None) - } -} - -fn key_paths(config: &SshConnectionConfig) -> Result> { - if config.auth == AuthKind::Key { - return config - .key_path - .clone() - .map(|path| vec![path]) - .ok_or_else(|| anyhow!("私钥认证需要选择私钥文件")); - } - let Some(home) = dirs::home_dir() else { - return Ok(Vec::new()); - }; - Ok(["id_ed25519", "id_ecdsa", "id_rsa"] - .into_iter() - .map(|name| home.join(".ssh").join(name)) - .collect()) -} - -async fn load_private_key( - path: &Path, - secrets: &SharedSecretStore, - broker: &PromptBroker, -) -> Result> { - if !path.exists() { - return Ok(None); - } - match load_secret_key(path, None) { - Ok(key) => return Ok(Some(key)), - Err(russh::keys::Error::KeyIsEncrypted) => {} - Err(_) => bail!("无法读取私钥文件:{}", path.display()), - } - // Passphrases are saved per key file, so one saved answer unlocks the same - // key for every session that uses it. - let mut saved_rejected = false; - if let Some(saved) = saved_secret(secrets, &SecretRef::passphrase(path)) { - if let Ok(key) = load_secret_key(path, Some(saved.as_str())) { - return Ok(Some(key)); - } - saved_rejected = true; - } - let instructions = if saved_rejected { - format!("已保存的口令无法解开 {},请重新输入", path.display()) - } else { - format!("请输入 {} 的口令", path.display()) - }; - for _ in 0..AUTH_RETRIES { - let answer = ask_one_secret(broker, "私钥口令", &instructions, "口令").await?; - if let Ok(key) = load_secret_key(path, Some(answer.expose())) { - return Ok(Some(key)); - } - } - bail!("私钥口令错误次数过多") -} - /// Open a channel and run one probe command on it. Best effort throughout: a /// server that refuses the channel or the command just leaves the session's /// recorded operating system as it was. @@ -797,118 +288,6 @@ async fn open_probe( Some(channel) } -/// Where this connection's password lives in the system keychain. -fn password_secret(config: &SshConnectionConfig) -> SecretRef { - SecretRef::password(&config.user, &config.host, config.port) -} - -/// Read a saved secret. A keychain that errors, is locked, or holds an empty -/// value counts as nothing saved: the connection then falls back to asking, -/// which is always better than refusing to connect. -/// -/// This blocks. It only ever runs on the SSH worker thread, whose runtime -/// serves this one connection, and only while authentication is already -/// waiting on a person. -fn saved_secret(secrets: &SharedSecretStore, secret: &SecretRef) -> Option> { - secrets - .get(secret) - .ok() - .flatten() - .filter(|value| !value.is_empty()) -} - -async fn ask_one_secret( - broker: &PromptBroker, - title: &str, - instructions: &str, - label: &str, -) -> Result { - let reply = broker - .ask(TerminalPromptKind::authentication( - title, - instructions, - vec![TerminalPromptField::new(label, false)], - )) - .await?; - match reply { - TerminalPromptReply::Answers(mut answers) if answers.len() == 1 => Ok(answers.remove(0)), - TerminalPromptReply::Cancel => bail!("认证已取消"), - _ => bail!("认证回复无效"), - } -} - -async fn keyboard_interactive( - handle: &mut client::Handle, - user: &str, - broker: &PromptBroker, -) -> Result -where - H::Error: From, -{ - let mut response = handle - .authenticate_keyboard_interactive_start(user, None) - .await - .map_err(|_| anyhow!("无法开始交互式认证"))?; - loop { - match response { - KeyboardInteractiveAuthResponse::Success => return Ok(true), - KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false), - KeyboardInteractiveAuthResponse::InfoRequest { - name, - instructions, - prompts, - } => { - let fields = prompts - .into_iter() - .map(|prompt| TerminalPromptField::new(prompt.prompt, prompt.echo)) - .collect(); - let reply = broker - .ask(TerminalPromptKind::authentication( - name, - instructions, - fields, - )) - .await?; - let TerminalPromptReply::Answers(answers) = reply else { - bail!("交互式认证已取消") - }; - response = handle - .authenticate_keyboard_interactive_respond( - answers - .into_iter() - .map(|answer| answer.into_inner()) - .collect(), - ) - .await - .map_err(|_| anyhow!("交互式认证失败"))?; - } - } - } -} - -fn remaining_methods(result: AuthResult) -> MethodSet { - match result { - AuthResult::Success => MethodSet::empty(), - AuthResult::Failure { - remaining_methods, .. - } => remaining_methods, - } -} - -fn is_partial(result: &AuthResult) -> bool { - matches!( - result, - AuthResult::Failure { - partial_success: true, - .. - } - ) -} - -fn safe_connect_error(_: &anyhow::Error) -> &'static str { - "无法建立 SSH 连接,请检查主机、端口和主机密钥" -} - #[cfg(test)] mod tests { use super::*; @@ -916,8 +295,15 @@ mod tests { use crate::session::HostOs; use crate::ssh::probe::{PROBE_COMMAND, WINDOWS_PROBE_COMMAND}; use crate::terminal::TerminalSecret; + use crate::{ + session::AuthKind, + terminal::{TerminalPromptKind, TerminalPromptReply}, + }; + use russh::keys::{PublicKey, known_hosts::learn_known_hosts_path}; use russh::server::{self, Server as _}; + use russh::{MethodKind, MethodSet}; use std::borrow::Cow; + use std::{path::Path, sync::Mutex}; const TEST_PASSWORD: &str = "test-password"; @@ -1747,15 +1133,6 @@ mod tests { assert!(saw_challenge); } - #[test] - fn malformed_known_hosts_is_blocked() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("known_hosts"); - std::fs::write(&path, "this is not a key\n").unwrap(); - let error = read_known_keys("example.test", 22, &path).unwrap_err(); - assert!(error.to_string().contains("已损坏")); - } - #[test] fn changed_host_key_is_reported_and_blocked() { let Some(server) = start_server(TestAuth::Password) else { diff --git a/src/terminal/transport.rs b/src/terminal/transport.rs index ff723f4..b6193ef 100644 --- a/src/terminal/transport.rs +++ b/src/terminal/transport.rs @@ -2,222 +2,16 @@ use std::sync::{Arc, mpsc}; use anyhow::Result; use async_channel::Sender; -use zeroize::Zeroizing; use super::TerminalSize; use crate::session::{HostOs, Session}; -/// One field requested by an SSH keyboard-interactive challenge. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TerminalPromptField { - label: String, - echo: bool, -} - -impl TerminalPromptField { - pub fn new(label: impl Into, echo: bool) -> Self { - Self { - label: label.into(), - echo, - } - } - - pub fn label(&self) -> &str { - &self.label - } - pub fn echo(&self) -> bool { - self.echo - } -} - -/// A transport question that must be answered by the UI before SSH setup can -/// continue. The id is scoped to a single transport generation. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TerminalPrompt { - request_id: u64, - kind: TerminalPromptKind, -} - -impl TerminalPrompt { - pub fn new(request_id: u64, kind: TerminalPromptKind) -> Self { - Self { request_id, kind } - } - - pub fn request_id(&self) -> u64 { - self.request_id - } - pub fn kind(&self) -> &TerminalPromptKind { - &self.kind - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TerminalPromptKind { - UnknownHost(UnknownHostPrompt), - HostKeyChanged(HostKeyChangedPrompt), - Authentication(AuthenticationPrompt), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct UnknownHostPrompt { - host: String, - port: u16, - algorithm: String, - fingerprint: String, -} - -impl UnknownHostPrompt { - pub fn host(&self) -> &str { - &self.host - } - pub fn port(&self) -> u16 { - self.port - } - pub fn algorithm(&self) -> &str { - &self.algorithm - } - pub fn fingerprint(&self) -> &str { - &self.fingerprint - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostKeyChangedPrompt { - host: String, - port: u16, - algorithm: String, - old_fingerprints: Vec, - fingerprint: String, - known_hosts_path: std::path::PathBuf, -} - -impl HostKeyChangedPrompt { - pub fn host(&self) -> &str { - &self.host - } - pub fn port(&self) -> u16 { - self.port - } - pub fn algorithm(&self) -> &str { - &self.algorithm - } - pub fn old_fingerprints(&self) -> &[String] { - &self.old_fingerprints - } - pub fn fingerprint(&self) -> &str { - &self.fingerprint - } - pub fn known_hosts_path(&self) -> &std::path::Path { - &self.known_hosts_path - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AuthenticationPrompt { - title: String, - instructions: String, - fields: Vec, -} - -impl AuthenticationPrompt { - pub fn title(&self) -> &str { - &self.title - } - pub fn instructions(&self) -> &str { - &self.instructions - } - pub fn fields(&self) -> &[TerminalPromptField] { - &self.fields - } -} - -impl TerminalPromptKind { - pub fn unknown_host( - host: impl Into, - port: u16, - algorithm: impl Into, - fingerprint: impl Into, - ) -> Self { - Self::UnknownHost(UnknownHostPrompt { - host: host.into(), - port, - algorithm: algorithm.into(), - fingerprint: fingerprint.into(), - }) - } - - pub fn host_key_changed( - host: impl Into, - port: u16, - algorithm: impl Into, - old_fingerprints: Vec, - fingerprint: impl Into, - known_hosts_path: impl Into, - ) -> Self { - Self::HostKeyChanged(HostKeyChangedPrompt { - host: host.into(), - port, - algorithm: algorithm.into(), - old_fingerprints, - fingerprint: fingerprint.into(), - known_hosts_path: known_hosts_path.into(), - }) - } - - pub fn authentication( - title: impl Into, - instructions: impl Into, - fields: Vec, - ) -> Self { - Self::Authentication(AuthenticationPrompt { - title: title.into(), - instructions: instructions.into(), - fields, - }) - } -} - -/// A secret that is erased when dropped and never prints its contents. -pub struct TerminalSecret(Zeroizing); - -impl TerminalSecret { - pub fn new(value: impl Into) -> Self { - Self(Zeroizing::new(value.into())) - } - pub(crate) fn expose(&self) -> &str { - self.0.as_str() - } - pub(crate) fn into_inner(mut self) -> String { - std::mem::take(&mut *self.0) - } -} - -impl std::fmt::Debug for TerminalSecret { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("TerminalSecret([已隐藏])") - } -} - -/// UI response to a transport prompt. Deliberately not `Clone`: answers may -/// contain passwords, private-key passphrases or one-time codes. -pub enum TerminalPromptReply { - TrustAndSave, - Answers(Vec), - Cancel, -} - -impl std::fmt::Debug for TerminalPromptReply { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::TrustAndSave => formatter.write_str("TrustAndSave"), - Self::Answers(answers) => formatter - .debug_tuple("Answers") - .field(&format_args!("{} 个已隐藏答案", answers.len())) - .finish(), - Self::Cancel => formatter.write_str("Cancel"), - } - } -} +pub use crate::connection::{ + AuthenticationPrompt, ConnectionPrompt as TerminalPrompt, + ConnectionPromptField as TerminalPromptField, ConnectionPromptKind as TerminalPromptKind, + ConnectionPromptReply as TerminalPromptReply, ConnectionSecret as TerminalSecret, + HostKeyChangedPrompt, UnknownHostPrompt, +}; /// Commands accepted by every terminal byte-stream transport. pub enum TerminalTransportCommand { diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 3b20495..e3872b2 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -20,16 +20,21 @@ use gpui_kit::*; use crate::app::{ CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTerminal, ConnectSession, CopyTerminal, - DeleteGroup, DeleteSession, DisconnectSession, DuplicateSession, EditSession, FocusSearch, - NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer, - PasteTerminal, ReconnectTerminal, RenameGroup, RestartLocalTerminal, ToggleSessionPanel, - ToggleTheme, ZoomIn, ZoomOut, ZoomReset, + DeleteGroup, DeleteSession, DisconnectSession, DuplicateSession, EditSession, ExplorerAction, + ExplorerCommand, FocusSearch, NewChildGroup, NewGroup, NewLocalTerminal, NewSession, + NewSessionInGroup, OpenExplorer, PasteTerminal, ReconnectTerminal, RenameGroup, + RestartLocalTerminal, SelectAllUploadFiles, ToggleSessionPanel, ToggleTheme, + ToggleUploadSelection, UploadSelectedFiles, ZoomIn, ZoomOut, ZoomReset, }; -use crate::explorer::{ExplorerPanel, ExplorerPanelEvent}; +use crate::explorer::{ExplorerPanel, ExplorerPanelEvent, confirm_close_upload}; use crate::session::{ ConnectionState, GroupId, SessionId, SessionPanel, SessionStore, SessionStoreEvent, confirm_delete_group, confirm_delete_session, open_group_dialog, open_session_dialog, }; +use crate::sftp::{ + SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider, + SystemLocalDirectoryProvider, +}; use crate::terminal::{ LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent, RemoteTerminalId, SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, @@ -71,6 +76,12 @@ enum CenterTab { LocalTerminal(LocalTerminalId), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PromptOwner { + Terminal(RemoteTerminalId), + Sftp(SessionId, u64), +} + /// The main window content: title bar above the dock, status bar below. /// /// Owns the session store, the dock and the registry of open per-session @@ -87,12 +98,14 @@ pub struct Workspace { local_terminals: HashMap>, local_terminal_factory: SharedTerminalTransportFactory, remote_terminal_provider: SharedRemoteTerminalTransportProvider, + sftp_provider: SharedSftpTransportProvider, + local_directory_provider: SharedLocalDirectoryProvider, next_remote_terminal_id: u64, next_local_terminal_id: u64, /// The center tab displayed most recently; `CloseActiveTab` closes it. active_tab: Option, - prompt_queue: VecDeque<(RemoteTerminalId, SessionId, TerminalPrompt)>, - active_prompt: Option<(RemoteTerminalId, SessionId, u64)>, + prompt_queue: VecDeque<(PromptOwner, SessionId, TerminalPrompt)>, + active_prompt: Option<(PromptOwner, SessionId, u64)>, /// Dispatch target for the title bar and start page: actions sent to it /// reach the workspace handlers whatever is focused. focus_handle: FocusHandle, @@ -103,38 +116,35 @@ impl Workspace { /// `store` is built by `main` from the database on disk, and by the UI /// tests from `SessionStore::seed`. pub fn new(store: Entity, window: &mut Window, cx: &mut Context) -> Self { - let known_hosts = crate::app::known_hosts_path() - .unwrap_or_else(|_| crate::app::data_dir().join("known_hosts")); - let secrets = store.read(cx).secrets(); - let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::new( - known_hosts, - secrets, - )); - Self::new_with_transport_providers( - store, - remote, - Arc::new(LocalPtyTransportFactory), - window, - cx, - ) + Self::new_with_local_terminal_factory(store, Arc::new(LocalPtyTransportFactory), window, cx) } - /// Alternate constructor used by UI tests to avoid launching a real - /// login shell while exercising workspace behavior. pub fn new_with_local_terminal_factory( store: Entity, local_terminal_factory: SharedTerminalTransportFactory, window: &mut Window, cx: &mut Context, ) -> Self { - let known_hosts = crate::app::known_hosts_path() - .unwrap_or_else(|_| crate::app::data_dir().join("known_hosts")); - let secrets = store.read(cx).secrets(); - let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::new( - known_hosts, - secrets, + let connector = crate::ssh::SshConnector::new( + crate::app::data_dir().join("known_hosts"), + store.read(cx).secrets(), + ); + let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::with_connector( + connector.clone(), )); - Self::new_with_transport_providers(store, remote, local_terminal_factory, window, cx) + let sftp = Arc::new(SshSftpTransportProvider::new( + connector, + crate::app::data_dir().join("upload-resume"), + )); + Self::new_with_services( + store, + remote, + local_terminal_factory, + sftp, + Arc::new(SystemLocalDirectoryProvider), + window, + cx, + ) } /// Fully injectable constructor used by UI tests: remote sessions never @@ -145,6 +155,35 @@ impl Workspace { local_terminal_factory: SharedTerminalTransportFactory, window: &mut Window, cx: &mut Context, + ) -> Self { + let connector = crate::ssh::SshConnector::new( + crate::app::data_dir().join("known_hosts"), + store.read(cx).secrets(), + ); + let sftp = Arc::new(SshSftpTransportProvider::new( + connector, + crate::app::data_dir().join("upload-resume"), + )); + Self::new_with_services( + store, + remote_terminal_provider, + local_terminal_factory, + sftp, + Arc::new(SystemLocalDirectoryProvider), + window, + cx, + ) + } + + /// Inject every filesystem and transport service; tests require neither a server nor a keychain. + pub fn new_with_services( + store: Entity, + remote_terminal_provider: SharedRemoteTerminalTransportProvider, + local_terminal_factory: SharedTerminalTransportFactory, + sftp_provider: SharedSftpTransportProvider, + local_directory_provider: SharedLocalDirectoryProvider, + window: &mut Window, + cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); let recent = cx.new(|cx| RecentSessions::new(store.clone(), focus_handle.clone(), cx)); @@ -180,7 +219,9 @@ impl Workspace { .cloned() .collect(); if !panels.is_empty() { - this.cancel_prompts_for_session(*id, window, cx); + for panel in &panels { + this.cancel_prompts_for_terminal(panel.read(cx).id(), window, cx); + } this.store.update(cx, |store, cx| { store.set_state(*id, ConnectionState::Connecting, cx) }); @@ -260,6 +301,8 @@ impl Workspace { local_terminals: HashMap::new(), local_terminal_factory, remote_terminal_provider, + sftp_provider, + local_directory_provider, next_remote_terminal_id, next_local_terminal_id: 1, active_tab: None, @@ -316,7 +359,7 @@ impl Workspace { fn enqueue_prompt( &mut self, - terminal_id: RemoteTerminalId, + terminal_id: PromptOwner, session_id: SessionId, prompt: TerminalPrompt, window: &mut Window, @@ -354,7 +397,7 @@ impl Workspace { let Some(next) = self.prompt_queue.pop_front() else { return; }; - if self.terminals.contains_key(&next.0) { + if self.prompt_owner_is_live(next.0, cx) { break next; } }; @@ -550,7 +593,7 @@ impl Workspace { fn finish_prompt( &mut self, - terminal_id: RemoteTerminalId, + terminal_id: PromptOwner, request_id: u64, reply: TerminalPromptReply, cx: &mut Context, @@ -563,37 +606,74 @@ impl Workspace { } self.active_prompt = None; let canceled = matches!(&reply, TerminalPromptReply::Cancel); - if let Some(panel) = self.terminals.get(&terminal_id).cloned() { - panel.read(cx).reply_to_prompt(request_id, reply, cx); - if canceled { - panel.update(cx, |panel, cx| panel.cancel_connection(cx)); - self.refresh_session_connection_state(session_id, cx); + self.reply_to_owner(terminal_id, request_id, reply, cx); + if canceled + && let PromptOwner::Terminal(id) = terminal_id + && let Some(panel) = self.terminals.get(&id).cloned() + { + panel.update(cx, |panel, cx| panel.cancel_connection(cx)); + } + self.refresh_session_connection_state(session_id, cx); + } + + fn prompt_owner_is_live(&self, owner: PromptOwner, cx: &App) -> bool { + match owner { + PromptOwner::Terminal(id) => self.terminals.contains_key(&id), + PromptOwner::Sftp(id, generation) => self + .explorers + .get(&id) + .is_some_and(|p| p.read(cx).generation() == generation), + } + } + + fn reply_to_owner( + &self, + owner: PromptOwner, + request_id: u64, + reply: TerminalPromptReply, + cx: &App, + ) { + match owner { + PromptOwner::Terminal(id) => { + if let Some(panel) = self.terminals.get(&id) { + panel.read(cx).reply_to_prompt(request_id, reply, cx); + } + } + PromptOwner::Sftp(id, generation) => { + if let Some(panel) = self.explorers.get(&id) + && panel.read(cx).generation() == generation + { + panel.read(cx).reply_to_prompt(request_id, reply); + } } } } + fn cancel_prompts_for_owner( + &mut self, + owner: PromptOwner, + window: &mut Window, + cx: &mut Context, + ) { + self.prompt_queue.retain(|(id, _, _)| *id != owner); + if let Some((id, _, request_id)) = self.active_prompt + && id == owner + { + self.active_prompt = None; + self.reply_to_owner(id, request_id, TerminalPromptReply::Cancel, cx); + if window.has_active_dialog(cx) { + window.close_dialog(cx); + } + } + } fn cancel_prompts_for_terminal( &mut self, terminal_id: RemoteTerminalId, window: &mut Window, cx: &mut Context, ) { - self.prompt_queue.retain(|(id, _, _)| *id != terminal_id); - if let Some((id, _, request_id)) = self.active_prompt - && id == terminal_id - { - self.active_prompt = None; - if let Some(panel) = self.terminals.get(&terminal_id) { - panel - .read(cx) - .reply_to_prompt(request_id, TerminalPromptReply::Cancel, cx); - } - if window.has_active_dialog(cx) { - window.close_dialog(cx); - } - } + self.cancel_prompts_for_owner(PromptOwner::Terminal(terminal_id), window, cx); } - fn cancel_prompts_for_session( &mut self, session_id: SessionId, @@ -601,18 +681,10 @@ impl Workspace { cx: &mut Context, ) { self.prompt_queue.retain(|(_, id, _)| *id != session_id); - if let Some((terminal_id, id, request_id)) = self.active_prompt + if let Some((owner, id, _)) = self.active_prompt && id == session_id { - self.active_prompt = None; - if let Some(panel) = self.terminals.get(&terminal_id) { - panel - .read(cx) - .reply_to_prompt(request_id, TerminalPromptReply::Cancel, cx); - } - if window.has_active_dialog(cx) { - window.close_dialog(cx); - } + self.cancel_prompts_for_owner(owner, window, cx); } } @@ -655,6 +727,13 @@ impl Workspace { | TerminalLifecycle::Closing => {} } } + if let Some(panel) = self.explorers.get(&session_id) { + match panel.read(cx).connection_state() { + ConnectionState::Connected => has_running = true, + ConnectionState::Connecting => has_starting = true, + ConnectionState::Disconnected => {} + } + } let state = if has_running { ConnectionState::Connected } else if has_starting { @@ -709,7 +788,7 @@ impl Workspace { if self.store.read(cx).session(id).is_none() { return; } - let (panel, subscription) = new_explorer_panel(&self.store, id, window, cx); + let (panel, subscription) = new_explorer_panel(self, id, window, cx); self._subscriptions.push(subscription); self.explorers.insert(id, panel.clone()); self.dock_area.update(cx, |area, cx| { @@ -756,10 +835,88 @@ impl Workspace { cx: &mut Context, ) { if let Some(panel) = self.explorers.get(&action.0).cloned() { + let generation = panel.read(cx).generation(); + if panel.read(cx).is_uploading() { + confirm_close_upload(action.0, generation, self.focus_handle.clone(), window, cx); + } else { + self.remove_explorer(action.0, window, cx); + } + } + } + + fn remove_explorer(&mut self, id: SessionId, window: &mut Window, cx: &mut Context) { + if let Some(panel) = self.explorers.get(&id).cloned() { + self.cancel_prompts_for_owner( + PromptOwner::Sftp(id, panel.read(cx).generation()), + window, + cx, + ); self.dock_area .update(cx, |area, cx| area.remove_panel(panel, window, cx)); } } + fn on_explorer_action( + &mut self, + action: &ExplorerAction, + window: &mut Window, + cx: &mut Context, + ) { + let Some(panel) = self.explorers.get(&action.session()).cloned() else { + return; + }; + if action + .generation() + .is_some_and(|g| g != panel.read(cx).generation()) + { + return; + } + if matches!(action.command(), ExplorerCommand::CancelUpload) { + self.cancel_prompts_for_owner( + PromptOwner::Sftp(action.session(), panel.read(cx).generation()), + window, + cx, + ); + } + if matches!(action.command(), ExplorerCommand::CloseConfirmed) { + self.remove_explorer(action.session(), window, cx); + } else { + panel.update(cx, |panel, cx| panel.execute(action.command(), window, cx)); + } + } + fn explorer_shortcut( + &mut self, + command: ExplorerCommand, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(CenterTab::Explorer(id)) = self.active_tab { + self.on_explorer_action(&ExplorerAction::new(id, command), window, cx); + } + } + fn on_upload_selected( + &mut self, + _: &UploadSelectedFiles, + window: &mut Window, + cx: &mut Context, + ) { + self.explorer_shortcut(ExplorerCommand::UploadSelected, window, cx); + } + fn on_toggle_upload_selection( + &mut self, + _: &ToggleUploadSelection, + window: &mut Window, + cx: &mut Context, + ) { + self.explorer_shortcut(ExplorerCommand::ToggleSelection, window, cx); + } + fn on_select_all_upload_files( + &mut self, + _: &SelectAllUploadFiles, + window: &mut Window, + cx: &mut Context, + ) { + self.explorer_shortcut(ExplorerCommand::SelectAll, window, cx); + } fn on_close_local_terminal( &mut self, @@ -854,6 +1011,9 @@ impl Workspace { for terminal in terminals { terminal.update(cx, |terminal, cx| terminal.disconnect(cx)); } + if let Some(panel) = self.explorers.get(&id).cloned() { + panel.update(cx, |panel, cx| panel.disconnect(window, cx)); + } } fn on_reconnect_terminal( @@ -919,7 +1079,14 @@ impl Workspace { let workspace = cx.entity().downgrade(); confirm_delete_session( &session, - closes_tabs, + ( + closes_tabs, + usize::from( + self.explorers + .get(&id) + .is_some_and(|p| p.read(cx).is_uploading()), + ), + ), Rc::new(move |window, cx| { workspace .update(cx, |this, cx| this.remove_session(id, window, cx)) @@ -1018,7 +1185,17 @@ impl Workspace { &name, doomed.len(), subgroups, - closes_tabs, + ( + closes_tabs, + doomed + .iter() + .filter(|id| { + self.explorers + .get(id) + .is_some_and(|p| p.read(cx).is_uploading()) + }) + .count(), + ), Rc::new(move |window, cx| { workspace .update(cx, |this, cx| this.remove_group(id, window, cx)) @@ -1140,9 +1317,14 @@ fn new_terminal_panel( this.refresh_session_connection_state(*session_id, cx); cx.notify(); } - TerminalPanelEvent::PromptRequested(terminal_id, session_id, prompt) => { - this.enqueue_prompt(*terminal_id, *session_id, prompt.clone(), window, cx) - } + TerminalPanelEvent::PromptRequested(terminal_id, session_id, prompt) => this + .enqueue_prompt( + PromptOwner::Terminal(*terminal_id), + *session_id, + prompt.clone(), + window, + cx, + ), TerminalPanelEvent::HostOsDetected(session_id, os) => { let (session_id, os) = (*session_id, *os); this.store @@ -1155,7 +1337,7 @@ fn new_terminal_panel( fn resolve_prompt( workspace: &WeakEntity, - terminal_id: RemoteTerminalId, + terminal_id: PromptOwner, request_id: u64, reply: TerminalPromptReply, cx: &mut App, @@ -1214,22 +1396,51 @@ impl Render for AuthenticationPromptForm { } fn new_explorer_panel( - store: &Entity, + workspace: &Workspace, id: SessionId, window: &mut Window, cx: &mut Context, ) -> (Entity, Subscription) { - let panel = cx.new(|cx| ExplorerPanel::new(id, store.clone(), window, cx)); - let subscription = cx.subscribe( + let panel = cx.new(|cx| { + ExplorerPanel::new( + id, + workspace.store.clone(), + workspace.sftp_provider.clone(), + workspace.local_directory_provider.clone(), + workspace.focus_handle.clone(), + window, + cx, + ) + }); + let subscription = cx.subscribe_in( &panel, - |this, _, event: &ExplorerPanelEvent, _| match event { + window, + |this, _, event: &ExplorerPanelEvent, window, cx| match event { ExplorerPanelEvent::Activated(id) => this.active_tab = Some(CenterTab::Explorer(*id)), ExplorerPanelEvent::Closed(id) => { this.explorers.remove(id); if this.active_tab == Some(CenterTab::Explorer(*id)) { this.active_tab = None; } + this.refresh_session_connection_state(*id, cx); } + ExplorerPanelEvent::StateChanged(id) => { + if let Some(panel) = this.explorers.get(id) { + let generation = panel.read(cx).generation(); + this.prompt_queue.retain(|(owner,_,_)| !matches!(owner,PromptOwner::Sftp(s,g) if s == id && *g != generation)); + if let Some((owner @ PromptOwner::Sftp(s,g),_,_)) = this.active_prompt && s == *id && g != generation { + this.cancel_prompts_for_owner(owner,window,cx); + } + } + this.refresh_session_connection_state(*id,cx); + }, + ExplorerPanelEvent::PromptRequested(id, generation, prompt) => this.enqueue_prompt( + PromptOwner::Sftp(*id, *generation), + *id, + prompt.clone(), + window, + cx, + ), }, ); (panel, subscription) @@ -1296,6 +1507,10 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_disconnect_session)) .on_action(cx.listener(Self::on_reconnect_terminal)) .on_action(cx.listener(Self::on_open_explorer)) + .on_action(cx.listener(Self::on_explorer_action)) + .on_action(cx.listener(Self::on_upload_selected)) + .on_action(cx.listener(Self::on_toggle_upload_selection)) + .on_action(cx.listener(Self::on_select_all_upload_files)) .on_action(cx.listener(Self::on_close_terminal)) .on_action(cx.listener(Self::on_close_explorer)) .on_action(cx.listener(Self::on_close_local_terminal)) diff --git a/tests/ui.rs b/tests/ui.rs index 8088cbc..17f5032 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -21,6 +21,10 @@ use shellr::session::{ AuthKind, ConnectionState, GroupId, HostOs, SessionDatabase, SessionDraft, SessionId, SessionStore, }; +use shellr::sftp::{ + DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath, + SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadRequest, +}; use shellr::terminal::{ FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalPrompt, TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSize, @@ -57,10 +61,12 @@ fn open_workspace_with_store( FakeTerminalFactory::default(), ))); let view = cx.new(|cx| { - Workspace::new_with_transport_providers( + Workspace::new_with_services( store, remote, Arc::new(FakeTerminalFactory::default()), + Arc::new(FakeSftpProvider::default()), + Arc::new(FakeLocalDirectory), window, cx, ) @@ -209,7 +215,15 @@ fn open_workspace_with_factory( FakeTerminalFactory::default(), ))); let view = cx.new(|cx| { - Workspace::new_with_transport_providers(store, remote, factory.clone(), window, cx) + Workspace::new_with_services( + store, + remote, + factory.clone(), + Arc::new(FakeSftpProvider::default()), + Arc::new(FakeLocalDirectory), + window, + cx, + ) }); workspace = Some(view.clone()); Root::new(view, window, cx) @@ -228,10 +242,12 @@ fn open_workspace_with_remote_factory( let store = cx.new(|_| store); let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(factory.clone())); let view = cx.new(|cx| { - Workspace::new_with_transport_providers( + Workspace::new_with_services( store, remote, Arc::new(FakeTerminalFactory::default()), + Arc::new(FakeSftpProvider::default()), + Arc::new(FakeLocalDirectory), window, cx, ) @@ -618,7 +634,7 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA } #[gpui_kit::test] -fn opening_mock_sftp_does_not_change_connection_state(cx: &mut TestAppContext) { +async fn opening_sftp_updates_connection_state_without_terminal(cx: &mut TestAppContext) { let (store, id) = one_session_store(AuthKind::Auto); let (handle, workspace) = open_workspace_with_store(cx, store); @@ -627,14 +643,24 @@ fn opening_mock_sftp_does_not_change_connection_state(cx: &mut TestAppContext) { window.dispatch_action(Box::new(OpenExplorer(id)), cx); }) .unwrap(); - cx.run_until_parked(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + workspace + .read(cx) + .store() + .read(cx) + .session(id) + .unwrap() + .state + == ConnectionState::Connected + }) + .await; cx.update(|cx| { let workspace = workspace.read(cx); assert!(workspace.explorer(id).is_some()); assert_eq!( workspace.store().read(cx).session(id).unwrap().state, - shellr::session::ConnectionState::Disconnected + shellr::session::ConnectionState::Connected ); }); } @@ -863,7 +889,7 @@ fn search_filters_the_tree(cx: &mut TestAppContext) { } #[gpui_kit::test] -fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) { +async fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); cx.update_window(handle.into(), |_, window, cx| { @@ -883,6 +909,14 @@ fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) { .unwrap(); cx.run_until_parked(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window + .try_find("remote-path") + .is_some_and(|p| p.value() == Some("~")) + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert!(window.find(("explorer", DB_01)).visible()); @@ -895,6 +929,11 @@ fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) { .unwrap(); cx.run_until_parked(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("remote-path").value() == Some("/home") + }) + .await; cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); assert_eq!(window.find("remote-path").value(), Some("/home")); @@ -2127,3 +2166,617 @@ async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut }) .unwrap(); } + +#[derive(Default)] +struct FakeSftpProvider { + requests: Arc>>, + events: Arc>>>, +} +impl SftpTransportProvider for FakeSftpProvider { + fn create(&self, _: &shellr::session::Session) -> Box { + Box::new(FakeSftpTransport { + requests: self.requests.clone(), + events: self.events.clone(), + }) + } +} +struct FakeSftpTransport { + requests: Arc>>, + events: Arc>>>, +} +impl SftpTransport for FakeSftpTransport { + fn run( + self: Box, + commands: async_channel::Receiver, + events: async_channel::Sender, + ) -> anyhow::Result<()> { + use shellr::sftp::{UploadChoice, UploadPhase, UploadProgress}; + self.events.lock().unwrap().push(events.clone()); + events.send_blocking(SftpEvent::Connected { + home: RemotePath::new("/home/tester")?, + })?; + while let Ok(command) = commands.recv_blocking() { + match command { + SftpCommand::List { request_id, path } => { + if path.as_str() == "/slow" { + continue; + } + let result = match path.as_str() { + "/denied" => Err("权限不足".into()), + "/empty" => Ok(DirectoryListing::new("/empty", vec![])), + _ => Ok(fake_listing(path.as_str())), + }; + events.send_blocking(SftpEvent::Listed { request_id, result })?; + } + SftpCommand::Upload(request) => { + self.requests.lock().unwrap().push(request); + events.send_blocking(SftpEvent::Progress(Default::default()))?; + } + SftpCommand::Cancel => { + events.send_blocking(SftpEvent::Progress(UploadProgress::new( + UploadPhase::Stopped, + )))?; + } + SftpCommand::Resume => { + events.send_blocking(SftpEvent::Progress(UploadProgress::new( + UploadPhase::Uploading, + )))?; + } + SftpCommand::Answer { answer, .. } => { + events.send_blocking(SftpEvent::Progress(UploadProgress::new( + if answer.choice() == UploadChoice::Cancel { + UploadPhase::Stopped + } else { + UploadPhase::Completed + }, + )))?; + } + SftpCommand::Shutdown => break, + _ => {} + } + } + Ok(()) + } +} +struct FakeLocalDirectory; +impl LocalDirectoryProvider for FakeLocalDirectory { + fn home(&self) -> std::path::PathBuf { + "/local/tester".into() + } + fn list(&self, path: &std::path::Path) -> anyhow::Result { + Ok(fake_listing(path.to_str().unwrap())) + } +} +fn fake_listing(path: &str) -> DirectoryListing { + DirectoryListing::new( + path, + vec![ + DirectoryEntry::new( + "文件 甲.txt", + FileMetadata::new(EntryKind::File, 12, Some(100), Some(0o644)), + ), + DirectoryEntry::new( + "文件 乙.txt", + FileMetadata::new(EntryKind::File, 34, Some(200), Some(0o644)), + ), + DirectoryEntry::new( + "目录", + FileMetadata::new(EntryKind::Directory, 0, None, Some(0o755)), + ), + ], + ) +} + +fn open_workspace_with_sftp( + cx: &mut TestAppContext, + provider: Arc, +) -> (WindowHandle, Entity) { + cx.update(shellr::init); + let mut workspace = None; + let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { + let store = cx.new(|_| SessionStore::seed()); + let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new( + FakeTerminalFactory::default(), + ))); + let view = cx.new(|cx| { + Workspace::new_with_services( + store, + remote, + Arc::new(FakeTerminalFactory::default()), + provider, + Arc::new(FakeLocalDirectory), + window, + cx, + ) + }); + workspace = Some(view.clone()); + Root::new(view, window, cx) + }); + (handle, workspace.unwrap()) +} +async fn open_test_explorer(cx: &mut TestAppContext, handle: WindowHandle) { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window + .try_find("remote-path") + .is_some_and(|p| p.value() == Some("~")) + && window + .within(("local-pane", DB_01)) + .try_find("file:文件 甲.txt") + .is_some() + }) + .await; +} + +#[gpui_kit::test] +async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes( + cx: &mut TestAppContext, +) { + use shellr::app::{ExplorerAction, ExplorerCommand}; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone()); + open_test_explorer(cx, handle).await; + cx.update_window(handle.into(), |_, window, cx| { + window + .within(("local-pane", DB_01)) + .click("file:文件 甲.txt", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("space", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx) + .local() + .read(cx) + .upload_sources(), + vec![std::path::PathBuf::from("/local/tester/文件 甲.txt")] + ); + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("cmd-a", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx) + .local() + .read(cx) + .upload_sources() + .len(), + 3 + ); + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("f5", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("upload-confirm").visible()); + window.click("upload-target", cx); + window.press("cmd-a", cx); + window.input("/固定目标", cx); + window.click("upload-confirm", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("cancel-upload").is_some() + }) + .await; + assert_eq!( + provider.requests.lock().unwrap()[0].destination().as_str(), + "/固定目标" + ); + assert_eq!(provider.requests.lock().unwrap()[0].sources().len(), 3); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action( + Box::new(ExplorerAction::new( + SessionId(DB_01), + ExplorerCommand::Navigate { + remote: true, + path: "/other".into(), + }, + )), + cx, + ); + window.click("cancel-upload", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("resume-upload").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("resume-upload", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("cancel-upload").is_some() + }) + .await; + assert_eq!(provider.requests.lock().unwrap().len(), 1); + assert_eq!( + provider.requests.lock().unwrap()[0].destination().as_str(), + "/固定目标" + ); +} + +#[gpui_kit::test] +async fn sftp_native_picker_and_external_drop_share_confirmation(cx: &mut TestAppContext) { + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("choose-upload", cx); + }) + .unwrap(); + cx.run_until_parked(); + assert!(cx.did_prompt_for_paths()); + cx.simulate_path_prompt_response(|options| { + assert!(options.files && options.directories && options.multiple); + Some(vec!["/tmp/甲".into(), "/tmp/目录".into()]) + }); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("upload-confirm").visible()); + assert_eq!(window.find("upload-target").value(), Some("/home/tester")); + window.press("escape", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.within(("remote-pane", DB_01)).hover("file:目录", cx); + window.render_frame(cx); + let position = window + .within(("remote-pane", DB_01)) + .find("file:目录") + .bounds() + .center(); + let paths = + gpui_kit::ExternalPaths(vec![std::path::PathBuf::from("/tmp/Finder 文件")].into()); + window.dispatch_event( + gpui_kit::FileDropEvent::Entered { position, paths }.to_platform_input(), + cx, + ); + window.dispatch_event( + gpui_kit::FileDropEvent::Submit { position }.to_platform_input(), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("upload-confirm").visible()); + assert_eq!( + window.find("upload-target").value(), + Some("/home/tester/目录") + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +async fn sftp_internal_drag_conflict_and_close_confirmation(cx: &mut TestAppContext) { + use shellr::sftp::{UploadQuestion, UploadQuestionKind}; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone()); + open_test_explorer(cx, handle).await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let from = window + .within(("local-pane", DB_01)) + .find("file:文件 甲.txt") + .bounds() + .center(); + let to = window + .within(("remote-pane", DB_01)) + .find("file:目录") + .bounds() + .center(); + window.drag(from, to, cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!( + window.find("upload-target").value(), + Some("/home/tester/目录") + ); + window.click("upload-confirm", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("cancel-upload").is_some() + }) + .await; + provider.events.lock().unwrap()[0] + .send_blocking(SftpEvent::Question(UploadQuestion::new( + 900, + UploadQuestionKind::Conflict, + "/home/tester/目录/文件 甲.txt", + "目标已存在", + ))) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("upload-question-confirm").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("upload-apply-all", cx); + window.click("upload-question-cancel", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("resume-upload").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("resume-upload", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("cancel-upload").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("close-explorer", DB_01), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("ok").visible()); + assert!(workspace.read(cx).explorer(SessionId(DB_01)).is_some()); + window.click("ok", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| assert!(workspace.read(cx).explorer(SessionId(DB_01)).is_none())); +} + +#[gpui_kit::test] +async fn sftp_sort_range_selection_and_dialog_focus_preserve_path_identity( + cx: &mut TestAppContext, +) { + use shellr::app::{ExplorerAction, ExplorerCommand}; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within(("local-pane", DB_01)) + .click("check:文件 乙.txt", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within(("local-pane", DB_01)) + .click(("col-header", 1usize), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within(("local-pane", DB_01)) + .click(("col-header", 1usize), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx) + .local() + .read(cx) + .upload_sources(), + vec![std::path::PathBuf::from("/local/tester/文件 乙.txt")] + ) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action( + Box::new(ExplorerAction::new( + SessionId(DB_01), + ExplorerCommand::Check { + name: "文件 甲.txt".into(), + checked: true, + extend: true, + }, + )), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx) + .local() + .read(cx) + .upload_sources() + .len(), + 2 + ) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within(("local-pane", DB_01)) + .click("file:文件 甲.txt", cx); + window.press("f5", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("upload-confirm").visible()); + window.press("escape", cx); + }) + .unwrap(); + cx.run_until_parked(); + // F5 works again without clicking a pane after dismissing the dialog. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("f5", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("upload-confirm").visible()); + }) + .unwrap(); +} + +#[gpui_kit::test] +async fn sftp_discards_stale_directory_replies(cx: &mut TestAppContext) { + use shellr::app::{ExplorerAction, ExplorerCommand}; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone()); + open_test_explorer(cx, handle).await; + for path in ["/slow", "/fresh"] { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action( + Box::new(ExplorerAction::new( + SessionId(DB_01), + ExplorerCommand::Navigate { + remote: true, + path: path.into(), + }, + )), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + } + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx) + .remote() + .read(cx) + .path() + == "/fresh" + }) + .await; + provider.events.lock().unwrap()[0] + .send_blocking(SftpEvent::Listed { + request_id: 2, + result: Ok(fake_listing("/slow")), + }) + .unwrap(); + // Follow with an ordinary command and wait for its reply to ensure both events were consumed. + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + SessionId(DB_01), + ExplorerCommand::Refresh { remote: true }, + )), + cx, + ); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("remote-path").value() == Some("/fresh") + }) + .await; + cx.update(|cx| { + assert_eq!( + workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx) + .remote() + .read(cx) + .path(), + "/fresh" + ) + }); +} + +#[gpui_kit::test] +async fn sftp_controls_fit_small_window_in_light_dark_and_zoom(cx: &mut TestAppContext) { + use gpui_kit::component::{Theme, ThemeMode}; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + for mode in [ThemeMode::Light, ThemeMode::Dark] { + for zoom in [12., 20.] { + cx.update_window(handle.into(), |_, window, cx| { + Theme::change(mode, Some(window), cx); + Theme::global_mut(cx).font_size = px(zoom); + window.resize(size(px(960.), px(600.))); + window.render_frame(cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let local = window.find(("local-pane", DB_01)).bounds(); + let upload = window.find("upload").bounds(); + let choose = window.find("choose-upload").bounds(); + assert!(upload.left() >= local.left() && choose.right() <= local.right()); + assert_eq!(upload.top(), choose.top()); + assert!(window.find("remote-path").visible()); + }) + .unwrap(); + } + } +}