diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 0061629..90f2e8d 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -24,6 +24,7 @@ on: - "src/sftp/download.rs" - "src/sftp/model.rs" - "src/sftp/upload.rs" + - "src/sftp/sync.rs" - "src/credential/credential_dialog.rs" - "src/settings/settings_panel.rs" - "src/app/shortcuts.rs" diff --git a/CLAUDE.md b/CLAUDE.md index 4812635..808ab0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。 -- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。暂不做目录同步、过滤、查找、目录树和 Dock 布局持久化。 +- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。SFTP 标签暂不做目录同步(外部 CLI 有 `shellrs sync`)、过滤、查找、目录树和 Dock 布局持久化。 - **主机树**:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。 - **主机对话框**: - 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、`~/.ssh` 默认私钥,服务器要密码就报错、不询问)。主机自己没有私钥文件,要用私钥就建密钥凭据。 @@ -35,6 +35,11 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 临时连接的密码只在内存。 - 外部连接只认命令行参数,不注册系统的 `ssh://` 链接;打开时收起左侧栏。 - 外部 CLI 照样列出这两种主机,分组写「(未保存)」。 +- **外部 CLI**:`hosts` / `credentials` 的 `list`、`show`、`create`、`update`、`delete`,以及 `exec`、`upload`、`download`、`sync`,写法对齐 termark 的 skill。对象叫 host / host-id,不学 termark 的 asset(ShellRS 只有「主机」一种)。没有顶层的 `shellrs list`(早先的写法,已去掉,不兼容),列主机用 `hosts list`。不做交互式的 `shell`(给人在自己终端里用,Agent 用不上)。 + - 改主机和凭据跟着「启用外部 CLI」走,不另设开关;校验和文案同对话框。 + - 秘密只写不读:JSON 里的 `password` / `passphrase` / `private_key` 进钥匙串或 `keys/`,任何输出都没有,`show` 只说有没有保存,ShellRS 保存的私钥连路径也不给。 + - 删除不确认:`hosts delete` 遇到有打开标签的主机报 `host_in_use`,`--force` 才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能 `show`。 + - `sync` 只从本机到主机,大小和修改时间都没变的跳过;`--delete` 不删 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录不清理,拒绝同步到 `/`。 - **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。 - **右侧栏**:窗口最右一列竖排的工具切换。 - 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。 @@ -127,7 +132,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `monitor/`、`netstat/`、`processes/`、`services/`、`docker/`、`history/`、`snippets/`:右侧栏的七个工具。各自是「命令(`linux.rs` 等)+ 纯解析和模型(`model.rs`)+ 面板」,确认和通知在工作区。 - `secrets/`:`SecretStore`(钥匙串)、`SecretRef`(条目归属)、`TemporarySecretStore`(不保存的连接的密码只在内存)。 - `settings/`:`AppSettings`、写 `settings.json` 的 `SettingsStore`、把设置落到窗口上的 `apply`、设置标签。 -- `cli/`:外部 CLI(`shellrs list/exec/upload/download`,给 AI Agent 用)、套接字协议、应用里的服务端、PATH 和 Agent skill 的安装。 +- `cli/`:外部 CLI(`shellrs hosts/credentials/exec/upload/download/sync`,给 AI Agent 用)、套接字协议、应用里的服务端、主机和凭据改动的校验(`manage.rs`)、PATH 和 Agent skill 的安装。 - `update/`:在线升级,包括清单、验签、下载、各平台安装器和 `Updater`。 - `workspace/`:窗口壳。持有 store、`DockArea`、各类标签的注册表和**全部动作处理器**,按领域拆成多个文件(`forwards.rs`、`credentials.rs`、`links.rs`、`editors.rs`、`tools.rs`、`updates.rs`…)。另有标题栏、状态栏、左侧栏 `Sidebar`、右侧栏 `ToolSidebar`、开始页。 - `shared/`:多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`SegmentedControl`、`RowTooltips`、`confirm_danger`、`form_error_notification` 等)。 @@ -153,7 +158,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 启动时 `main` 先读设置和 `window.json` 再开窗口,`window_options` 按两个开关算 `window_bounds` 和 `display_id`。 - 屏幕按 `PlatformDisplay::uuid` 认(`DisplayId` 重启会变)。GPUI 的窗口坐标在 macOS 上从所在屏幕左上角算,别处是整个桌面,原样存、原样交回,配上同一个 `display_id` 就能落回原处。 - macOS 的 `window_bounds()` 从不报最大化,靠 `is_maximized()`(大小等于屏幕可见区)判断,恢复时给可见区大小的普通窗口;别的平台用 `WindowBounds::Maximized`。 -- **一个数据目录只跑一个 ShellRS。** 起图形界面之前先连外部 CLI 的套接字:连得上就发 `Request::Activate`(带启动链接)然后退出。为兼容已装的 CLI,不升 `PROTOCOL_VERSION`。 +- **一个数据目录只跑一个 ShellRS。** 起图形界面之前先连外部 CLI 的套接字:连得上就发 `Request::Activate`(带启动链接)然后退出。为兼容已装的 CLI,不升 `PROTOCOL_VERSION`,新请求只加变体。 - **临时主机在 `HostStore` 的 `temporary` 里**:`host()` / `login()` 找得到,`hosts()` 看不到,也不写库。会写外键的检查用 `saved_host`。最后一个标签关掉就 `remove_temporary`。 ### GPUI / gpui-kit @@ -254,6 +259,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - Unix 套接字 0600,并用 `peer_cred` 核对 uid。 - Windows 用命名管道:带 DACL 和 `FIRST_PIPE_INSTANCE`;客户端以 `SECURITY_IDENTIFICATION` 打开,并核对属主;用同步的 Win32 管道;关句柄时不 `DisconnectNamedPipe`。 - exec 和传输都不许挂住:非交互,陌生主机直接答否,问题按固定策略回答。 + - 读(`list`、`show`)走 UI 线程推过去的快照(`CliServer::set_hosts`);`password_saved` 只在 show 时经 `CliBackend::is_saved` 查钥匙串。 + - 改主机和凭据只能在 UI 线程做:请求线程把 `CliChange` 放进服务端的队列后等着,工作区每 100 ms 取一条(`serve_cli`,和「再次打开」共用),经 `apply_cli_change` 做完、快照刷新后再回复,然后才取下一条。超时只算排队的时间:没轮到就在锁里撤回、保证没做;已经开始的一直等,免得 Agent 重试做出两份。服务端 drop 时清空队列。 + - `cli/manage.rs` 先校验、解析 ID 和分组路径,再交给 store:store 的 `normalized` 会把不存在的凭据、跳板悄悄改掉,CLI 要先拒绝。主机的密码随地址变化搬到新条目时,先在后台读旧值再改 store(store 会删没人用的旧条目)。 + - 写库失败靠 `HostStore::capture_failures` 收集(照常发 `PersistFailed`),钥匙串写入用 `write_secret` 等结果,都报 `save_failed`。 + - 凭据对外的 ID 就是 `keychain_id`,发布后不能改。请求里的秘密用 `Secret`,`Debug` 不露明文。 + - 协议只加变体、不升 `PROTOCOL_VERSION`:旧应用对新请求回 serde 的「unknown variant」,客户端改说成 `version_mismatch`。服务端先读版本再解析请求。 - `exec --json` 的输入输出都是 JSON,输出只用 ASCII(`ascii_json` 转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。 - UI 测试不监听真实套接字,`CliIntegration` 默认没有路径。 - **Windows**: diff --git a/README.en.md b/README.en.md index 83cc3d3..835028e 100644 --- a/README.en.md +++ b/README.en.md @@ -84,7 +84,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database. **A CLI for AI agents** -- `shellrs list` / `exec` / `upload` / `download`. The running ShellRS logs in on the command's behalf, so the command itself never handles a password or private key. +- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`: manage hosts and credentials, run commands, move and sync files. The running ShellRS logs in on the command's behalf, so the command itself never reads a password or private key. - Install `shellrs` into your PATH and an Agent Skill for Claude Code, Codex, OpenCode or WorkBuddy from the settings page. **Security and updates** @@ -141,10 +141,11 @@ Once installed, ShellRS keeps itself up to date: it checks, downloads and verifi In 设置 › 外部 CLI (Settings › External CLI), turn on 启用外部 CLI (Enable external CLI), then install the `shellrs` command and the Agent Skills. Your agent can then use the hosts you have saved: ```sh -shellrs list # list hosts and their IDs +shellrs hosts list -q web # list hosts and their IDs shellrs exec "uptime && df -h" # run a command; the exit code is the remote one shellrs upload ./dist/app.tar.gz /opt/app/ # upload shellrs download /var/log/syslog ./logs/ # download +shellrs sync ./dist /opt/app --delete # sync a folder, skipping unchanged files ``` The command hands each request to the running ShellRS, which logs in with the saved settings and keychain. An unknown host or a missing password fails right away with a stable error code instead of a prompt that would hang the agent. See the [user manual](docs/manual.md#外部-cli) (in Chinese) for details. diff --git a/README.md b/README.md index d67a887..ac7ffc9 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF **给 AI Agent 用的 CLI** -- `shellrs list` / `exec` / `upload` / `download`,由正在运行的 ShellRS 代为登录,命令本身不接触密码和私钥。 +- `shellrs hosts` / `credentials` / `exec` / `upload` / `download` / `sync`:管理主机和凭据、执行命令、传输和同步文件。由正在运行的 ShellRS 代为登录,命令本身不读密码和私钥。 - 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode、WorkBuddy 安装 Agent Skill。 **安全与更新** @@ -141,10 +141,11 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF 在「设置 › 外部 CLI」里打开「启用外部 CLI」,安装 `shellrs` 命令和 Agent Skills。之后 Agent 就能直接用你保存的主机: ```sh -shellrs list # 列出主机和它们的 ID +shellrs hosts list -q web # 列出主机和它们的 ID shellrs exec "uptime && df -h" # 执行命令,退出码就是远程命令的退出码 shellrs upload ./dist/app.tar.gz /opt/app/ # 上传 shellrs download /var/log/syslog ./logs/ # 下载 +shellrs sync ./dist /opt/app --delete # 同步目录,没变的文件跳过 ``` 命令把请求交给正在运行的 ShellRS,由它用保存的配置和钥匙串登录。遇到陌生主机或缺少密码时,命令直接失败、返回稳定的错误码,不会弹框卡住 Agent。详见[使用手册](docs/manual.md#外部-cli)。 diff --git a/docs/manual.md b/docs/manual.md index ae45391..bfbf101 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -49,7 +49,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - **功能齐全**:在新标签页中连接、打开 SFTP、重新连接和右侧栏的全部工具都能用。连接方式是直接连接。 - **不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」。 - **密码只在内存里**:填的密码不进钥匙串,重新连接时照样用它;没填时服务器要密码就弹框问。选「使用凭据」时用凭据保存的密码或私钥。 -- 外部 CLI 的 `shellrs list` 也列出它(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时可以用它的 ID。 +- 外部 CLI 的 `shellrs hosts list` 也列出它(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时可以用它的 ID。 要保存的主机在主机列表标题栏的「+」、右键菜单或 ⌘N(其他平台 Ctrl+N)新建。 @@ -98,7 +98,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - **删除**:右键「删除」。有主机在用时确认框写明数量和它们之后怎么登录:用密码凭据的主机改为「密码」,连接时询问;用密钥或 SSH Agent 凭据的主机改为「无密码」。用户名不变,密码和私钥不会复制过去。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。 - **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。「无密码」的主机也会尝试同一个 Agent,连不上时静默跳过,接着试默认私钥(Windows 上同样如此)。 -**尚未实现:在主机对话框里直接新建凭据、通过外部 CLI 管理凭据、凭据排序、自定义 SSH Agent 的套接字、把公钥自动装到主机上、导出 ShellRS 保存的私钥。** +**尚未实现:在主机对话框里直接新建凭据、凭据排序、自定义 SSH Agent 的套接字、把公钥自动装到主机上、导出 ShellRS 保存的私钥。** ## 设置 @@ -120,11 +120,11 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 ## 外部 CLI -`shellrs` 命令让 Codex、Claude Code、OpenCode 等 AI Agent 在 ShellRS 里已保存的主机上执行远程命令、传输文件。命令本身不接触密码和私钥,也不读数据库:它把请求交给正在运行的 ShellRS,由 ShellRS 用保存的配置和钥匙串去连接。请求走本机的进程间通信:macOS、Linux 上是数据目录下的套接字 `cli.sock`,Windows 上是命名管道 `\\.\pipe\shellrs-cli-<数据目录的散列>`。 +`shellrs` 命令让 Codex、Claude Code、OpenCode 等 AI Agent 在 ShellRS 里已保存的主机上执行远程命令、传输和同步文件,也能查看、新建、修改和删除主机与凭据。命令本身不读密码和私钥,也不读数据库:它把请求交给正在运行的 ShellRS,由 ShellRS 用保存的配置和钥匙串去连接。请求走本机的进程间通信:macOS、Linux 上是数据目录下的套接字 `cli.sock`,Windows 上是命名管道 `\\.\pipe\shellrs-cli-<数据目录的散列>`。 在 设置 → 外部 CLI 中: -- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。 +- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据(没有另外的开关)。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。 - **CLI 二进制**:把 `shellrs` 放进 PATH。 - macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。 - Linux:链接到 `~/.local/bin/shellrs`。 @@ -135,17 +135,29 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 命令(`shellrs --help` 查看完整说明): ```sh -shellrs list [--query <关键词>] [--json] # 列出主机;输出不是终端时自动用 JSON +shellrs hosts list [-q <关键词>] [--json] # 列出主机;输出不是终端时自动用 JSON +shellrs hosts show [--json] # 一台主机的全部配置 shellrs exec "<命令>" # 或 --stdin 从标准输入读命令 shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON shellrs upload <本地路径> <远程路径> shellrs download <远程路径> <本地路径> +shellrs sync <本地目录> <远程目录> [--delete] +shellrs hosts create | update | delete [--force] +shellrs credentials list | show | create | update | delete ``` -- `` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。 +- `` 是 16 位的主机 ID,即 `shellrs hosts list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。 - `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。 - 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。 -- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。 +- `sync` 只从本机到主机:把本地目录里的内容同步进远程目录。远程目录不存在就新建(父目录必须存在),是文件就报错,不能是根目录 `/`。大小和修改时间都没变的文件、指向不变的链接跳过,摘要里计为「未变」,所以再同步一次只传改过的;其余文件直接覆盖,新文件保留可执行权限。`--delete` 先删掉远程目录里本地没有的、或类型不同的项(不跟随链接),但不删传输进行中留下的 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录也不清理;删不掉的项计为失败。 +- `hosts` 和 `credentials` 管理保存的主机和凭据。`create`、`update` 从标准输入读一个 JSON 对象(`update` 只改给出的字段),字段见 `shellrs hosts create --help` 和 `shellrs credentials create --help`;`show --json` 的输出可以改了直接交回 `update`。校验和对话框一样,报错用对话框的说法: + - 主机:`name`、`host`(地址)、`port`(默认 22)、`user`(默认 root)、`group`(分组路径如 `生产/数据库`,不存在就逐级新建;同一路径有不止一个分组时报错)、`auth`(`password` / `credential` / `no_password`)、`credential`(凭据 ID)、`password`、`route`(直接连接、按 ID 列出的跳板主机,或 HTTP / SOCKS5 代理)、`notes`。跳板只能是保存的主机,不能是自己。改了地址、端口或用户而没给密码时,保存的密码跟着搬过去,同对话框。 + - 凭据:`name`、`kind`(`password` / `key` / `agent`)、`user`、`password`、`key_path`(本机私钥文件)或 `private_key`(私钥全文,同对话框的「粘贴」,存进数据目录的 `keys/`)、`passphrase`。凭据的 ID 是 16 位随机串,`credentials list` 里有。 + - 密码、口令和私钥只写不读:写进系统钥匙串或 `keys/`,任何命令都不输出,`show` 只说有没有保存;ShellRS 保存的私钥连路径也不显示。 + - 临时连接和外部连接能 `show`,不能修改或删除。 + - `hosts delete` 不确认,连同主机的端口转发规则一起删;主机在 ShellRS 里有打开的标签时拒绝(`host_in_use`),加 `--force` 才关掉标签再删。经它跳转的主机,那一跳变成「已删除的主机」。`credentials delete` 也不确认,用它的主机改为自己登录(同界面)。 + - 改动由 ShellRS 一条一条执行:10 秒内没轮到就撤回、不做;开始执行后会等到完成。 +- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令)。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。 - `exec --json` 从标准输入读 `{"host": "", "command": "<命令>"}`,命令结束后输出 `{"exit_code", "stdout", "stderr"}`,出错时输出 `{"error": {"code", "message"}}`,退出码和不加 `--json` 时一样。输出只含 ASCII,中文等字符都转成 `\uXXXX`;不是 UTF-8 的输出换成 U+FFFD。命令不经过本机 shell 的引号处理,控制台的代码页也不会把输出弄乱,适合 Windows PowerShell 和需要分开读标准输出、标准错误的 Agent。 - 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里执行命令优先用 `exec --json`(`@{ host = ''; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec --stdin`),并和 `list`、`upload`、`download` 一样,先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`,否则中文会变成问号或乱码。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。 @@ -170,7 +182,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] - Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。 - macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。 - Linux:AppImage 本身。 -- 外部 CLI 的 `shellrs list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。 +- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。 - 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。 ## 标签页 @@ -384,7 +396,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以 文件名不能准确表示时会明确报错。当前 `russh-sftp 3.0.0` 的远端字符串解码会替换无效 UTF-8,浏览器因此拒绝包含替换字符 `U+FFFD` 的远端名称,避免访问错误路径。符号链接沿用 OpenSSH 的 SFTP v3 参数顺序。 -**尚未实现:目录同步、前台传输进度对话框、队列排序、过滤、查找文件、目录树和 Dock 布局持久化。** +**尚未实现:SFTP 标签里的目录同步(外部 CLI 的 `shellrs sync` 可以同步)、前台传输进度对话框、队列排序、过滤、查找文件、目录树和 Dock 布局持久化。** ## 下载与更新 diff --git a/src/cli/SKILL.md b/src/cli/SKILL.md index c5693b1..27a912c 100644 --- a/src/cli/SKILL.md +++ b/src/cli/SKILL.md @@ -1,25 +1,27 @@ --- name: shellrs -description: This skill should be used when the user asks to "connect to a server", "SSH into a machine", "run a command on a remote host", "check a server", "list servers" or "list SSH sessions", "upload/download files to a server", or mentions ShellRS or the `shellrs` CLI. Use `shellrs` instead of running `ssh`, `scp`, or `sftp` directly. +description: This skill should be used when the user asks to "connect to a server", "SSH into a machine", "run a command on a remote host", "check a server", "list servers" or "list SSH sessions", "upload/download or sync files to a server", "add, change or remove a saved host or credential", or mentions ShellRS or the `shellrs` CLI. Use `shellrs` instead of running `ssh`, `scp`, or `sftp` directly. --- # ShellRS CLI -Use the `shellrs` CLI to list the user's saved SSH hosts, run one-off commands, and transfer files through the user's running ShellRS desktop app. ShellRS holds the credentials and connects with the saved configuration. +Use the `shellrs` CLI to manage the user's saved SSH hosts and credentials, run one-off commands, and transfer files through the user's running ShellRS desktop app. ShellRS holds the credentials and connects with the saved configuration. Prefer `shellrs` over running `ssh`, `scp`, or `sftp` directly. -Run `shellrs --help` or `shellrs --help` for details. `list` prints JSON when piped or given `--json`. +Run `shellrs --help` or `shellrs --help` for details. Every command prints JSON when piped or given `--json`. ## Common Workflow ```bash -shellrs list --query --json +shellrs hosts list -q --json +shellrs hosts show --json shellrs exec "" shellrs upload shellrs download +shellrs sync [--delete] ``` -`` is the 16-character `id` from `shellrs list` (the same ID that ShellRS copies with 复制 ID). +`` is the 16-character `id` from `shellrs hosts list` (the same ID that ShellRS copies with 复制 ID). For a simple command, pass one complete remote shell command string as ``. Pass a command through stdin when it contains quotes, pipes, redirects, JSON, `$`, backticks, or nested shell code, and quote the heredoc delimiter so the local shell leaves it alone: @@ -37,8 +39,40 @@ shellrs upload ./dist /tmp/dist shellrs upload ./app.tar.gz /tmp/release.tar.gz shellrs download /var/log/nginx ./logs/nginx shellrs download /tmp/app.tar.gz ./latest.tar.gz +shellrs sync ./dist /opt/app --delete ``` +`sync` copies what the local folder holds into the remote folder, making the remote folder when it is not there (its parent must exist). Files with the same size and modification time are skipped, so running it again copies only what changed. `--delete` first removes what is in the remote folder and not in the local one. It only goes from this machine to the host. + +## Manage Hosts and Credentials + +```bash +shellrs hosts list +shellrs hosts show +shellrs hosts create < host.json +shellrs hosts update < changes.json +shellrs hosts delete +shellrs credentials list +shellrs credentials show +shellrs credentials create < credential.json +shellrs credentials update < changes.json +shellrs credentials delete +``` + +`create` and `update` read one JSON object from stdin; `update` changes only the fields given. `shellrs hosts create --help` and `shellrs credentials create --help` list the fields. What `show --json` prints can be edited and passed back to `update` as it is. For example: + +```bash +shellrs hosts create <<'EOF' +{"name": "web-01", "host": "10.0.1.12", "group": "生产/web", "auth": "credential", "credential": ""} +EOF +``` + +- `group` is a path such as `生产/web`; groups that are not there are made. +- A host logs in with `"auth": "password"` (its own password), `"credential"` (a saved credential, by its ID; the host takes the credential's user) or `"no_password"` (the SSH agent and the default keys). +- `route` is `{"type": "direct"}`, `{"type": "jump", "hosts": ["", ...]}` or `{"type": "proxy", "kind": "http", "host": "...", "port": 3128}`. +- A credential is `"kind": "password"`, `"key"` (with `key_path`, a key file on this machine, or `private_key`, the key's text for ShellRS to keep) or `"agent"`. +- `hosts delete` refuses a host whose tabs are open in ShellRS, with `host_in_use`; `--force` closes them. + ## Windows The same commands work in PowerShell, cmd, and Git Bash. Local paths may be Windows paths (`.\dist`, `C:\Users\me\app.tar.gz`); remote paths are still POSIX paths. In Git Bash, use the heredoc form above. @@ -60,15 +94,24 @@ $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new() '@ | shellrs exec --stdin ``` -The `@'` must end its line and the `'@` must start one. Set the same encodings before `list`, `upload` and `download`, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake. +The `@'` must end its line and the `'@` must start one. Set the same encodings before the other commands, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake. + +To create or update a host or credential from Windows PowerShell 5.1, pipe the JSON in the same way, writing non-ASCII characters in it as `\uXXXX` escapes (`"group": "\u751f\u4ea7"` for 生产): + +```powershell +@{ name = 'web-01'; host = '10.0.1.12'; user = 'deploy' } | ConvertTo-Json -Compress | shellrs hosts create +``` ## Rules - Do not ask the user for SSH passwords, private keys, or passphrases. ShellRS holds those credentials. +- Only when the user gives you a password, key or passphrase to save in ShellRS, put it in the JSON on stdin (`password`, `private_key`, `passphrase`), never in a command argument. ShellRS keeps it in the system keychain and never prints it back: `show` only says whether one is saved. +- Create, change or delete hosts and credentials only when the user asks you to. Ask before `hosts delete --force`: it closes the host's open tabs. Deleting a credential makes its hosts log in on their own. - `shellrs exec` takes a host id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code. - The remote command gets no stdin, so interactive programs (editors, pagers, password prompts) will not work. Use non-interactive flags such as `--yes`, `-y`, or `--no-pager`. - Long-running tasks should use tmux, nohup, or systemd on the remote host. - `shellrs upload` takes a host id, a local file or folder path, and a remote destination path. `shellrs download` takes a host id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten. +- `shellrs sync` takes a host id, a local folder and a remote folder, and copies the local folder's contents into the remote one; `--delete` removes remote files absent locally. - Destination paths follow scp: if the destination exists and is a directory, the source keeps its name inside that directory; otherwise the destination path is the final file or folder path, and its parent directory must exist. ## Errors @@ -77,7 +120,11 @@ Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. E - `not_running`: ShellRS is not open. Ask the user to open ShellRS. - `not_enabled`: ask the user to turn on 启用外部 CLI in ShellRS under 设置 → 外部 CLI. -- `host_not_found`: run `shellrs list` again and use an `id` from its output. +- `host_not_found`: run `shellrs hosts list` again and use an `id` from its output. `credential_not_found`: the same with `shellrs credentials list`. +- `bad_request` from `create` or `update`: the message says which field is wrong, in the words the ShellRS forms use. +- `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`). +- `save_failed`: the change was made, but not all of it could be written to disk or the keychain. Tell the user the message. +- `version_mismatch`: the `shellrs` command and the running ShellRS are different versions. Ask the user to restart or update ShellRS. - `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password. - `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it. - `connect_failed` saying there is no permission to connect to ShellRS: the agent's sandbox blocks local inter-process communication. Ask the user to allow it, or run the command outside the sandbox. diff --git a/src/cli/backend.rs b/src/cli/backend.rs index 1e3d860..6609081 100644 --- a/src/cli/backend.rs +++ b/src/cli/backend.rs @@ -10,6 +10,7 @@ use std::{ use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary}; use super::server::{CliBackend, CliTarget}; use crate::connection::{ConnectionPromptKind, ConnectionPromptReply}; +use crate::secrets::{SecretRef, SharedSecretStore}; use crate::sftp::{ DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider, TransferAnswer, TransferChoice, TransferPhase, TransferProgress, TransferQuestionKind, @@ -25,13 +26,24 @@ const PROGRESS_INTERVAL: Duration = Duration::from_millis(200); pub struct SshCliBackend { connector: SshConnector, sftp: SharedSftpTransportProvider, + secrets: SharedSecretStore, } impl SshCliBackend { /// The same connector as the terminals, so host trust goes through one - /// file and lock, and the same SFTP provider as the SFTP tab. - pub fn new(connector: SshConnector, sftp: SharedSftpTransportProvider) -> Self { - Self { connector, sftp } + /// file and lock, and the same SFTP provider as the SFTP tab. `secrets` + /// is the host store's, which holds the passwords of the hosts not + /// saved too. + pub fn new( + connector: SshConnector, + sftp: SharedSftpTransportProvider, + secrets: SharedSecretStore, + ) -> Self { + Self { + connector, + sftp, + secrets, + } } /// Run one transfer on a connection of its own, answering every @@ -166,6 +178,28 @@ impl CliBackend for SshCliBackend { .map_err(|error| CliError::new(ErrorCode::BadRequest, error.to_string()))?; self.transfer(target, SftpCommand::Download(request), progress) } + + fn sync( + &self, + target: &CliTarget, + source: &Path, + destination: &str, + delete: bool, + progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, + ) -> Result { + if !source.is_dir() { + return Err(CliError::new( + ErrorCode::BadRequest, + format!("本地目录不存在:{}", source.display()), + )); + } + let request = UploadRequest::sync(source.to_path_buf(), remote_path(destination)?, delete); + self.transfer(target, SftpCommand::Upload(request), progress) + } + + fn is_saved(&self, secret: &SecretRef) -> bool { + self.secrets.get(secret).is_ok_and(|value| value.is_some()) + } } /// What one transfer ran into along the way. @@ -222,6 +256,7 @@ impl Transfer { bytes: state.completed_bytes(), skipped: state.skipped() as u64, failed: state.failed() as u64, + deleted: state.deleted() as u64, failures: std::mem::take(&mut self.failures), }), _ => Err(self.refused.take().unwrap_or_else(|| { diff --git a/src/cli/client.rs b/src/cli/client.rs index 5134aec..2f6695b 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -12,8 +12,9 @@ use unicode_width::UnicodeWidthStr as _; use super::link::OpenLink; use super::protocol::{ - Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request, TransferCounters, - TransferSummary, parse_json, read_frame, write_json, + AuthChoice, CredentialDeleted, CredentialDetails, CredentialKindChoice, Envelope, ErrorCode, + FrameKind, HostDeleted, HostDetails, HostInfo, PROTOCOL_VERSION, ProxyChoice, Reply, Request, + RouteDetails, TransferCounters, TransferSummary, parse_json, read_frame, write_json, }; use crate::host::HostOs; @@ -77,6 +78,33 @@ pub(super) fn ascii_json(value: &impl serde::Serialize) -> String { ascii } +/// What the request did, for the words its answer is printed with. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Asked { + Show, + Create, + Update, + Sync, + Other, +} + +impl Asked { + fn of(request: &Request) -> Self { + match request { + Request::ShowHost { .. } | Request::ShowCredential { .. } => Asked::Show, + Request::CreateHost { .. } | Request::CreateCredential { .. } => Asked::Create, + Request::UpdateHost { .. } | Request::UpdateCredential { .. } => Asked::Update, + Request::Sync { .. } => Asked::Sync, + _ => Asked::Other, + } + } +} + +/// What an app from before a request was added says about it: it cannot +/// read the request, and serde names what it did not know. +const OLDER_APP: &str = + "正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它"; + /// Send `request` to the app listening at `endpoint` and print the answer; /// the exit code. pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 { @@ -196,6 +224,7 @@ fn talk(stream: S, request: Request, console: &mut Console) -> Result return Ok(print_summary(&summary, console)?), + Reply::TransferDone(summary) => { + return Ok(print_summary(&summary, asked == Asked::Sync, console)?); + } + Reply::Host(host) => { + print_host(&host, asked, console)?; + return Ok(0); + } + Reply::HostDeleted(deleted) => { + print_host_deleted(&deleted, console)?; + return Ok(0); + } + Reply::Credentials { credentials } => { + print_credentials(&credentials, console)?; + return Ok(0); + } + Reply::Credential(credential) => { + print_credential(&credential, asked, console)?; + return Ok(0); + } + Reply::CredentialDeleted(deleted) => { + print_credential_deleted(&deleted, console)?; + return Ok(0); + } Reply::Exit { code } if console.exec_json => { let result = ExecResult { exit_code: code, @@ -251,6 +302,12 @@ where } Reply::Exit { code } => return Ok(code), Reply::Activated => return Ok(0), + Reply::Error { + code: ErrorCode::BadRequest, + message, + } if message.contains("unknown variant") => { + return Ok(console.error(ErrorCode::VersionMismatch, OLDER_APP)); + } Reply::Error { code, message } => return Ok(console.error(code, &message)), } } @@ -289,10 +346,268 @@ impl ProgressLine { } } +fn print_json(value: &impl serde::Serialize, console: &mut Console) -> io::Result<()> { + serde_json::to_writer_pretty(&mut *console.stdout, value).map_err(io::Error::other)?; + writeln!(console.stdout) +} + +/// Rows lined up in columns, wide characters counted as two. +fn print_table( + header: [&str; N], + rows: &[[String; N]], + console: &mut Console, +) -> io::Result<()> { + let header = header.map(String::from); + let widths: Vec = (0..N) + .map(|column| { + std::iter::once(&header) + .chain(rows) + .map(|row| row[column].width()) + .max() + .unwrap_or(0) + }) + .collect(); + for row in std::iter::once(&header).chain(rows) { + let line: Vec = row + .iter() + .zip(&widths) + .map(|(cell, width)| format!("{cell}{}", " ".repeat(width - cell.width()))) + .collect(); + writeln!(console.stdout, "{}", line.join(" ").trim_end())?; + } + Ok(()) +} + +/// Labelled lines, the labels lined up. +fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result<()> { + let width = fields + .iter() + .map(|(label, _)| label.width()) + .max() + .unwrap_or(0); + for (label, value) in fields { + let padding = " ".repeat(width - label.width()); + writeln!(console.stdout, "{label}{padding} {value}")?; + } + Ok(()) +} + +fn saved_word(saved: Option) -> &'static str { + match saved { + Some(true) => "(已保存)", + Some(false) => "(未保存)", + None => "", + } +} + +fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Result<()> { + if console.json { + return print_json(host, console); + } + match asked { + Asked::Create => { + return writeln!( + console.stdout, + "已创建主机「{}」,ID {}", + host.name, host.id + ); + } + Asked::Update => { + return writeln!( + console.stdout, + "已保存主机「{}」(ID {})", + host.name, host.id + ); + } + _ => {} + } + let auth = match host.auth { + AuthChoice::Password => format!("密码{}", saved_word(host.password_saved)), + AuthChoice::Credential => { + format!("凭据 {}", host.credential.as_deref().unwrap_or_default()) + } + AuthChoice::NoPassword => "无密码".to_string(), + }; + let route = match &host.route { + RouteDetails::Direct => "直接连接".to_string(), + RouteDetails::Jump { hosts } => format!( + "SSH 跳板 {}", + hosts + .iter() + .map(|hop| hop.as_deref().unwrap_or("(已删除的主机)")) + .collect::>() + .join(" → ") + ), + RouteDetails::Proxy { + kind, + host, + port, + user, + password_saved, + } => format!( + "{} {}{host}:{port}{}", + match kind { + ProxyChoice::Http => "HTTP 代理", + ProxyChoice::Socks5 => "SOCKS5 代理", + }, + user.as_deref() + .map(|user| format!("{user}@")) + .unwrap_or_default(), + password_saved + .map(|saved| if saved { + "(密码已保存)" + } else { + "(密码未保存)" + }) + .unwrap_or_default() + ), + }; + let mut fields = vec![ + ("ID", host.id.clone()), + ("名称", host.name.clone()), + ( + "分组", + if host.temporary { + "(未保存)".to_string() + } else { + host.group.clone().unwrap_or_default() + }, + ), + ("地址", format!("{}@{}:{}", host.user, host.host, host.port)), + ("认证", auth), + ("连接方式", route), + ]; + if let Some(os) = host.os.as_deref().and_then(HostOs::from_stored) { + fields.push(("系统", os.label().to_string())); + } + if !host.notes.is_empty() { + fields.push(("备注", host.notes.clone())); + } + print_fields(&fields, console) +} + +fn print_host_deleted(deleted: &HostDeleted, console: &mut Console) -> io::Result<()> { + if console.json { + return print_json(deleted, console); + } + let mut line = format!("已删除主机「{}」", deleted.host.name); + if deleted.forwards > 0 { + line.push_str(&format!(",连同它的 {} 条端口转发规则", deleted.forwards)); + } + if deleted.jump_users > 0 { + line.push_str(&format!( + ";{} 台主机经它跳转,现在那一跳是「已删除的主机」", + deleted.jump_users + )); + } + writeln!(console.stdout, "{line}") +} + +fn kind_label(kind: CredentialKindChoice) -> &'static str { + match kind { + CredentialKindChoice::Password => "密码", + CredentialKindChoice::Key => "密钥", + CredentialKindChoice::Agent => "SSH Agent", + } +} + +fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) -> io::Result<()> { + if console.json { + return print_json(&credentials, console); + } + if credentials.is_empty() { + return writeln!(console.stderr, "没有匹配的凭据"); + } + let rows: Vec<[String; 5]> = credentials + .iter() + .map(|credential| { + [ + credential.id.clone(), + credential.name.clone(), + kind_label(credential.kind).to_string(), + credential.user.clone(), + credential.hosts.len().to_string(), + ] + }) + .collect(); + print_table(["ID", "名称", "类型", "用户名", "主机数"], &rows, console) +} + +fn print_credential( + credential: &CredentialDetails, + asked: Asked, + console: &mut Console, +) -> io::Result<()> { + if console.json { + return print_json(credential, console); + } + match asked { + Asked::Create => { + return writeln!( + console.stdout, + "已创建凭据「{}」,ID {}", + credential.name, credential.id + ); + } + Asked::Update => { + return writeln!( + console.stdout, + "已保存凭据「{}」(ID {})", + credential.name, credential.id + ); + } + _ => {} + } + let mut fields = vec![ + ("ID", credential.id.clone()), + ("名称", credential.name.clone()), + ( + "类型", + format!( + "{}{}", + kind_label(credential.kind), + saved_word(credential.password_saved) + ), + ), + ("用户名", credential.user.clone()), + ]; + if credential.kind == CredentialKindChoice::Key { + let key = match (&credential.key_path, credential.kept) { + (Some(path), _) => path.clone(), + (None, _) => "由 ShellRS 保存".to_string(), + }; + fields.push(("私钥", key)); + if let Some(saved) = credential.passphrase_saved { + fields.push(("口令", if saved { "已保存" } else { "未保存" }.to_string())); + } + } + fields.push(("主机", credential.hosts.join(", "))); + print_fields(&fields, console) +} + +fn print_credential_deleted(deleted: &CredentialDeleted, console: &mut Console) -> io::Result<()> { + if console.json { + return print_json(deleted, console); + } + let mut line = format!("已删除凭据「{}」", deleted.credential.name); + if !deleted.released.is_empty() { + let names: Vec<&str> = deleted + .released + .iter() + .map(|host| host.name.as_str()) + .collect(); + line.push_str(&format!( + ";{} 台主机改为自己登录:{}", + names.len(), + names.join("、") + )); + } + writeln!(console.stdout, "{line}") +} + fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> { if console.json { - serde_json::to_writer_pretty(&mut *console.stdout, hosts).map_err(io::Error::other)?; - return writeln!(console.stdout); + return print_json(&hosts, console); } if hosts.is_empty() { return writeln!(console.stderr, "没有匹配的主机"); @@ -319,44 +634,30 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> { ] }) .collect(); - let header = ["ID", "名称", "分组", "地址", "系统"].map(String::from); - let widths: Vec = (0..5) - .map(|column| { - std::iter::once(&header) - .chain(&rows) - .map(|row| row[column].width()) - .max() - .unwrap_or(0) - }) - .collect(); - for row in std::iter::once(&header).chain(&rows) { - let line: Vec = row - .iter() - .zip(&widths) - .map(|(cell, width)| format!("{cell}{}", " ".repeat(width - cell.width()))) - .collect(); - writeln!(console.stdout, "{}", line.join(" ").trim_end())?; - } - Ok(()) + print_table(["ID", "名称", "分组", "地址", "系统"], &rows, console) } -fn print_summary(summary: &TransferSummary, console: &mut Console) -> io::Result { +/// A sync's skipped items are the ones that had not changed. +fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) -> io::Result { if console.json { - serde_json::to_writer_pretty(&mut *console.stdout, summary).map_err(io::Error::other)?; - writeln!(console.stdout)?; + print_json(summary, console)?; } else { - writeln!( - console.stdout, - "已传输 {} 个文件,共 {}{}", + let mut line = format!( + "已传输 {} 个文件,共 {}", summary.files, - format_bytes(summary.bytes), - match (summary.skipped, summary.failed) { - (0, 0) => String::new(), - (skipped, 0) => format!(",跳过 {skipped} 个"), - (0, failed) => format!(",失败 {failed} 个"), - (skipped, failed) => format!(",跳过 {skipped} 个,失败 {failed} 个"), - } - )?; + format_bytes(summary.bytes) + ); + if summary.skipped > 0 { + let word = if sync { "未变" } else { "跳过" }; + line.push_str(&format!(",{word} {} 个", summary.skipped)); + } + if summary.deleted > 0 { + line.push_str(&format!(",删除 {} 个", summary.deleted)); + } + if summary.failed > 0 { + line.push_str(&format!(",失败 {} 个", summary.failed)); + } + writeln!(console.stdout, "{line}")?; for failure in &summary.failures { writeln!(console.stderr, "{failure}")?; } diff --git a/src/cli/manage.rs b/src/cli/manage.rs new file mode 100644 index 0000000..8e3d5b6 --- /dev/null +++ b/src/cli/manage.rs @@ -0,0 +1,1117 @@ +//! Hosts and credentials as the CLI sees them: the details it prints, and +//! the changes it asks for, checked and resolved against the store before +//! the workspace makes them. Everything here reads the store on the UI +//! thread; nothing here changes it. + +use std::path::Path; + +use crate::host::{ + AuthKind, Credential, CredentialDraft, CredentialId, CredentialKind, GroupId, Host, HostDraft, + HostId, HostStore, PastedKey, ProxyKind, ProxySettings, Route, +}; +use crate::secrets::SecretRef; + +use super::protocol::{ + AuthChoice, CliError, CredentialDetails, CredentialFields, CredentialKindChoice, ErrorCode, + HostDetails, HostFields, HostInfo, ProxyChoice, RouteDetails, RouteFields, Secret, +}; + +/// A change a CLI request asks for, made on the UI thread one at a time. +/// Hosts and credentials are named by the IDs the CLI prints. +#[derive(Debug)] +pub enum CliChange { + CreateHost(HostFields), + UpdateHost { + host: String, + fields: HostFields, + }, + DeleteHost { + host: String, + force: bool, + }, + CreateCredential(CredentialFields), + UpdateCredential { + credential: String, + fields: CredentialFields, + }, + DeleteCredential { + credential: String, + }, +} + +/// The keychain entries a host's details say are saved or not: its own +/// password, and its proxy's. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HostSecrets { + pub password: Option, + pub proxy: Option, +} + +/// The keychain entries a credential's details say are saved or not. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CredentialSecrets { + pub password: Option, + pub passphrase: Option, +} + +/// A host as `shellrs hosts list` lists it. +pub fn host_info(host: &Host, store: &HostStore) -> HostInfo { + HostInfo { + id: host.public_id.to_string(), + name: host.name.to_string(), + group: group_path(host.group, store), + user: host.user.to_string(), + host: host.address.to_string(), + port: host.port, + os: host.os.map(|os| os.as_str().to_string()), + temporary: store.is_temporary(host.id), + } +} + +/// A group's path as the CLI writes it, `生产/数据库`; `None` at the root. +fn group_path(group: Option, store: &HostStore) -> Option { + let names = group.map(|id| store.group_names(id)).unwrap_or_default(); + (!names.is_empty()).then(|| names.join("/")) +} + +/// `host` as it is in `store`, without saying which passwords are saved: +/// that takes the keychain, which [`host_secrets`] names. +pub fn host_details(host: &Host, store: &HostStore) -> HostDetails { + let credential = host + .credential + .and_then(|id| store.credential(id)) + .map(|credential| credential.keychain_id.to_string()); + let auth = match (&credential, host.auth) { + (Some(_), _) => AuthChoice::Credential, + (None, AuthKind::Password) => AuthChoice::Password, + (None, AuthKind::NoPassword) => AuthChoice::NoPassword, + }; + let route = match &host.route { + Route::Direct => RouteDetails::Direct, + Route::Jump(hops) => RouteDetails::Jump { + hosts: hops + .iter() + .map(|hop| { + hop.and_then(|id| store.saved_host(id)) + .map(|hop| hop.public_id.to_string()) + }) + .collect(), + }, + Route::Proxy(proxy) => RouteDetails::Proxy { + kind: match proxy.kind { + ProxyKind::Http => ProxyChoice::Http, + ProxyKind::Socks5 => ProxyChoice::Socks5, + }, + host: proxy.host.to_string(), + port: proxy.port, + user: proxy.user.as_ref().map(ToString::to_string), + password_saved: None, + }, + }; + HostDetails { + id: host.public_id.to_string(), + name: host.name.to_string(), + group: group_path(host.group, store), + host: host.address.to_string(), + port: host.port, + user: host.user.to_string(), + auth, + credential, + password_saved: None, + route, + notes: host.notes.to_string(), + os: host.os.map(|os| os.as_str().to_string()), + temporary: store.is_temporary(host.id), + } +} + +/// The entries behind a host's `password_saved` fields: its own password +/// when it logs in with one (a temporary host's is in memory), and its +/// proxy's when the proxy has a user. +pub fn host_secrets(host: &Host, store: &HostStore) -> HostSecrets { + let login = store.login_of(host); + HostSecrets { + password: (host.credential.is_none() && host.auth == AuthKind::Password) + .then_some(login.password), + proxy: login.route.proxy_password(), + } +} + +/// `details` saying which of `secrets` are saved, as `saved` finds them. +pub fn with_saved_passwords( + mut details: HostDetails, + secrets: &HostSecrets, + saved: impl Fn(&SecretRef) -> bool, +) -> HostDetails { + details.password_saved = secrets.password.as_ref().map(&saved); + if let RouteDetails::Proxy { password_saved, .. } = &mut details.route { + *password_saved = secrets.proxy.as_ref().map(&saved); + } + details +} + +pub fn credential_details(credential: &Credential, store: &HostStore) -> CredentialDetails { + let kept = credential.keeps_key_in(store.key_dir()); + CredentialDetails { + id: credential.keychain_id.to_string(), + name: credential.name.to_string(), + kind: match credential.kind { + CredentialKind::Password => CredentialKindChoice::Password, + CredentialKind::Key => CredentialKindChoice::Key, + CredentialKind::Agent => CredentialKindChoice::Agent, + }, + user: credential.user.to_string(), + // Where a key ShellRS keeps lies is as good as the key itself. + key_path: credential + .key_path + .as_ref() + .filter(|_| !kept) + .map(ToString::to_string), + kept, + hosts: store + .hosts_using(credential.id) + .map(|host| host.public_id.to_string()) + .collect(), + password_saved: None, + passphrase_saved: None, + } +} + +pub fn credential_secrets(credential: &Credential) -> CredentialSecrets { + CredentialSecrets { + password: (credential.kind == CredentialKind::Password) + .then(|| credential.password_secret()), + passphrase: credential + .key_path + .as_deref() + .filter(|_| credential.kind == CredentialKind::Key) + .map(SecretRef::passphrase), + } +} + +pub fn with_saved_credential_secrets( + mut details: CredentialDetails, + secrets: &CredentialSecrets, + saved: impl Fn(&SecretRef) -> bool, +) -> CredentialDetails { + details.password_saved = secrets.password.as_ref().map(&saved); + details.passphrase_saved = secrets.passphrase.as_ref().map(&saved); + details +} + +/// The credential search: its name, user, kind or ID. +pub fn credential_matches(details: &CredentialDetails, query: &str) -> bool { + let needle = query.trim().to_lowercase(); + let kind = match details.kind { + CredentialKindChoice::Password => "password 密码", + CredentialKindChoice::Key => "key 密钥", + CredentialKindChoice::Agent => "agent ssh agent", + }; + needle.is_empty() + || details.name.to_lowercase().contains(&needle) + || details.user.to_lowercase().contains(&needle) + || kind.to_lowercase().contains(&needle) + || details.id.to_lowercase() == needle +} + +fn bad(message: impl Into) -> CliError { + CliError::new(ErrorCode::BadRequest, message) +} + +fn host_not_found(id: &str) -> CliError { + CliError::new( + ErrorCode::HostNotFound, + format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"), + ) +} + +/// The host, saved or temporary, the CLI calls `id`. +pub fn find_host<'a>(store: &'a HostStore, id: &str) -> Result<&'a Host, CliError> { + store + .hosts() + .iter() + .chain(store.temporary_hosts()) + .find(|host| host.public_id.as_str() == id.trim()) + .ok_or_else(|| host_not_found(id)) +} + +/// The saved host the CLI calls `id`: what can be changed or deleted. +pub fn find_saved_host(store: &HostStore, id: &str) -> Result { + let host = find_host(store, id)?; + if store.is_temporary(host.id) { + return Err(bad(format!( + "「{}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可", + host.name + ))); + } + Ok(host.id) +} + +pub fn find_credential<'a>(store: &'a HostStore, id: &str) -> Result<&'a Credential, CliError> { + store + .credentials() + .iter() + .find(|credential| credential.keychain_id.as_str() == id.trim()) + .ok_or_else(|| { + CliError::new( + ErrorCode::CredentialNotFound, + format!("没有 ID 为 {id} 的凭据:请用 shellrs credentials list 查看"), + ) + }) +} + +/// Where a host goes in the tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GroupPlan { + Existing(Option), + /// Into groups made for it: `names` one inside the other, the first + /// under `parent`. + Create { + parent: Option, + names: Vec, + }, +} + +/// What becomes of a keychain entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SecretChange { + Keep, + Set(Secret), + Delete, + /// Moved here from the entry the host used to log in with, which the + /// store forgets once nothing uses it, as the host form does. + Carry(SecretRef), +} + +impl SecretChange { + /// What to write, `Some(None)` to delete; `None` for nothing, or for + /// a value that has to be read from where it moves from first. + pub fn value(&self) -> Option> { + match self { + SecretChange::Keep | SecretChange::Carry(_) => None, + SecretChange::Set(secret) => Some(Some(secret.expose().to_string())), + SecretChange::Delete => Some(None), + } + } + + fn from_field(field: Option>) -> Self { + match field { + None => SecretChange::Keep, + Some(None) => SecretChange::Delete, + Some(Some(secret)) => SecretChange::Set(secret), + } + } + + /// What `field` means for an entry the host moves to from `before`: + /// left out, the old one comes along. + fn moving(field: Option>, before: Option, after: &SecretRef) -> Self { + match (field, before) { + (None, Some(before)) if before != *after => SecretChange::Carry(before), + (field, _) => Self::from_field(field), + } + } +} + +/// A host to save, checked: the draft (its group still to be placed), +/// where it goes, and what to do with its passwords. +#[derive(Debug)] +pub struct HostPlan { + pub draft: HostDraft, + pub group: GroupPlan, + pub password: Option<(SecretRef, SecretChange)>, + pub proxy_password: Option<(SecretRef, SecretChange)>, +} + +/// `fields` on top of the host `existing` is (a new host's defaults when +/// `None`), or the first thing wrong with them, in the host form's words +/// where it has them. +pub fn plan_host( + fields: HostFields, + existing: Option, + store: &HostStore, +) -> Result { + let current = existing.and_then(|id| store.saved_host(id)); + let mut draft = current + .map(Host::draft) + .unwrap_or_else(|| HostDraft::new("", "", 22, "", AuthKind::default(), None)); + if let Some(name) = fields.name { + draft.name = name.into(); + } + if let Some(address) = fields.host { + draft.address = address.into(); + } + if let Some(port) = fields.port { + draft.port = port; + } + if let Some(notes) = fields.notes { + draft.notes = notes.into(); + } + let group = match fields.group { + None => GroupPlan::Existing(draft.group), + Some(None) => GroupPlan::Existing(None), + Some(Some(path)) => resolve_group(store, &path)?, + }; + + // How it logs in: a credential named, or its own way. + let named = match &fields.credential { + Some(Some(id)) => Some(find_credential(store, id)?), + _ => None, + }; + let credential = match (fields.auth, named) { + (Some(AuthChoice::Password | AuthChoice::NoPassword), Some(_)) => { + return Err(bad( + "auth 为 password 或 no_password 时不能同时指定 credential", + )); + } + (Some(AuthChoice::Password | AuthChoice::NoPassword), None) => None, + (_, Some(named)) => Some(named), + (_, None) if fields.credential == Some(None) => None, + (_, None) => draft.credential.and_then(|id| store.credential(id)), + }; + match (fields.auth, credential) { + (Some(AuthChoice::Credential), None) => return Err(bad("请选择凭据")), + (_, Some(credential)) => { + if let Some(user) = &fields.user + && user.trim() != credential.user.as_ref() + { + return Err(bad(format!( + "使用凭据的主机以凭据的用户名 {} 登录:去掉 user,或改凭据的用户名", + credential.user + ))); + } + draft.credential = Some(credential.id); + draft.user = credential.user.clone(); + draft.auth = AuthKind::default(); + } + (auth, None) => { + let previous = draft.credential.and_then(|id| store.credential(id)); + draft.auth = match (auth, previous) { + (Some(AuthChoice::NoPassword), _) => AuthKind::NoPassword, + (Some(_), _) => AuthKind::Password, + // Off its credential without saying how: as when the + // credential is deleted. + (None, Some(previous)) => previous.kind.without_credential(), + (None, None) => draft.auth, + }; + draft.credential = None; + if let Some(user) = fields.user { + draft.user = user.into(); + } + } + } + let own_password = draft.credential.is_none() && draft.auth == AuthKind::Password; + if !own_password && matches!(fields.password, Some(Some(_))) { + return Err(bad("只有 auth 为 password 的主机才保存自己的密码")); + } + + // The proxy password field, when the route was given at all. + let mut proxy_field = None; + if let Some(route) = fields.route { + draft.route = match route { + RouteFields::Direct => Route::Direct, + RouteFields::Jump { hosts } => Route::Jump(jump_hosts(&hosts, existing, store)?), + RouteFields::Proxy { + kind, + host, + port, + user, + password, + } => { + let user = user.unwrap_or_default(); + if user.trim().is_empty() && matches!(password, Some(Some(_))) { + return Err(bad("填写代理密码时请同时填写用户名")); + } + proxy_field = Some(password); + let kind = match kind { + ProxyChoice::Http => ProxyKind::Http, + ProxyChoice::Socks5 => ProxyKind::Socks5, + }; + Route::Proxy(ProxySettings::new(kind, host, port).with_user(user)) + } + }; + } + let draft = draft.validated().map_err(|error| bad(error.message()))?; + + let password = own_password.then(|| { + let after = draft.password_secret(); + let before = current + .filter(|host| host.credential.is_none() && host.auth == AuthKind::Password) + .map(Host::password_secret); + let change = SecretChange::moving(fields.password, before, &after); + (after, change) + }); + let proxy_password = match &draft.route { + Route::Proxy(proxy) => proxy.password_secret().map(|after| { + let before = current.and_then(|host| match &host.route { + Route::Proxy(proxy) => proxy.password_secret(), + _ => None, + }); + let change = SecretChange::moving(proxy_field.flatten(), before, &after); + (after, change) + }), + _ => None, + }; + Ok(HostPlan { + draft, + group, + password, + proxy_password, + }) +} + +/// The jump hosts the CLI names, as the store keeps them, or why they will +/// not do. Only saved hosts, never the host itself, each once: the host +/// form offers no other. The store would quietly turn the rest into +/// deleted jump hosts. +fn jump_hosts( + ids: &[Option], + own: Option, + store: &HostStore, +) -> Result>, CliError> { + let mut hops = Vec::new(); + for id in ids { + let Some(id) = id else { + return Err(bad(crate::host::HostDraftError::DeletedJumpHost.message())); + }; + let hop = find_host(store, id)?; + if store.is_temporary(hop.id) { + return Err(bad(format!("跳板只能是保存的主机:{id} 是没有保存的连接"))); + } + if Some(hop.id) == own { + return Err(bad("跳板主机不能是这台主机自己")); + } + if hops.contains(&Some(hop.id)) { + return Err(bad(format!("跳板主机 {id} 重复了"))); + } + hops.push(Some(hop.id)); + } + Ok(hops) +} + +/// The group a path such as `生产/数据库` names, or the groups to make for +/// it. The whole path is looked for first, so a name with a `/` in it +/// still matches; then level by level, making what is missing. +fn resolve_group(store: &HostStore, path: &str) -> Result { + let path = path.trim(); + if path.is_empty() { + return Ok(GroupPlan::Existing(None)); + } + let names: Vec<&str> = path.split('/').map(str::trim).collect(); + if names.iter().any(|name| name.is_empty()) { + return Err(bad(format!("分组路径「{path}」里有空的一级"))); + } + let wanted = names.join("/"); + let ambiguous = |count: usize, path: &str| { + bad(format!( + "有 {count} 个分组的路径都是「{path}」:请先在 ShellRS 里给它们改名" + )) + }; + let whole: Vec = store + .groups() + .iter() + .filter(|group| store.group_names(group.id).join("/") == wanted) + .map(|group| group.id) + .collect(); + match whole.as_slice() { + [id] => return Ok(GroupPlan::Existing(Some(*id))), + [] => {} + many => return Err(ambiguous(many.len(), &wanted)), + } + let mut parent = None; + for (level, name) in names.iter().enumerate() { + let children: Vec = store + .groups() + .iter() + .filter(|group| group.parent == parent && group.name.as_ref() == *name) + .map(|group| group.id) + .collect(); + match children.as_slice() { + [] => { + return Ok(GroupPlan::Create { + parent, + names: names[level..].iter().map(ToString::to_string).collect(), + }); + } + [id] => parent = Some(*id), + many => return Err(ambiguous(many.len(), &names[..=level].join("/"))), + } + } + Ok(GroupPlan::Existing(parent)) +} + +/// A credential to save, checked. Its password's and passphrase's entries +/// are only known once it is saved (and its key file written), so the +/// changes come without them. +pub struct CredentialPlan { + pub draft: CredentialDraft, + /// A pasted key, for ShellRS to keep in a file of its own; the draft's + /// key path is a placeholder until it is written. + pub private_key: Option, + pub password: SecretChange, + pub passphrase: SecretChange, +} + +/// `fields` on top of the credential `existing` is (a new password +/// credential when `None`), or the first thing wrong with them. +pub fn plan_credential( + fields: CredentialFields, + existing: Option, + store: &HostStore, +) -> Result { + let current = existing.and_then(|id| store.credential(id)); + let gives_key = fields.key_path.is_some() || fields.private_key.is_some(); + let kind = match (fields.kind, current) { + (Some(kind), _) => kind, + (None, Some(current)) => match current.kind { + CredentialKind::Password => CredentialKindChoice::Password, + CredentialKind::Key => CredentialKindChoice::Key, + CredentialKind::Agent => CredentialKindChoice::Agent, + }, + // A key given is a key credential's. + (None, None) if gives_key => CredentialKindChoice::Key, + (None, None) => CredentialKindChoice::Password, + }; + let kind = match kind { + CredentialKindChoice::Password => CredentialKind::Password, + CredentialKindChoice::Key => CredentialKind::Key, + CredentialKindChoice::Agent => CredentialKind::Agent, + }; + let mut draft = current + .map(Credential::draft) + .unwrap_or_else(|| CredentialDraft::new("", kind, "")); + draft.kind = kind; + if let Some(name) = fields.name { + draft.name = name.into(); + } + if let Some(user) = fields.user { + draft.user = user.into(); + } + if gives_key && kind != CredentialKind::Key { + return Err(bad("只有密钥凭据(kind 为 key)才有私钥")); + } + if kind != CredentialKind::Password && matches!(fields.password, Some(Some(_))) { + return Err(bad("只有密码凭据(kind 为 password)才保存密码")); + } + if kind != CredentialKind::Key && matches!(fields.passphrase, Some(Some(_))) { + return Err(bad("只有密钥凭据(kind 为 key)才有口令")); + } + let private_key = match (fields.key_path, fields.private_key) { + (Some(_), Some(_)) => return Err(bad("key_path 和 private_key 只能给一个")), + (Some(path), None) => { + if !path.is_absolute() { + return Err(bad(format!("私钥文件必须是绝对路径:{}", path.display()))); + } + if !Path::new(&path).is_file() { + return Err(bad(format!("私钥文件不存在:{}", path.display()))); + } + draft = draft.with_key_path(path.display().to_string()); + None + } + (None, Some(text)) => { + let pasted = PastedKey::parse(text.expose()).map_err(|error| bad(error.to_string()))?; + if store.key_dir().is_none() { + return Err(CliError::new( + ErrorCode::SaveFailed, + "没有可以保存私钥的目录", + )); + } + // Stands in until the key file is written, so the draft passes. + draft = draft.with_key_path("(新私钥)"); + Some(pasted) + } + (None, None) => None, + }; + let draft = draft.validated().map_err(|error| bad(error.to_string()))?; + let passphrase = match &private_key { + // Only an encrypted key has a passphrase to keep; any other + // clears what a key replaced in place had, as the form does. + Some(pasted) => match fields.passphrase { + Some(Some(passphrase)) if pasted.is_encrypted() && !passphrase.expose().is_empty() => { + SecretChange::Set(passphrase) + } + Some(_) => SecretChange::Delete, + None if pasted.is_encrypted() => SecretChange::Keep, + None => SecretChange::Delete, + }, + None => SecretChange::from_field(fields.passphrase), + }; + Ok(CredentialPlan { + draft, + private_key, + password: if kind == CredentialKind::Password { + SecretChange::from_field(fields.password) + } else { + SecretChange::Keep + }, + passphrase: if kind == CredentialKind::Key { + passphrase + } else { + SecretChange::Keep + }, + }) +} + +/// What `hosts show` prints and nobody sets, taken out of what `hosts +/// create` / `update` read, so the one's output can be fed to the other; +/// anything else unknown is still refused. +pub fn host_fields(mut value: serde_json::Value) -> Result { + if let Some(object) = value.as_object_mut() { + for key in ["id", "os", "temporary", "password_saved"] { + object.remove(key); + } + if let Some(route) = object + .get_mut("route") + .and_then(|route| route.as_object_mut()) + { + route.remove("password_saved"); + } + } + serde_json::from_value(value).map_err(|error| error.to_string()) +} + +/// [`host_fields`] for a credential. +pub fn credential_fields(mut value: serde_json::Value) -> Result { + if let Some(object) = value.as_object_mut() { + for key in ["id", "kept", "hosts", "password_saved", "passphrase_saved"] { + object.remove(key); + } + // `credentials show` prints `null` for a key ShellRS keeps. + if object + .get("key_path") + .is_some_and(serde_json::Value::is_null) + { + object.remove("key_path"); + } + } + serde_json::from_value(value).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use crate::host::{ + AuthKind, CredentialDraft, CredentialKind, GeneratedKey, GroupDraft, HostDraft, HostId, + HostStore, KeyAlgorithm, ProxyKind, ProxySettings, Route, + }; + use crate::secrets::SecretRef; + + use super::super::protocol::{ + AuthChoice, CliError, CredentialFields, CredentialKindChoice, ErrorCode, HostFields, + RouteFields, Secret, + }; + use super::{ + GroupPlan, SecretChange, credential_fields, host_details, host_fields, plan_credential, + plan_host, + }; + + /// 生产 › 数据库, with web in 生产 and a password credential `deploy`. + fn store() -> (HostStore, HostId, String) { + let mut store = HostStore::empty(); + let production = store.insert_group_unnotified(GroupDraft::new("生产", None)); + store.insert_group_unnotified(GroupDraft::new("数据库", Some(production))); + let web = store.insert_unnotified(HostDraft::new( + "web", + "10.0.0.1", + 22, + "root", + AuthKind::Password, + Some(production), + )); + let credential = store.insert_credential_unnotified(CredentialDraft::new( + "部署", + CredentialKind::Password, + "deploy", + )); + let id = store + .credential(credential) + .unwrap() + .keychain_id + .to_string(); + (store, web, id) + } + + fn fields(json: serde_json::Value) -> HostFields { + host_fields(json).unwrap() + } + + fn refused(result: Result) -> (ErrorCode, String) { + let error = result.unwrap_err(); + (error.code, error.message) + } + + fn bad(message: &str) -> (ErrorCode, String) { + (ErrorCode::BadRequest, message.to_string()) + } + + #[test] + fn a_new_host_needs_a_name_and_an_address_and_takes_the_forms_defaults() { + let (store, _, _) = store(); + let plan = plan_host( + fields(serde_json::json!({ "name": " db ", "host": "10.0.0.2" })), + None, + &store, + ) + .unwrap(); + assert_eq!(plan.draft.name.as_ref(), "db"); + assert_eq!((plan.draft.port, plan.draft.user.as_ref()), (22, "root")); + assert_eq!(plan.draft.auth, AuthKind::Password); + assert_eq!(plan.group, GroupPlan::Existing(None)); + assert_eq!( + plan.password, + Some(( + SecretRef::password("root", "10.0.0.2", 22), + SecretChange::Keep + )) + ); + + for (json, message) in [ + (serde_json::json!({ "host": "10.0.0.2" }), "请输入名称"), + (serde_json::json!({ "name": "db" }), "请输入地址"), + ( + serde_json::json!({ "name": "db", "host": "h", "port": 0 }), + "端口必须是 1 到 65535 之间的数字", + ), + ( + serde_json::json!({ "name": "db", "host": "h", "auth": "credential" }), + "请选择凭据", + ), + ( + serde_json::json!({ "name": "db", "host": "h", "route": { "type": "jump", "hosts": [] } }), + "请添加跳板主机", + ), + ( + serde_json::json!({ "name": "db", "host": "h", "route": { "type": "jump", "hosts": [null] } }), + "请移除已删除的跳板主机", + ), + ( + serde_json::json!({ "name": "db", "host": "h", + "route": { "type": "proxy", "host": "p", "port": 3128, "password": "x" } }), + "填写代理密码时请同时填写用户名", + ), + ( + serde_json::json!({ "name": "db", "host": "h", "auth": "no_password", "password": "x" }), + "只有 auth 为 password 的主机才保存自己的密码", + ), + ] { + assert_eq!(refused(plan_host(fields(json), None, &store)), bad(message)); + } + } + + #[test] + fn a_group_path_names_a_group_or_the_groups_to_make() { + let (mut store, web, _) = store(); + let group = |path: &str, store: &HostStore| { + plan_host( + fields(serde_json::json!({ "group": path })), + Some(web), + store, + ) + .map(|plan| plan.group) + }; + let production = store.host(web).unwrap().group; + assert_eq!( + group(" 生产 ", &store).unwrap(), + GroupPlan::Existing(production) + ); + assert_eq!( + group("生产 / 数据库 / 主库", &store).unwrap(), + GroupPlan::Create { + parent: Some(crate::host::GroupId(2)), + names: vec!["主库".into()], + } + ); + assert_eq!( + group("测试/web", &store).unwrap(), + GroupPlan::Create { + parent: None, + names: vec!["测试".into(), "web".into()], + } + ); + assert_eq!(group("", &store).unwrap(), GroupPlan::Existing(None)); + assert_eq!( + refused(group("生产//web", &store)), + bad("分组路径「生产//web」里有空的一级") + ); + // Two groups by one name: the whole path still tells them apart, + // a level to make under them does not. + store.insert_group_unnotified(GroupDraft::new("生产", None)); + assert_eq!( + group("生产/数据库", &store).unwrap(), + GroupPlan::Existing(Some(crate::host::GroupId(2))) + ); + assert_eq!( + refused(group("生产/新的", &store)), + bad("有 2 个分组的路径都是「生产」:请先在 ShellRS 里给它们改名") + ); + assert_eq!( + refused(group("生产", &store)), + bad("有 2 个分组的路径都是「生产」:请先在 ShellRS 里给它们改名") + ); + // Unchanged when left out, the top level for null. + let plan = plan_host(HostFields::default(), Some(web), &store).unwrap(); + assert_eq!(plan.group, GroupPlan::Existing(production)); + let plan = plan_host( + fields(serde_json::json!({ "group": null })), + Some(web), + &store, + ); + assert_eq!(plan.unwrap().group, GroupPlan::Existing(None)); + } + + #[test] + fn a_host_on_a_credential_logs_in_as_the_credentials_user() { + let (store, web, credential) = store(); + let plan = plan_host( + fields(serde_json::json!({ "credential": credential })), + Some(web), + &store, + ) + .unwrap(); + assert_eq!(plan.draft.user.as_ref(), "deploy"); + assert!(plan.draft.credential.is_some()); + assert_eq!(plan.password, None); + + let given = |json| refused(plan_host(fields(json), Some(web), &store)); + assert_eq!( + given(serde_json::json!({ "credential": credential, "user": "root" })), + bad("使用凭据的主机以凭据的用户名 deploy 登录:去掉 user,或改凭据的用户名") + ); + assert_eq!( + given(serde_json::json!({ "credential": credential, "auth": "password" })), + bad("auth 为 password 或 no_password 时不能同时指定 credential") + ); + assert_eq!( + given(serde_json::json!({ "credential": "nope" })).0, + ErrorCode::CredentialNotFound + ); + } + + #[test] + fn only_saved_hosts_other_than_itself_are_jump_hosts() { + let (mut store, web, _) = store(); + let db = store.insert_unnotified(HostDraft::new( + "db", + "10.0.0.2", + 22, + "root", + AuthKind::Password, + None, + )); + let temporary = store.insert_temporary_unnotified( + HostDraft::new("临时", "10.0.0.3", 22, "root", AuthKind::Password, None), + None, + ); + let id = |host| store.host(host).unwrap().public_id.to_string(); + let jump = |hosts: Vec| { + plan_host( + fields(serde_json::json!({ "route": { "type": "jump", "hosts": hosts } })), + Some(web), + &store, + ) + }; + assert_eq!( + jump(vec![id(db)]).unwrap().draft.route, + Route::Jump(vec![Some(db)]) + ); + assert_eq!( + refused(jump(vec![id(web)])), + bad("跳板主机不能是这台主机自己") + ); + assert_eq!( + refused(jump(vec![id(db), id(db)])), + bad(&format!("跳板主机 {} 重复了", id(db))) + ); + assert_eq!( + refused(jump(vec![id(temporary)])), + bad(&format!( + "跳板只能是保存的主机:{} 是没有保存的连接", + id(temporary) + )) + ); + assert_eq!( + refused(jump(vec!["nope".into()])).0, + ErrorCode::HostNotFound + ); + } + + #[test] + fn a_host_that_moves_takes_its_passwords_along() { + let (mut store, web, _) = store(); + let proxy = ProxySettings::new(ProxyKind::Http, "proxy", 3128).with_user("me"); + store.update_unnotified( + web, + store + .host(web) + .unwrap() + .draft() + .with_route(Route::Proxy(proxy.clone())), + ); + let plan = plan_host( + fields(serde_json::json!({ "port": 2222 })), + Some(web), + &store, + ) + .unwrap(); + let before = SecretRef::password("root", "10.0.0.1", 22); + assert_eq!( + plan.password, + Some(( + SecretRef::password("root", "10.0.0.1", 2222), + SecretChange::Carry(before) + )) + ); + // The route left out: the proxy and its password as they were. + assert_eq!( + plan.proxy_password, + Some((proxy.password_secret().unwrap(), SecretChange::Keep)) + ); + + let plan = plan_host( + fields(serde_json::json!({ + "password": null, + "route": { "type": "proxy", "kind": "socks5", "host": "proxy", "port": 1080, "user": "me" }, + })), + Some(web), + &store, + ) + .unwrap(); + assert_eq!(plan.password.unwrap().1, SecretChange::Delete); + let (moved, change) = plan.proxy_password.unwrap(); + assert_eq!(moved, SecretRef::proxy("me", "proxy", 1080)); + assert_eq!( + change, + SecretChange::Carry(proxy.password_secret().unwrap()) + ); + + let plan = plan_host( + fields(serde_json::json!({ "password": "hunter2" })), + Some(web), + &store, + ) + .unwrap(); + assert_eq!( + plan.password.unwrap().1, + SecretChange::Set(Secret::new("hunter2")) + ); + } + + #[test] + fn what_show_prints_goes_back_in_and_typos_do_not() { + let (store, web, _) = store(); + let shown = serde_json::to_value(host_details(store.host(web).unwrap(), &store)).unwrap(); + let read = host_fields(shown).unwrap(); + assert_eq!(read.name.as_deref(), Some("web")); + assert_eq!(read.group, Some(Some("生产".into()))); + assert_eq!(read.route, Some(RouteFields::Direct)); + assert_eq!(read.auth, Some(AuthChoice::Password)); + assert_eq!(read.password, None); + let plan = plan_host(read, Some(web), &store).unwrap(); + assert_eq!(plan.draft, store.host(web).unwrap().draft()); + + let error = host_fields(serde_json::json!({ "name": "web", "adress": "h" })).unwrap_err(); + assert!(error.contains("adress"), "{error}"); + let error = host_fields(serde_json::json!({ "route": { "type": "proxy", "host": "p", "port": 1, "pasword": "x" } })) + .unwrap_err(); + assert!(error.contains("pasword"), "{error}"); + let read = credential_fields( + serde_json::json!({ "id": "x", "kept": true, "key_path": null, "hosts": [] }), + ) + .unwrap(); + assert_eq!(read, CredentialFields::default()); + } + + #[test] + fn a_credential_takes_a_key_file_or_the_keys_text() { + let (store, _, _) = store(); + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join("id_ed25519"); + std::fs::write(&key_file, "key").unwrap(); + let credential = |json: serde_json::Value, store: &HostStore| { + plan_credential(credential_fields(json).unwrap(), None, store) + }; + + let plan = credential( + serde_json::json!({ "name": "deploy", "key_path": key_file, "passphrase": "p" }), + &store, + ) + .unwrap(); + assert_eq!(plan.draft.kind, CredentialKind::Key); + assert_eq!(plan.draft.user.as_ref(), "root"); + assert_eq!(plan.passphrase, SecretChange::Set(Secret::new("p"))); + assert_eq!(plan.password, SecretChange::Keep); + + let missing = dir.path().join("missing"); + assert_eq!( + refused( + credential( + serde_json::json!({ "name": "x", "key_path": missing }), + &store + ) + .map(|_| ()) + ), + bad(&format!("私钥文件不存在:{}", missing.display())) + ); + assert_eq!( + refused( + credential( + serde_json::json!({ "name": "x", "key_path": "id_ed25519" }), + &store + ) + .map(|_| ()) + ), + bad("私钥文件必须是绝对路径:id_ed25519") + ); + assert_eq!( + refused( + credential( + serde_json::json!({ "name": "x", "kind": "agent", "password": "p" }), + &store + ) + .map(|_| ()) + ), + bad("只有密码凭据(kind 为 password)才保存密码") + ); + assert_eq!( + refused( + credential(serde_json::json!({ "name": "x", "kind": "key" }), &store).map(|_| ()) + ), + bad("密钥凭据需要选择私钥文件") + ); + + // A pasted key needs somewhere to be kept, and to be a private key. + let key = GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap(); + let text = key.encode("deploy", "").unwrap().to_string(); + let pasted = serde_json::json!({ "name": "x", "private_key": text, "passphrase": "p" }); + assert_eq!( + refused(credential(pasted.clone(), &store).map(|_| ())).0, + ErrorCode::SaveFailed + ); + let kept = HostStore::empty().with_key_dir(dir.path().join("keys")); + let plan = credential(pasted, &kept).unwrap(); + assert!(plan.private_key.is_some()); + // Not encrypted, so no passphrase to keep. + assert_eq!(plan.passphrase, SecretChange::Delete); + let public = key.public_key_line("deploy"); + assert_eq!( + refused( + credential( + serde_json::json!({ "name": "x", "private_key": public }), + &kept + ) + .map(|_| ()) + ), + bad("这是公钥,请粘贴私钥(以 -----BEGIN 开头的那一段)") + ); + + let plan = credential(serde_json::json!({ "name": "x", "password": "p" }), &store).unwrap(); + assert_eq!(plan.draft.kind, CredentialKind::Password); + assert_eq!(plan.password, SecretChange::Set(Secret::new("p"))); + assert_eq!( + credential(serde_json::json!({ "name": "x", "kind": "agent" }), &store) + .unwrap() + .draft + .kind, + CredentialKind::Agent + ); + let _ = CredentialKindChoice::Agent; + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ef6e90d..564317d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -10,6 +10,7 @@ mod install; mod install_windows; mod integration; mod link; +mod manage; #[cfg(windows)] mod pipe_windows; mod protocol; @@ -34,20 +35,60 @@ pub use install::{ }; pub use integration::{CliIntegration, IntegrationStatus}; pub use link::{OpenLink, link_arguments}; -pub use protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary}; -pub use server::{CliBackend, CliServer, CliTarget}; +pub use manage::CliChange; +pub(crate) use manage::{ + CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets, + find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential, + plan_host, with_saved_credential_secrets, with_saved_passwords, +}; +pub use protocol::{ + AuthChoice, CliError, CredentialDeleted, CredentialDetails, CredentialFields, + CredentialKindChoice, ErrorCode, HostDeleted, HostDetails, HostFields, HostInfo, ProxyChoice, + Reply, Request, RouteDetails, RouteFields, Secret, TransferCounters, TransferSummary, +}; +pub use server::{ChangeReply, CliBackend, CliServer, CliTarget}; const AFTER_HELP: &str = "\ -Hosts are named by the 16-character ID that `shellrs list` prints (the one ShellRS copies with 复制 ID). +Hosts are named by the 16-character ID that `shellrs hosts list` prints (the one ShellRS copies with 复制 ID). ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on. Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures; 255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`)."; -/// Run commands and move files on the SSH hosts saved in ShellRS. +const HOST_FIELDS: &str = "\ +Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given. + name Required to create. + host The address (IP or host name). Required to create. Also read as `address`. + port Default 22. + user Default root. A host using a credential logs in as the credential's user. + group A group path such as \"生产/数据库\", made when it is not there; null for the top level. + auth \"password\", \"credential\" or \"no_password\". + credential A credential ID from `shellrs credentials list`, for auth \"credential\" (implied). + password The host's own password, saved to the keychain and never printed; null deletes it. + Changing host, port or user without it keeps the saved one. + route {\"type\": \"direct\"} + {\"type\": \"jump\", \"hosts\": [ID, ...]} (saved hosts, in order) + {\"type\": \"proxy\", \"kind\": \"http\" | \"socks5\", \"host\": ..., \"port\": ..., + \"user\": ..., \"password\": ...} + notes Free text. +What `shellrs hosts show --json` prints can be edited and passed back as it is."; + +const CREDENTIAL_FIELDS: &str = "\ +Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given. + name Required to create. + kind \"password\" (the default), \"key\" (implied by a key) or \"agent\". + user Default root. + password A password credential's, saved to the keychain and never printed; null deletes it. + key_path A private key file on this machine (relative to the current directory, or ~/...). + private_key The private key's text instead, for ShellRS to keep in a file of its own. + passphrase The key's passphrase, saved to the keychain; null deletes it. +What `shellrs credentials show --json` prints can be edited and passed back as it is."; + +/// Run commands, move files and manage the SSH hosts saved in ShellRS. /// /// ShellRS holds the passwords and keys and makes the connections; this -/// command never sees them. +/// command never reads them. One given to it to save goes to the keychain +/// and is never printed back. #[derive(Debug, Parser)] #[command(name = "shellrs", version, after_help = AFTER_HELP)] struct Cli { @@ -57,14 +98,15 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - /// List the saved hosts. - List { - /// Only hosts whose name, host, user, group or ID matches. - #[arg(short, long)] - query: Option, - /// Print JSON. Implied when stdout is not a terminal. - #[arg(long)] - json: bool, + /// Show, create, change or delete saved hosts. + Hosts { + #[command(subcommand)] + command: HostsCommand, + }, + /// Show, create, change or delete saved credentials. + Credentials { + #[command(subcommand)] + command: CredentialsCommand, }, /// Run one command on a saved host and print its output. /// @@ -72,7 +114,7 @@ enum Command { /// and closes it. The command gets no stdin. The exit code is the /// remote command's. Exec { - /// Host ID, from `shellrs list`. + /// Host ID, from `shellrs hosts list`. #[arg(required_unless_present = "json")] id: Option, /// One complete remote shell command, quoted as one argument. @@ -97,7 +139,7 @@ enum Command { /// copy's path. Folders are copied recursively; existing files are /// overwritten. Upload { - /// Host ID, from `shellrs list`. + /// Host ID, from `shellrs hosts list`. id: String, /// Local file or folder. local: PathBuf, @@ -111,7 +153,7 @@ enum Command { /// /// Destination rules are scp's, as for `upload`. Download { - /// Host ID, from `shellrs list`. + /// Host ID, from `shellrs hosts list`. id: String, /// Remote file or folder. `~` is the login directory. remote: String, @@ -121,6 +163,115 @@ enum Command { #[arg(long)] json: bool, }, + /// Copy what a local folder holds into a folder on a saved host, + /// skipping files that have not changed. + /// + /// The remote folder is made when it is not there (its parent must + /// be). A file of the same size and modification time is left alone; + /// any other is copied over. Only this way: nothing comes back. + Sync { + /// Host ID, from `shellrs hosts list`. + id: String, + /// Local folder, whose contents are copied. + local: PathBuf, + /// Remote folder. `~` is the login directory. + remote: String, + /// First delete what is in the remote folder and not in the local + /// one. + #[arg(long)] + delete: bool, + /// Print the summary as JSON. Implied when stdout is not a terminal. + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum HostsCommand { + /// List the saved hosts, and the ones connected to without saving + /// while their tabs are open. + List { + /// Only hosts whose name, host, user, group or ID matches. + #[arg(short, long)] + query: Option, + /// Print JSON. Implied when stdout is not a terminal. + #[arg(long)] + json: bool, + }, + /// Show one host in full. Passwords are never shown, only whether one + /// is saved. + Show { + /// Host ID, from `shellrs hosts list`. + id: String, + #[arg(long)] + json: bool, + }, + /// Save a new host, described by JSON on stdin. + #[command(after_help = HOST_FIELDS)] + Create { + #[arg(long)] + json: bool, + }, + /// Change a saved host: the fields of the JSON on stdin. + #[command(after_help = HOST_FIELDS)] + Update { + /// Host ID, from `shellrs hosts list`. + id: String, + #[arg(long)] + json: bool, + }, + /// Delete a saved host, with its port forwarding rules. + Delete { + /// Host ID, from `shellrs hosts list`. + id: String, + /// Delete it even with its tabs open in ShellRS, closing them. + #[arg(long)] + force: bool, + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum CredentialsCommand { + /// List the credentials. + List { + /// Only credentials whose name, user, kind or ID matches. + #[arg(short, long)] + query: Option, + #[arg(long)] + json: bool, + }, + /// Show one credential. Passwords and keys are never shown, only + /// whether they are saved. + Show { + /// Credential ID, from `shellrs credentials list`. + id: String, + #[arg(long)] + json: bool, + }, + /// Save a new credential, described by JSON on stdin. + #[command(after_help = CREDENTIAL_FIELDS)] + Create { + #[arg(long)] + json: bool, + }, + /// Change a credential: the fields of the JSON on stdin. + #[command(after_help = CREDENTIAL_FIELDS)] + Update { + /// Credential ID, from `shellrs credentials list`. + id: String, + #[arg(long)] + json: bool, + }, + /// Delete a credential. The hosts using it log in on their own from + /// then on. + Delete { + /// Credential ID, from `shellrs credentials list`. + id: String, + #[arg(long)] + json: bool, + }, } /// The command line entry: `args` without the program name. Returns the @@ -158,10 +309,6 @@ pub fn main(args: Vec) -> i32 { /// cannot be sent. fn request(command: Command, console: &mut Console) -> Result { Ok(match command { - Command::List { query, json } => { - console.json |= json; - Request::List { query } - } Command::Exec { id, command, @@ -210,9 +357,129 @@ fn request(command: Command, console: &mut Console) -> Result { destination: absolute(local, console)?, } } + Command::Sync { + id, + local, + remote, + delete, + json, + } => { + console.json |= json; + Request::Sync { + host: id, + source: absolute(local, console)?, + destination: remote, + delete, + } + } + Command::Hosts { command } => hosts_request(command, console)?, + Command::Credentials { command } => credentials_request(command, console)?, }) } +fn hosts_request(command: HostsCommand, console: &mut Console) -> Result { + Ok(match command { + HostsCommand::List { query, json } => { + console.json |= json; + Request::List { query } + } + HostsCommand::Show { id, json } => { + console.json |= json; + Request::ShowHost { host: id } + } + HostsCommand::Create { json } => { + console.json |= json; + Request::CreateHost { + fields: host_fields(console, "shellrs hosts create < host.json")?, + } + } + HostsCommand::Update { id, json } => { + console.json |= json; + let example = format!("shellrs hosts update {id} < changes.json"); + Request::UpdateHost { + fields: host_fields(console, &example)?, + host: id, + } + } + HostsCommand::Delete { id, force, json } => { + console.json |= json; + Request::DeleteHost { host: id, force } + } + }) +} + +fn credentials_request(command: CredentialsCommand, console: &mut Console) -> Result { + Ok(match command { + CredentialsCommand::List { query, json } => { + console.json |= json; + Request::ListCredentials { query } + } + CredentialsCommand::Show { id, json } => { + console.json |= json; + Request::ShowCredential { credential: id } + } + CredentialsCommand::Create { json } => { + console.json |= json; + Request::CreateCredential { + fields: credential_fields(console, "shellrs credentials create < credential.json")?, + } + } + CredentialsCommand::Update { id, json } => { + console.json |= json; + let example = format!("shellrs credentials update {id} < changes.json"); + Request::UpdateCredential { + fields: credential_fields(console, &example)?, + credential: id, + } + } + CredentialsCommand::Delete { id, json } => { + console.json |= json; + Request::DeleteCredential { credential: id } + } + }) +} + +/// The JSON object on stdin. Not from a terminal: nothing would say what +/// to type, or that it waits for it. +fn read_json(console: &mut Console, example: &str) -> Result { + if io::stdin().is_terminal() { + return Err(console.error( + ErrorCode::BadRequest, + &format!("请从标准输入传入 JSON,例如 {example}"), + )); + } + let text = read_stdin(console)?; + let text = text.strip_prefix('\u{feff}').unwrap_or(&text); + serde_json::from_str(text).map_err(|error| { + console.error( + ErrorCode::BadRequest, + &format!("标准输入不是 JSON:{error}"), + ) + }) +} + +fn host_fields(console: &mut Console, example: &str) -> Result { + let value = read_json(console, example)?; + manage::host_fields(value) + .map_err(|error| console.error(ErrorCode::BadRequest, &format!("JSON 有误:{error}"))) +} + +/// A key file named relative to here, or to the home directory, is sent +/// as the absolute path the app can find. +fn credential_fields(console: &mut Console, example: &str) -> Result { + let value = read_json(console, example)?; + let mut fields = manage::credential_fields(value) + .map_err(|error| console.error(ErrorCode::BadRequest, &format!("JSON 有误:{error}")))?; + if let Some(path) = fields.key_path.take() { + let path = match path.strip_prefix("~") { + Ok(rest) => dirs::home_dir().map(|home| home.join(rest)).unwrap_or(path), + Err(_) => path, + }; + fields.key_path = Some(absolute(path, console)?); + } + Ok(fields) +} + fn read_stdin(console: &mut Console) -> Result { let mut text = String::new(); std::io::stdin() diff --git a/src/cli/protocol.rs b/src/cli/protocol.rs index ae68b38..98144a6 100644 --- a/src/cli/protocol.rs +++ b/src/cli/protocol.rs @@ -11,13 +11,19 @@ use std::{ path::PathBuf, }; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned}; +use zeroize::Zeroizing; use super::link::OpenLink; /// Bumped when a request or reply changes shape. The command and the app /// come from the same build, so they differ only while an older copy still /// runs, or on Windows while the copy on the PATH has not been updated. +/// +/// New requests and replies are added without bumping it, so a command +/// and an app of different builds still agree on what both know; an app +/// asked for a request it does not know answers `bad_request`, which the +/// command explains. pub const PROTOCOL_VERSION: u32 = 2; /// Frames larger than this are refused, so a confused peer cannot make the @@ -104,6 +110,13 @@ pub struct Envelope { pub request: Request, } +/// The version alone, read before the request: a request this build does +/// not know is then a version mismatch rather than a puzzle. +#[derive(Deserialize)] +pub struct VersionOnly { + pub version: u32, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Request { @@ -126,6 +139,49 @@ pub enum Request { source: String, destination: PathBuf, }, + /// Copy what the local folder `source` holds into `destination` on the + /// host, skipping what has not changed; with `delete`, remove what is + /// there and not here first. + Sync { + host: String, + source: PathBuf, + destination: String, + #[serde(default)] + delete: bool, + }, + ShowHost { + host: String, + }, + CreateHost { + fields: HostFields, + }, + UpdateHost { + host: String, + fields: HostFields, + }, + /// `force` closes the host's tabs; without it, a host with tabs open + /// is refused. + DeleteHost { + host: String, + #[serde(default)] + force: bool, + }, + ListCredentials { + query: Option, + }, + ShowCredential { + credential: String, + }, + CreateCredential { + fields: CredentialFields, + }, + UpdateCredential { + credential: String, + fields: CredentialFields, + }, + DeleteCredential { + credential: String, + }, /// Bring the app's window forward, and open `open` in it. Not the /// `shellrs` command's: ShellRS sends it when it is opened while it is /// already running, so it is answered whatever 启用外部 CLI says. @@ -151,6 +207,14 @@ pub enum Reply { Exit { code: i32, }, + /// A host as it is now: shown, created or changed. + Host(HostDetails), + HostDeleted(HostDeleted), + Credentials { + credentials: Vec, + }, + Credential(CredentialDetails), + CredentialDeleted(CredentialDeleted), /// The app heard [`Request::Activate`]. Activated, Error { @@ -198,8 +262,13 @@ pub struct TransferCounters { pub struct TransferSummary { pub files: u64, pub bytes: u64, + /// For a sync, the files left alone because they had not changed. pub skipped: u64, pub failed: u64, + /// What a sync with `--delete` removed from the host. Missing from an + /// older app's answer. + #[serde(default)] + pub deleted: u64, /// Why each failed item failed. pub failures: Vec, } @@ -221,6 +290,12 @@ pub enum ErrorCode { ConnectFailed, TransferFailed, BadRequest, + CredentialNotFound, + /// A host with tabs open, asked to be deleted without `--force`. + HostInUse, + /// A change was made, but could not all be written to disk or the + /// keychain. + SaveFailed, } impl ErrorCode { @@ -236,6 +311,9 @@ impl ErrorCode { ErrorCode::ConnectFailed => "connect_failed", ErrorCode::TransferFailed => "transfer_failed", ErrorCode::BadRequest => "bad_request", + ErrorCode::CredentialNotFound => "credential_not_found", + ErrorCode::HostInUse => "host_in_use", + ErrorCode::SaveFailed => "save_failed", } } } @@ -256,6 +334,262 @@ impl CliError { } } +/// A password, passphrase or private key on its way to the app, never +/// the other way. `Debug` leaves it out, so a request can be printed or +/// compared in a test without it showing. +#[derive(Clone, PartialEq, Eq)] +pub struct Secret(Zeroizing); + +impl Secret { + pub fn new(value: impl Into) -> Self { + Self(Zeroizing::new(value.into())) + } + + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Secret(***)") + } +} + +impl Serialize for Secret { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Secret { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer).map(Self::new) + } +} + +/// For a field that can be left out, set or set to `null`, three different +/// things: `None` when left out, `Some(None)` for `null`. +fn present<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} + +/// How a host logs in, as the CLI spells it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthChoice { + Password, + Credential, + NoPassword, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProxyChoice { + #[default] + Http, + Socks5, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialKindChoice { + Password, + Key, + Agent, +} + +/// A saved host in full, as `hosts show` prints it. `hosts create` and +/// `hosts update` read the same names back, so what one prints the other +/// takes. Never a secret: only whether one is saved, and only when shown. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostDetails { + pub id: String, + pub name: String, + /// The full group path, such as `生产/数据库`; `None` at the root. + pub group: Option, + pub host: String, + pub port: u16, + pub user: String, + pub auth: AuthChoice, + /// The credential's ID, for a host that logs in with one. + pub credential: Option, + /// For a host with its own password, whether one is saved. Looked up + /// only for `hosts show`, and for the host a change returns. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_saved: Option, + pub route: RouteDetails, + pub notes: String, + pub os: Option, + #[serde(default)] + pub temporary: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RouteDetails { + Direct, + /// The jump hosts' IDs in order; `None` for one that was deleted. + Jump { + hosts: Vec>, + }, + Proxy { + kind: ProxyChoice, + host: String, + port: u16, + user: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + password_saved: Option, + }, +} + +/// What `hosts create` sets and `hosts update` changes: every field may be +/// left out (`update` leaves it as it is), and those that can be empty may +/// be `null`. What `hosts show` prints and nobody sets is taken out before +/// these are read. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostFields { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, alias = "address", skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + /// `null` for the root. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub group: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub credential: Option>, + /// `null` deletes the saved one. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub password: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub route: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// A route as `hosts create` and `hosts update` take it: always whole. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum RouteFields { + Direct, + Jump { + hosts: Vec>, + }, + Proxy { + #[serde(default)] + kind: ProxyChoice, + host: String, + port: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + user: Option, + /// `null` deletes the saved one. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + password: Option>, + }, +} + +/// Deleted, with what went with it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostDeleted { + pub host: HostDetails, + /// Its port forwarding rules, deleted with it. + pub forwards: u64, + /// Other hosts that went through it, which now have a deleted jump + /// host in its place. + pub jump_users: u64, +} + +/// A saved credential, as `credentials show` prints it. Never a secret, +/// nor the path of a key ShellRS keeps: only whether one is saved. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialDetails { + pub id: String, + pub name: String, + pub kind: CredentialKindChoice, + pub user: String, + /// The user's own key file; `None` for a key ShellRS keeps (`kept`). + pub key_path: Option, + /// The key was pasted or generated, and ShellRS keeps it itself. + #[serde(default)] + pub kept: bool, + /// The IDs of the saved hosts that log in with it. + #[serde(default)] + pub hosts: Vec, + /// Looked up only for `credentials show`, and for the credential a + /// change returns. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_saved: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase_saved: Option, +} + +/// What `credentials create` sets and `credentials update` changes, as +/// for [`HostFields`]. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CredentialFields { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + /// A password credential's; `null` deletes the saved one. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub password: Option>, + /// A key file on this machine, absolute by the time it reaches the app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_path: Option, + /// The key's text, for ShellRS to keep. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub private_key: Option, + /// The key's; `null` deletes the saved one. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub passphrase: Option>, +} + +/// Deleted; the hosts that logged in with it now log in on their own. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialDeleted { + pub credential: CredentialDetails, + pub released: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/server.rs b/src/cli/server.rs index 7f7550f..0f5b4e5 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -3,24 +3,34 @@ //! backend's, so tests can put a fake behind the same server. use std::{ + collections::VecDeque, io::{self, BufReader}, path::{Path, PathBuf}, sync::{ Arc, Mutex, RwLock, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, }, thread::JoinHandle, + time::Duration, }; #[cfg(unix)] use tokio::sync::watch; use super::link::OpenLink; +use super::manage::{ + CliChange, CredentialSecrets, HostSecrets, credential_details, credential_matches, + credential_secrets, host_details, host_info, host_secrets, with_saved_credential_secrets, + with_saved_passwords, +}; use super::protocol::{ - CliError, Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request, - TransferCounters, TransferSummary, parse_json, read_frame, write_frame, write_json, + CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo, + PROTOCOL_VERSION, Reply, Request, TransferCounters, TransferSummary, VersionOnly, parse_json, + read_frame, write_frame, write_json, }; use crate::host::{Host, HostLogin, HostStore, matches_query}; +use crate::secrets::SecretRef; use crate::ssh::ExecStream; /// Does what a CLI request asks. Every method blocks: each request has a @@ -56,6 +66,22 @@ pub trait CliBackend: Send + Sync + 'static { destination: &Path, progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, ) -> Result; + + /// Bring the folder `destination` on the host in line with the local + /// folder `source`: what changed is copied, what did not is left alone, + /// and with `delete` what is not in `source` goes. + fn sync( + &self, + target: &CliTarget, + source: &Path, + destination: &str, + delete: bool, + progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, + ) -> Result; + + /// Whether the keychain (or, for a host not saved, memory) holds this + /// entry. Only asked when one host or credential is shown. + fn is_saved(&self, secret: &SecretRef) -> bool; } /// A saved host as the CLI sees it, with the login it connects with @@ -63,35 +89,23 @@ pub trait CliBackend: Send + Sync + 'static { #[derive(Clone, Debug)] pub struct CliTarget { info: HostInfo, + details: HostDetails, + secrets: HostSecrets, host: Host, login: HostLogin, } impl CliTarget { - /// `group` is the host's full group path, if it has one. - pub fn new(host: &Host, login: HostLogin, group: Option) -> Self { + fn of(host: &Host, store: &HostStore) -> Self { Self { - info: HostInfo { - id: host.public_id.to_string(), - name: host.name.to_string(), - group, - user: host.user.to_string(), - host: host.address.to_string(), - port: host.port, - os: host.os.map(|os| os.as_str().to_string()), - temporary: false, - }, + info: host_info(host, store), + details: host_details(host, store), + secrets: host_secrets(host, store), host: host.clone(), - login, + login: store.login_of(host), } } - /// Marked as not saved. - fn temporary(mut self) -> Self { - self.info.temporary = true; - self - } - pub fn host(&self) -> &Host { &self.host } @@ -106,22 +120,12 @@ impl CliTarget { /// the password kept in memory; a bastion host may refuse a login it /// gave out for one use. pub fn all(store: &HostStore) -> Vec { - let saved = store.hosts().iter().map(|host| { - let names = host - .group - .map(|id| store.group_names(id)) - .unwrap_or_default(); - Self::new( - host, - store.login_of(host), - (!names.is_empty()).then(|| names.join("/")), - ) - }); - let temporary = store - .temporary_hosts() + store + .hosts() .iter() - .map(|host| Self::new(host, store.login_of(host), None).temporary()); - saved.chain(temporary).collect() + .chain(store.temporary_hosts()) + .map(|host| Self::of(host, store)) + .collect() } /// The host tree's search, plus the group path and the ID. @@ -137,15 +141,54 @@ impl CliTarget { } } +/// A saved credential as the CLI sees it. +#[derive(Clone, Debug)] +struct CliCredential { + details: CredentialDetails, + secrets: CredentialSecrets, +} + +/// How long a change may wait for the app to take it up. Only the wait in +/// line counts: once taken, the change is being made and its outcome is +/// waited for however long the keychain takes, or an agent would try +/// again and make it twice. +const QUEUE_TIMEOUT: Duration = if cfg!(test) { + Duration::from_millis(300) +} else { + Duration::from_secs(10) +}; + +/// A change waiting for the app, with where its outcome goes. +struct PendingChange { + id: u64, + change: CliChange, + reply: ChangeReply, +} + +/// Where the app sends the outcome of a change it took up. Dropping it +/// unsent tells the request it will not come. +pub struct ChangeReply(mpsc::Sender>); + +impl ChangeReply { + pub fn send(self, outcome: Result) { + let _ = self.0.send(outcome); + } +} + /// What the listener and the request threads share with the app. struct Shared { enabled: AtomicBool, targets: RwLock>, + credentials: RwLock>, backend: Arc, /// Set when ShellRS was opened again while this one runs, with the /// links it was opened with, until the app has come forward and opened /// them. activation: Mutex>>, + /// Changes to hosts and credentials, oldest first, until the app takes + /// them up: only the UI thread can change the store. + changes: Mutex>, + next_change: AtomicU64, } /// Listens on the CLI socket until dropped. Always listening, even with @@ -164,8 +207,11 @@ impl CliServer { let shared = Arc::new(Shared { enabled: AtomicBool::new(false), targets: RwLock::new(Vec::new()), + credentials: RwLock::new(Vec::new()), backend, activation: Mutex::new(None), + changes: Mutex::new(VecDeque::new()), + next_change: AtomicU64::new(0), }); let listener = listen(endpoint, shared.clone())?; Ok(Self { @@ -178,12 +224,38 @@ impl CliServer { self.shared.enabled.store(enabled, Ordering::Release); } - pub fn set_targets(&self, targets: Vec) { + /// What the requests see of the hosts and credentials: `store` as it + /// is now. Called whenever it changes. + pub fn set_hosts(&self, store: &HostStore) { + let credentials = store + .credentials() + .iter() + .map(|credential| CliCredential { + details: credential_details(credential, store), + secrets: credential_secrets(credential), + }) + .collect(); *self .shared .targets .write() - .unwrap_or_else(|error| error.into_inner()) = targets; + .unwrap_or_else(|error| error.into_inner()) = CliTarget::all(store); + *self + .shared + .credentials + .write() + .unwrap_or_else(|error| error.into_inner()) = credentials; + } + + /// The oldest change waiting, for the app to make and answer. One at a + /// time: the next is only taken once this one is answered. + pub fn take_change(&self) -> Option<(CliChange, ChangeReply)> { + self.shared + .changes + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front() + .map(|pending| (pending.change, pending.reply)) } /// Whether ShellRS was opened again since this was last asked, and the @@ -199,6 +271,18 @@ impl CliServer { } } +impl Drop for CliServer { + /// The app is going: whatever waits for it to take up a change hears + /// that it never will. + fn drop(&mut self) { + self.shared + .changes + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + } +} + /// The listening thread, stopped when dropped. On Unix, the socket file /// goes with it. #[cfg(unix)] @@ -436,11 +520,15 @@ fn respond( writer: &mut impl io::Write, shared: &Shared, ) -> Result { - let envelope: Envelope = parse_json(payload) - .map_err(|error| CliError::new(ErrorCode::BadRequest, error.to_string()))?; + let bad_request = |error: io::Error| CliError::new(ErrorCode::BadRequest, error.to_string()); + let envelope = parse_json::(payload); // Before the checks below: coming forward means the same in every // version, and it is ShellRS being opened again, not the external CLI. - if let Request::Activate { open } = &envelope.request { + if let Ok(Envelope { + request: Request::Activate { open }, + .. + }) = &envelope + { shared .activation .lock() @@ -449,13 +537,17 @@ fn respond( .extend(open.clone()); return Ok(Reply::Activated); } - if envelope.version != PROTOCOL_VERSION { + // The version before the request, which another version may spell in + // a way this one cannot read. + let version = parse_json::(payload).map_err(bad_request)?; + if version.version != PROTOCOL_VERSION { return Err(CliError::new( ErrorCode::VersionMismatch, "shellrs 命令与正在运行的 ShellRS 版本不同:\ 请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI", )); } + let envelope = envelope.map_err(bad_request)?; if !shared.enabled.load(Ordering::Acquire) { return Err(CliError::new( ErrorCode::NotEnabled, @@ -520,11 +612,131 @@ fn respond( }) .map(Reply::TransferDone) } + Request::Sync { + host, + source, + destination, + delete, + } => { + let target = find(shared, &host)?; + if !source.is_absolute() { + return Err(not_absolute(&source)); + } + shared + .backend + .sync(&target, &source, &destination, delete, &mut |counters| { + write_json(writer, &Reply::Progress(counters)) + }) + .map(Reply::TransferDone) + } + Request::ShowHost { host } => { + let target = find(shared, &host)?; + let saved = |secret: &SecretRef| shared.backend.is_saved(secret); + Ok(Reply::Host(with_saved_passwords( + target.details, + &target.secrets, + saved, + ))) + } + Request::ListCredentials { query } => { + let credentials = shared + .credentials + .read() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .filter(|credential| { + query + .as_deref() + .is_none_or(|query| credential_matches(&credential.details, query)) + }) + .map(|credential| credential.details.clone()) + .collect(); + Ok(Reply::Credentials { credentials }) + } + Request::ShowCredential { credential } => { + let found = shared + .credentials + .read() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .find(|known| known.details.id == credential.trim()) + .cloned() + .ok_or_else(|| { + CliError::new( + ErrorCode::CredentialNotFound, + format!( + "没有 ID 为 {credential} 的凭据:请用 shellrs credentials list 查看" + ), + ) + })?; + let saved = |secret: &SecretRef| shared.backend.is_saved(secret); + Ok(Reply::Credential(with_saved_credential_secrets( + found.details, + &found.secrets, + saved, + ))) + } + Request::CreateHost { fields } => change(shared, CliChange::CreateHost(fields)), + Request::UpdateHost { host, fields } => { + change(shared, CliChange::UpdateHost { host, fields }) + } + Request::DeleteHost { host, force } => { + change(shared, CliChange::DeleteHost { host, force }) + } + Request::CreateCredential { fields } => change(shared, CliChange::CreateCredential(fields)), + Request::UpdateCredential { credential, fields } => { + change(shared, CliChange::UpdateCredential { credential, fields }) + } + Request::DeleteCredential { credential } => { + change(shared, CliChange::DeleteCredential { credential }) + } // Answered above, before anything was checked. Request::Activate { .. } => Ok(Reply::Activated), } } +/// Hand a change to the app and wait for its outcome. Taken back, so +/// certainly not made, when the app does not take it up in time. +fn change(shared: &Shared, change: CliChange) -> Result { + let gone = || { + CliError::new( + ErrorCode::ConnectFailed, + "ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成", + ) + }; + let (sender, outcome) = mpsc::channel(); + let id = shared.next_change.fetch_add(1, Ordering::Relaxed); + shared + .changes + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push_back(PendingChange { + id, + change, + reply: ChangeReply(sender), + }); + match outcome.recv_timeout(QUEUE_TIMEOUT) { + Ok(outcome) => return outcome, + Err(mpsc::RecvTimeoutError::Disconnected) => return Err(gone()), + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + { + let mut changes = shared + .changes + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(index) = changes.iter().position(|pending| pending.id == id) { + changes.remove(index); + return Err(CliError::new( + ErrorCode::ConnectFailed, + "ShellRS 没有及时处理这项改动,改动没有做:请稍后再试", + )); + } + } + // Taken up just now: being made. + outcome.recv().unwrap_or_else(|_| Err(gone())) +} + fn find(shared: &Shared, id: &str) -> Result { shared .targets @@ -536,7 +748,7 @@ fn find(shared: &Shared, id: &str) -> Result { .ok_or_else(|| { CliError::new( ErrorCode::HostNotFound, - format!("没有 ID 为 {id} 的主机:请用 shellrs list 查看"), + format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"), ) }) } diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 1d90603..8efa25e 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -12,12 +12,19 @@ use clap::Parser as _; use super::client::ascii_json; use super::client::{self, Console}; use super::link::OpenLink; -use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary}; +use super::manage::CliChange; +use super::protocol::{ + AuthChoice, CliError, CredentialDetails, ErrorCode, HostDetails, HostFields, HostInfo, Reply, + Request, RouteDetails, Secret, TransferCounters, TransferSummary, +}; use super::server::{CliBackend, CliServer, CliTarget}; -use super::{Cli, Command}; +use super::{Cli, Command, CredentialsCommand, HostsCommand}; use super::{ConsoleText, exec_request, normalize_command}; use crate::app::cli_endpoint; -use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin, HostStore, SshLink}; +use crate::host::{ + AuthKind, CredentialDraft, CredentialKind, GroupDraft, Host, HostDraft, HostId, HostStore, + SshLink, +}; use crate::secrets::SecretRef; use crate::ssh::ExecStream; @@ -70,6 +77,7 @@ impl CliBackend for FakeBackend { bytes: 20, skipped: 0, failed: 1, + deleted: 0, failures: vec!["无法上传:permission denied".into()], }) } @@ -83,13 +91,47 @@ impl CliBackend for FakeBackend { ) -> Result { Ok(TransferSummary::default()) } + + fn sync( + &self, + target: &CliTarget, + source: &Path, + destination: &str, + delete: bool, + _: &mut dyn FnMut(TransferCounters) -> io::Result<()>, + ) -> Result { + self.calls.lock().unwrap().push(format!( + "sync {} {} {destination} {delete}", + target.host().name, + source.display() + )); + Ok(TransferSummary { + files: 2, + bytes: 2048, + skipped: 40, + failed: 0, + deleted: 3, + failures: Vec::new(), + }) + } + + /// Only a host's own password, of what the fixture has. + fn is_saved(&self, secret: &SecretRef) -> bool { + matches!(secret, SecretRef::Password { user, .. } if user == "root") + } } -fn host(id: u64, name: &str, host: &str) -> Host { - Host::new( - HostId(id), - HostDraft::new(name, host, 22, "root", AuthKind::Password, Some(GroupId(1))), - ) +/// web-01 in 生产 and db-01 in 生产/数据库. +fn store() -> HostStore { + let mut store = HostStore::empty(); + let production = store.insert_group_unnotified(GroupDraft::new("生产", None)); + let database = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production))); + let draft = |name: &str, address: &str, group| { + HostDraft::new(name, address, 22, "root", AuthKind::Password, Some(group)) + }; + store.insert_unnotified(draft("web-01", "10.0.1.12", production)); + store.insert_unnotified(draft("db-01", "10.0.2.5", database)); + store } struct Fixture { @@ -105,12 +147,9 @@ fn fixture() -> Fixture { let socket = cli_endpoint(dir.path()); let backend = Arc::new(FakeBackend::default()); let server = CliServer::start(socket.clone(), backend.clone()).unwrap(); - let web = host(1, "web-01", "10.0.1.12"); - let db = host(2, "db-01", "10.0.2.5"); - server.set_targets(vec![ - CliTarget::new(&web, HostLogin::of(&web, None), Some("生产".into())), - CliTarget::new(&db, HostLogin::of(&db, None), Some("生产/数据库".into())), - ]); + let store = store(); + let web = store.hosts()[0].clone(); + server.set_hosts(&store); server.set_enabled(true); Fixture { _dir: dir, @@ -395,7 +434,7 @@ fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() { assert_eq!(targets[1].login().password, SecretRef::temporary(link.0)); let fixture = fixture(); - fixture.server.set_targets(targets); + fixture.server.set_hosts(&store); let (code, stdout, _) = run(&fixture.socket, Request::List { query: None }, true); assert_eq!(code, 0); let hosts: Vec = serde_json::from_str(&stdout).unwrap(); @@ -694,3 +733,359 @@ fn exec_json_gathers_the_output_and_says_errors_in_json() { ); assert!(stderr.is_empty()); } + +#[test] +fn the_new_commands_read_their_arguments() { + let parse = |args: &[&str]| Cli::try_parse_from(args).map(|cli| cli.command); + assert!(matches!( + parse(&["shellrs", "hosts", "show", "ID", "--json"]), + Ok(Command::Hosts { command: HostsCommand::Show { id, json: true } }) if id == "ID" + )); + assert!(matches!( + parse(&["shellrs", "hosts", "delete", "ID", "--force"]), + Ok(Command::Hosts { + command: HostsCommand::Delete { force: true, .. } + }) + )); + assert!(matches!( + parse(&["shellrs", "credentials", "list", "-q", "deploy"]), + Ok(Command::Credentials { command: CredentialsCommand::List { query: Some(query), .. } }) + if query == "deploy" + )); + assert!(matches!( + parse(&["shellrs", "sync", "ID", "./dist", "/srv/app", "--delete"]), + Ok(Command::Sync { delete: true, remote, .. }) if remote == "/srv/app" + )); + // Hosts are listed under `hosts`, like the rest of what is done to them. + assert!(matches!( + parse(&["shellrs", "hosts", "list", "-q", "web"]), + Ok(Command::Hosts { command: HostsCommand::List { query: Some(query), .. } }) + if query == "web" + )); + assert!(parse(&["shellrs", "list"]).is_err()); + // `credentials` has no --force: nothing of it stays open. + assert!(parse(&["shellrs", "credentials", "delete", "ID", "--force"]).is_err()); + assert!(parse(&["shellrs", "sync", "ID", "./dist"]).is_err()); +} + +fn saved_host(name: &str) -> HostDetails { + HostDetails { + id: "Jwg5rHvXCxw89paM".into(), + name: name.into(), + group: None, + host: "10.0.0.9".into(), + port: 22, + user: "root".into(), + auth: AuthChoice::Password, + credential: None, + password_saved: Some(true), + route: RouteDetails::Direct, + notes: String::new(), + os: None, + temporary: false, + } +} + +/// The app's end of a change: wait for it to come in, and hand it over +/// with what to answer it with. +fn take_change(server: &CliServer) -> (CliChange, super::server::ChangeReply) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if let Some(taken) = server.take_change() { + return taken; + } + assert!(std::time::Instant::now() < deadline, "no change came in"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +#[test] +fn a_change_waits_for_the_app_and_comes_back_as_it_made_it() { + let fixture = fixture(); + let socket = fixture.socket.clone(); + let fields = HostFields { + name: Some("db-02".into()), + host: Some("10.0.0.9".into()), + password: Some(Some(Secret::new("hunter2"))), + ..HostFields::default() + }; + let command = std::thread::spawn(move || run(&socket, Request::CreateHost { fields }, false)); + let (change, reply) = take_change(&fixture.server); + let CliChange::CreateHost(fields) = change else { + panic!("{change:?}"); + }; + // The password is there to be saved, and nowhere it could be printed. + assert_eq!( + fields + .password + .as_ref() + .map(|password| password.as_ref().map(Secret::expose)), + Some(Some("hunter2")) + ); + assert!(!format!("{fields:?}").contains("hunter2")); + reply.send(Ok(Reply::Host(saved_host("db-02")))); + let (code, stdout, _) = command.join().unwrap(); + assert_eq!(code, 0); + assert_eq!(stdout, "已创建主机「db-02」,ID Jwg5rHvXCxw89paM\n"); + + // What went wrong, said the way every error is. + let socket = fixture.socket.clone(); + let command = std::thread::spawn(move || { + run( + &socket, + Request::DeleteHost { + host: "x".into(), + force: false, + }, + false, + ) + }); + let (_, reply) = take_change(&fixture.server); + reply.send(Err(CliError::new( + ErrorCode::HostInUse, + "主机「x」有打开的标签", + ))); + let (code, _, stderr) = command.join().unwrap(); + assert_eq!(code, 255); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "shellrs: [host_in_use] 主机「x」有打开的标签\n" + ); +} + +#[test] +fn a_change_nobody_takes_up_is_taken_back_and_one_dropped_says_so() { + let fixture = fixture(); + // The tests' wait in line is short; nothing takes it up. + let (code, _, stderr) = run( + &fixture.socket, + Request::DeleteCredential { + credential: "x".into(), + }, + false, + ); + assert_eq!(code, 255); + let stderr = String::from_utf8(stderr).unwrap(); + assert!(stderr.contains("改动没有做"), "{stderr}"); + assert!(fixture.server.take_change().is_none(), "taken back"); + + // Taken up, then the app goes without an answer. + let socket = fixture.socket.clone(); + let command = std::thread::spawn(move || { + run( + &socket, + Request::DeleteCredential { + credential: "x".into(), + }, + false, + ) + }); + let (_, reply) = take_change(&fixture.server); + drop(reply); + let (code, _, stderr) = command.join().unwrap(); + assert_eq!(code, 255); + assert!( + String::from_utf8(stderr) + .unwrap() + .contains("ShellRS 在处理改动时关闭了") + ); +} + +#[test] +fn with_the_switch_off_no_change_reaches_the_app() { + let fixture = fixture(); + fixture.server.set_enabled(false); + let (code, _, stderr) = run( + &fixture.socket, + Request::CreateHost { + fields: HostFields::default(), + }, + false, + ); + assert_eq!(code, 255); + assert!( + String::from_utf8(stderr) + .unwrap() + .starts_with("shellrs: [not_enabled]") + ); + assert!(fixture.server.take_change().is_none()); +} + +#[test] +fn showing_a_host_or_a_credential_says_what_is_saved_and_never_what() { + let fixture = fixture(); + let mut store = store(); + let credential = store.insert_credential_unnotified(CredentialDraft::new( + "部署", + CredentialKind::Password, + "deploy", + )); + let keychain_id = store + .credential(credential) + .unwrap() + .keychain_id + .to_string(); + let db = store.hosts()[1].id; + let draft = store.host(db).unwrap().draft().with_credential(credential); + store.update_unnotified(db, draft); + fixture.server.set_hosts(&store); + let web_host = store.hosts()[0].clone(); + + let show = |host: &Host| { + let (code, stdout, _) = run(&fixture.socket, Request::ShowHost { host: id(host) }, true); + assert_eq!(code, 0); + serde_json::from_str::(&stdout).unwrap() + }; + let web = show(&web_host); + assert_eq!( + (web.auth, web.password_saved), + (AuthChoice::Password, Some(true)) + ); + assert_eq!(web.group.as_deref(), Some("生产")); + let db = show(store.host(db).unwrap()); + assert_eq!(db.auth, AuthChoice::Credential); + assert_eq!(db.credential.as_deref(), Some(keychain_id.as_str())); + assert_eq!((db.user.as_str(), db.password_saved), ("deploy", None)); + + let (_, table, _) = run( + &fixture.socket, + Request::ShowHost { + host: id(&web_host), + }, + false, + ); + assert!(table.contains("认证 密码(已保存)"), "{table}"); + assert!(table.contains("连接方式 直接连接"), "{table}"); + + let (code, stdout, _) = run( + &fixture.socket, + Request::ListCredentials { + query: Some("DEPLOY".into()), + }, + true, + ); + assert_eq!(code, 0); + let listed: Vec = serde_json::from_str(&stdout).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].hosts, std::slice::from_ref(&db.id)); + assert_eq!(listed[0].password_saved, None, "only looked up when shown"); + let (_, stdout, _) = run( + &fixture.socket, + Request::ShowCredential { + credential: keychain_id.clone(), + }, + true, + ); + let shown: CredentialDetails = serde_json::from_str(&stdout).unwrap(); + assert_eq!(shown.password_saved, Some(false)); + let (code, _, stderr) = run( + &fixture.socket, + Request::ShowCredential { + credential: "nope".into(), + }, + false, + ); + assert_eq!(code, 255); + assert!( + String::from_utf8(stderr) + .unwrap() + .starts_with("shellrs: [credential_not_found]") + ); +} + +#[test] +fn a_sync_says_what_had_not_changed_and_what_went() { + let fixture = fixture(); + let (code, stdout, _) = run( + &fixture.socket, + Request::Sync { + host: id(&fixture.web), + source: local("dist"), + destination: "/srv/app".into(), + delete: true, + }, + false, + ); + assert_eq!(code, 0); + assert_eq!( + stdout, + "已传输 2 个文件,共 2.0 KB,未变 40 个,删除 3 个\n" + ); + assert_eq!( + fixture.backend.calls.lock().unwrap().last().unwrap(), + &format!("sync web-01 {} /srv/app true", local("dist").display()) + ); + // Relative is the command's to resolve. + let (code, _, _) = run( + &fixture.socket, + Request::Sync { + host: id(&fixture.web), + source: "dist".into(), + destination: "/srv/app".into(), + delete: false, + }, + false, + ); + assert_eq!(code, 255); +} + +/// What an app sees of a request it does not know, and of a version it +/// does not speak, sent the way a later command would. +#[cfg(unix)] +#[test] +fn a_request_from_a_later_command_is_told_apart_from_a_bad_one() { + use super::protocol::{parse_json, read_frame, write_json}; + + let fixture = fixture(); + let answer = |envelope: serde_json::Value| { + let stream = std::os::unix::net::UnixStream::connect(&fixture.socket).unwrap(); + write_json(&mut &stream, &envelope).unwrap(); + let (_, payload) = read_frame(&mut &stream).unwrap().unwrap(); + parse_json::(&payload).unwrap() + }; + let Reply::Error { code, message } = + answer(serde_json::json!({ "version": 2, "request": { "type": "teleport" } })) + else { + panic!("answered"); + }; + assert_eq!(code, ErrorCode::BadRequest); + // Which the command takes for an app older than itself. + assert!(message.contains("unknown variant"), "{message}"); + let Reply::Error { code, .. } = + answer(serde_json::json!({ "version": 3, "request": { "type": "teleport" } })) + else { + panic!("answered"); + }; + assert_eq!(code, ErrorCode::VersionMismatch); +} + +/// An app from before a command was added, as the command hears it. +#[cfg(unix)] +#[test] +fn an_app_that_does_not_know_the_command_is_said_to_be_older() { + use super::protocol::{read_frame, write_json}; + + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("old.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let app = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + read_frame(&mut &stream).unwrap(); + write_json( + &mut &stream, + &Reply::Error { + code: ErrorCode::BadRequest, + message: "unknown variant `show_host`, expected one of `list`, `exec`".into(), + }, + ) + .unwrap(); + }); + let (code, _, stderr) = run(&socket, Request::ShowHost { host: "x".into() }, false); + app.join().unwrap(); + assert_eq!(code, 255); + assert!( + String::from_utf8(stderr) + .unwrap() + .starts_with("shellrs: [version_mismatch] 正在运行的 ShellRS 不认识这个命令"), + ); +} diff --git a/src/host/host_dialog.rs b/src/host/host_dialog.rs index 8eb4ef8..e3a8503 100644 --- a/src/host/host_dialog.rs +++ b/src/host/host_dialog.rs @@ -25,7 +25,7 @@ use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, Unknow use super::secret_fields::SecretFields; use super::{ AuthKind, Credential, CredentialId, CredentialKind, DEFAULT_USER, GroupId, Host, HostDraft, - HostId, HostLogin, HostStore, ProxyKind, ProxySettings, Route, group_options, + HostDraftError, HostId, HostLogin, HostStore, ProxyKind, ProxySettings, Route, group_options, }; pub use crate::shared::DeleteHandler; use crate::shared::{ @@ -504,16 +504,18 @@ impl HostForm { fn committed_route(&self, cx: &App) -> Result { match self.route { RouteChoice::Direct => Ok(Route::Direct), - RouteChoice::Jump if self.hops.is_empty() => Err("请添加跳板主机"), - RouteChoice::Jump if self.hops.contains(&None) => Err("请移除已删除的跳板主机"), + RouteChoice::Jump if self.hops.is_empty() => Err(HostDraftError::NoJumpHosts.message()), + RouteChoice::Jump if self.hops.contains(&None) => { + Err(HostDraftError::DeletedJumpHost.message()) + } RouteChoice::Jump => Ok(Route::Jump(self.hops.clone())), RouteChoice::Proxy => { let host = self.proxy_host.read(cx).value().trim().to_string(); if host.is_empty() { - return Err("请输入代理地址"); + return Err(HostDraftError::ProxyAddress.message()); } let port = parse_port(self.proxy_port.read(cx).value().trim()) - .ok_or("代理端口必须是 1 到 65535 之间的数字")?; + .ok_or(HostDraftError::ProxyPort.message())?; let user = self.proxy_user.read(cx).value().trim().to_string(); if user.is_empty() && !self.proxy_secret.read(cx).password(cx).is_empty() { return Err("填写代理密码时请同时填写用户名"); @@ -544,8 +546,8 @@ impl HostForm { let address = self.address.read(cx).value().trim().to_string(); let port = parse_port(self.port.read(cx).value().trim()); match (address.is_empty(), port) { - (true, _) => Err("请输入地址"), - (_, None) => Err("端口必须是 1 到 65535 之间的数字"), + (true, _) => Err(HostDraftError::Address.message()), + (_, None) => Err(HostDraftError::Port.message()), (_, Some(port)) => Ok((address, port)), } } @@ -680,7 +682,7 @@ impl HostForm { pub fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { let name = self.name.read(cx).value().trim().to_string(); let checked = if name.is_empty() { - Err("请输入名称") + Err(HostDraftError::Name.message()) } else { self.endpoint(cx).and_then(|endpoint| { Ok(( diff --git a/src/host/model.rs b/src/host/model.rs index 719763c..fd09558 100644 --- a/src/host/model.rs +++ b/src/host/model.rs @@ -583,8 +583,89 @@ impl HostDraft { pub fn password_secret(&self) -> SecretRef { SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port) } + + /// The draft with surrounding blanks removed and an empty user name + /// taken as [`super::DEFAULT_USER`], or the first thing wrong with it, + /// in the order the host form checks. What the form cannot leave out + /// of a draft (a credential to pick, a proxy password without a user) + /// is the caller's to check. + pub fn validated(mut self) -> Result { + self.name = self.name.trim().to_string().into(); + if self.name.is_empty() { + return Err(HostDraftError::Name); + } + self.address = self.address.trim().to_string().into(); + if self.address.is_empty() { + return Err(HostDraftError::Address); + } + if self.port == 0 { + return Err(HostDraftError::Port); + } + let user = self.user.trim(); + self.user = if user.is_empty() { + super::DEFAULT_USER.into() + } else { + user.to_string().into() + }; + match &mut self.route { + Route::Direct => {} + Route::Jump(hops) if hops.is_empty() => return Err(HostDraftError::NoJumpHosts), + Route::Jump(hops) if hops.contains(&None) => { + return Err(HostDraftError::DeletedJumpHost); + } + Route::Jump(_) => {} + Route::Proxy(proxy) => { + proxy.host = proxy.host.trim().to_string().into(); + if proxy.host.is_empty() { + return Err(HostDraftError::ProxyAddress); + } + if proxy.port == 0 { + return Err(HostDraftError::ProxyPort); + } + let user = proxy.user.as_deref().map(str::trim).unwrap_or_default(); + proxy.user = (!user.is_empty()).then(|| user.to_string().into()); + } + } + self.notes = self.notes.trim().to_string().into(); + Ok(self) + } } +/// Why a host draft cannot be saved. `Display` is the text the host form +/// shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostDraftError { + Name, + Address, + Port, + NoJumpHosts, + DeletedJumpHost, + ProxyAddress, + ProxyPort, +} + +impl HostDraftError { + pub fn message(self) -> &'static str { + match self { + HostDraftError::Name => "请输入名称", + HostDraftError::Address => "请输入地址", + HostDraftError::Port => "端口必须是 1 到 65535 之间的数字", + HostDraftError::NoJumpHosts => "请添加跳板主机", + HostDraftError::DeletedJumpHost => "请移除已删除的跳板主机", + HostDraftError::ProxyAddress => "请输入代理地址", + HostDraftError::ProxyPort => "代理端口必须是 1 到 65535 之间的数字", + } + } +} + +impl std::fmt::Display for HostDraftError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.message()) + } +} + +impl std::error::Error for HostDraftError {} + #[cfg(test)] mod tests { use super::*; diff --git a/src/host/store.rs b/src/host/store.rs index 23c1a43..c0d1aa3 100644 --- a/src/host/store.rs +++ b/src/host/store.rs @@ -1,9 +1,10 @@ +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; -use gpui_kit::{Context, EventEmitter, SharedString}; +use gpui_kit::{Context, EventEmitter, SharedString, Task}; use zeroize::Zeroizing; use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TemporarySecretStore}; @@ -88,6 +89,9 @@ pub struct HostStore { /// form are kept. `None` without a database to remember them, which is /// also how most tests run: the form then offers key files only. key_dir: Option, + /// Inside [`Self::capture_failures`], the failures reported so far, + /// besides the events that report them. + capturing: RefCell>>, } /// How many hosts the start page lists as recently connected. @@ -133,6 +137,7 @@ impl HostStore { database: None, secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))), key_dir: None, + capturing: RefCell::new(None), } } @@ -202,6 +207,7 @@ impl HostStore { database: Some(database), secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))), key_dir: None, + capturing: RefCell::new(None), }) } @@ -290,7 +296,7 @@ impl HostStore { } /// A saved host only: what the database's foreign keys may point at. - fn saved_host(&self, id: HostId) -> Option<&Host> { + pub(crate) fn saved_host(&self, id: HostId) -> Option<&Host> { self.hosts.iter().find(|host| host.id == id) } @@ -1436,9 +1442,7 @@ impl HostStore { if let Err(error) = std::fs::remove_file(path) && error.kind() != io::ErrorKind::NotFound { - cx.emit(HostStoreEvent::PersistFailed( - format!("ShellRS 保存的私钥未能删除:{error}").into(), - )); + self.report_failure(format!("ShellRS 保存的私钥未能删除:{error}").into(), cx); } self.save_secret(SecretRef::passphrase(path), None, cx); } @@ -1456,6 +1460,17 @@ impl HostStore { value: Option, cx: &mut Context, ) { + self.write_secret(secret, value, cx).detach(); + } + + /// [`Self::save_secret`], for a caller that has to know how it went: + /// the failure, already reported as `PersistFailed`. + pub fn write_secret( + &mut self, + secret: SecretRef, + value: Option, + cx: &mut Context, + ) -> Task> { let secrets = self.secrets(); let failure = match (&secret, value.is_some()) { (SecretRef::Password { .. }, true) => "密码未能写入系统钥匙串", @@ -1479,16 +1494,15 @@ impl HostStore { } }) .await; - if let Err(error) = result { + result.map_err(|error| { + let message = SharedString::from(format!("{failure}:{error}")); this.update(cx, |_, cx| { - cx.emit(HostStoreEvent::PersistFailed( - format!("{failure}:{error}").into(), - )); + cx.emit(HostStoreEvent::PersistFailed(message.clone())); }) .ok(); - } + message + }) }) - .detach(); } /// The distinct password entries these hosts log in with, their @@ -1549,12 +1563,36 @@ impl HostStore { if let Some(database) = &self.database && let Err(error) = write(database) { - cx.emit(HostStoreEvent::PersistFailed( - format!("{action}未能保存到本地数据库:{error}").into(), - )); + self.report_failure(format!("{action}未能保存到本地数据库:{error}").into(), cx); } } + /// Say that a change could not be kept, as `PersistFailed`, and to + /// [`Self::capture_failures`] when one is listening. + fn report_failure(&self, message: SharedString, cx: &mut Context) { + if let Some(failures) = self.capturing.borrow_mut().as_mut() { + failures.push(message.clone()); + } + cx.emit(HostStoreEvent::PersistFailed(message)); + } + + /// Run `change` and say what of it could not be written to the + /// database (or a key file deleted), for a caller that answers someone + /// who cannot see the notification: the external CLI. The failures are + /// reported as `PersistFailed` all the same, and the change stands in + /// memory as it always does. Keychain writes are not among them: they + /// finish later, and [`Self::write_secret`] says how. + pub fn capture_failures( + &mut self, + cx: &mut Context, + change: impl FnOnce(&mut Self, &mut Context) -> R, + ) -> (R, Vec) { + let outer = self.capturing.replace(Some(Vec::new())); + let result = change(self, cx); + let failures = self.capturing.replace(outer).unwrap_or_default(); + (result, failures) + } + /// The sort order that puts a host last in `group`, among the /// hosts other than `except`. fn last_host_order(&self, group: Option, except: Option) -> i64 { @@ -1990,6 +2028,56 @@ mod tests { assert!(HostStore::empty().save_private_key(None, "one").is_err()); } + #[gpui_kit::test] + fn a_change_says_what_could_not_be_kept_and_still_reports_it( + cx: &mut gpui_kit::TestAppContext, + ) { + use gpui_kit::AppContext as _; + use std::{cell::RefCell, rc::Rc}; + + let database = HostDatabase::in_memory().unwrap(); + let store = cx.new(|_| HostStore::load(database).unwrap()); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&store, move |_, event: &HostStoreEvent, _| { + events.borrow_mut().push(event.clone()); + }) + }); + + // A group the database has never heard of: the write fails, the + // host stands in memory. + let ((id, kept), failures) = store.update(cx, |store, cx| { + store.capture_failures(cx, |store, cx| { + let kept = store.insert(draft("web", None), cx); + (store.insert(draft("db", Some(GroupId(99))), cx), kept) + }) + }); + assert_eq!(failures.len(), 1, "{failures:?}"); + assert!(failures[0].starts_with("新建主机未能保存到本地数据库")); + cx.run_until_parked(); + assert_eq!( + *events.borrow(), + [HostStoreEvent::PersistFailed(failures[0].clone())] + ); + store.read_with(cx, |store, _| { + assert!(store.host(id).is_some() && store.host(kept).is_some()); + }); + // Nothing left listening afterwards. + let (_, failures) = store.update(cx, |store, cx| { + store.capture_failures(cx, |store, cx| store.remove(kept, cx)) + }); + assert!(failures.is_empty()); + + // A keychain that keeps nothing: the write says so when it is done. + let secret = SecretRef::password("root", "10.0.0.1", 22); + let write = store.update(cx, |store, cx| { + store.write_secret(secret, Some("hunter2".into()), cx) + }); + let failure = cx.foreground_executor.block_test(write).unwrap_err(); + assert!(failure.starts_with("密码未能写入系统钥匙串"), "{failure}"); + } + #[gpui_kit::test] fn a_kept_key_goes_with_the_last_credential_using_it(cx: &mut gpui_kit::TestAppContext) { use crate::secrets::SecretStore as _; diff --git a/src/main.rs b/src/main.rs index 7e0d60c..7d4e1b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ fn main() { // bastion host opening ShellRS the way it opens Xshell. On every build, // a Windows release build above all, where bastion hosts run it. let link = shellrs::cli::link_arguments(&args); - // `shellrs list`, `shellrs exec …`: the command, not the app. Not in a + // `shellrs hosts list`, `shellrs exec …`: the command, not the app. Not in a // Windows release build, which has nowhere to print: there the command // is `shellrs-cli.exe`, which 设置 → 外部 CLI puts on the PATH. #[cfg(any(not(windows), debug_assertions))] diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index 5b869aa..aa67e20 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -593,7 +593,8 @@ fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec { ) .description( "开启后,本机当前用户下的程序(如 AI Agent)可以通过 shellrs 命令,\ - 在已保存的主机上执行命令、传输文件,无需知道密码。", + 在已保存的主机上执行命令、传输和同步文件,并查看、新建、修改和删除主机与凭据,\ + 无需知道密码。", )]), SettingGroup::new() .title("CLI 二进制") diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index 94ebcd7..b36ec0c 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -12,6 +12,7 @@ mod journal; mod meter; mod operations; mod speed; +mod sync; mod upload; mod worker; pub use worker::SshSftpTransportProvider; diff --git a/src/sftp/model.rs b/src/sftp/model.rs index b110da9..f2e3b01 100644 --- a/src/sftp/model.rs +++ b/src/sftp/model.rs @@ -162,9 +162,21 @@ impl DirectoryListing { pub struct UploadRequest { sources: Vec, destination: RemotePath, - scp: bool, + mode: UploadMode, target_name: Option, } + +/// How an upload treats what it is given, and what is already there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UploadMode { + /// The SFTP tab's: the sources go into the destination folder. + Into, + Scp, + Sync { + delete: bool, + }, +} + impl UploadRequest { pub fn new(sources: Vec, destination: RemotePath) -> Result { if sources.is_empty() { @@ -173,7 +185,7 @@ impl UploadRequest { Ok(Self { sources, destination, - scp: false, + mode: UploadMode::Into, target_name: None, }) } @@ -184,7 +196,21 @@ impl UploadRequest { Self { sources: vec![source], destination, - scp: true, + mode: UploadMode::Scp, + target_name: None, + } + } + /// What the local folder `source` holds, copied into the folder + /// `destination` (made when it is not there) for the external CLI's + /// `sync`: files of the same size and modification time, and links to + /// the same place, are left alone, and new files keep their permission + /// bits as with scp. With `delete`, what is there and not in `source` + /// goes first. + pub fn sync(source: PathBuf, destination: RemotePath, delete: bool) -> Self { + Self { + sources: vec![source], + destination, + mode: UploadMode::Sync { delete }, target_name: None, } } @@ -195,7 +221,19 @@ impl UploadRequest { &self.destination } pub fn is_scp(&self) -> bool { - self.scp + self.mode == UploadMode::Scp + } + /// `Some` for a sync, saying whether it deletes. + pub fn sync_deletes(&self) -> Option { + match self.mode { + UploadMode::Sync { delete } => Some(delete), + _ => None, + } + } + /// The source copied from, once a sync has resolved it. + pub(crate) fn with_source(mut self, source: PathBuf) -> Self { + self.sources = vec![source]; + self } /// The name the source is copied under, once an scp destination is /// resolved; `None` keeps the source's own. @@ -327,6 +365,8 @@ pub struct TransferProgress { pub(crate) succeeded: usize, pub(crate) skipped: usize, pub(crate) failed: usize, + /// What a sync removed from the host before copying. + pub(crate) deleted: usize, pub(crate) bytes_per_second: u64, pub(crate) details: Vec, /// The file in flight: where it comes from, and how far it has got. @@ -464,6 +504,9 @@ impl TransferProgress { pub fn skipped(&self) -> usize { self.skipped } + pub fn deleted(&self) -> usize { + self.deleted + } pub fn failed(&self) -> usize { self.failed } diff --git a/src/sftp/operations.rs b/src/sftp/operations.rs index 33a5671..1ea073e 100644 --- a/src/sftp/operations.rs +++ b/src/sftp/operations.rs @@ -66,7 +66,7 @@ pub(crate) async fn run(fs: &F, operation: &RemoteOperation) -> Res /// Delete a path and, for a directory, everything under it: children before /// their directory. A link is removed as a link. -async fn delete_tree(fs: &F, root: &RemotePath) -> Result<()> { +pub(super) async fn delete_tree(fs: &F, root: &RemotePath) -> Result<()> { let mut stack = vec![(root.clone(), false)]; while let Some((path, emptied)) = stack.pop() { if emptied { diff --git a/src/sftp/sync.rs b/src/sftp/sync.rs new file mode 100644 index 0000000..4e77739 --- /dev/null +++ b/src/sftp/sync.rs @@ -0,0 +1,137 @@ +//! What the external CLI's `sync --delete` removes before it copies: +//! whatever is in the remote folder and not in the local one, or is there +//! as another kind (a folder where the local one has a file, a link where +//! it has a folder). Links are removed as links, never followed. + +use std::{collections::HashMap, path::Path}; + +use anyhow::Result; + +use super::{ + EntryKind, RemotePath, TransferChoice, TransferDetail, TransferQuestionKind, + client::{RemoteFs, is_network_error}, + control::{Cancelled, TransferControl}, + model::local_metadata, + operations::delete_tree, +}; + +/// What the pruning did: how many entries it removed (a folder with all +/// in it counts once), and what it could not remove. +#[derive(Debug, Default)] +pub(super) struct Pruned { + pub deleted: usize, + pub failures: Vec, +} + +/// Remove from the folder `remote` what the local folder `local` does not +/// have, all the way down. A folder that cannot be read here is left alone +/// there: not being able to see what it holds says nothing about what +/// belongs in its copy. What a transfer leaves behind while it runs (a +/// `.filepart`, a `.shellrs-…backup`) stays too: it may be another +/// transfer's, or the only copy of a file being replaced. +pub(super) async fn prune( + fs: &F, + local: &Path, + remote: &RemotePath, + control: &TransferControl, +) -> Result { + let mut pruned = Pruned::default(); + let mut stack = vec![(local.to_path_buf(), remote.clone())]; + while let Some((local, remote)) = stack.pop() { + control.check()?; + let Ok(here) = local_kinds(&local).await else { + continue; + }; + let there = match control.run(fs.read_dir(&remote)).await { + Ok(there) => there, + Err(error) if gives_up(&error) => return Err(error), + Err(error) => { + fail(&mut pruned, &remote, &error, control).await?; + continue; + } + }; + for entry in there { + let name = entry.name(); + let path = remote.join(name)?; + let kind = entry.metadata().kind(); + match here.get(name) { + Some(Some(local_kind)) if *local_kind == kind => { + if kind == EntryKind::Directory { + stack.push((local.join(name), path)); + } + } + // There, but what it is cannot be told: left alone. + Some(None) => {} + None if is_transfer_leftover(name) => {} + _ => loop { + match control.run(delete_tree(fs, &path)).await { + Ok(()) => { + pruned.deleted += 1; + break; + } + Err(error) if gives_up(&error) => return Err(error), + Err(error) => { + if !fail(&mut pruned, &path, &error, control).await? { + break; + } + } + } + }, + } + } + } + Ok(pruned) +} + +/// A failure that ends the sync rather than one entry: cancelled, or the +/// connection gone. +fn gives_up(error: &anyhow::Error) -> bool { + error.is::() || is_network_error(error) +} + +/// Say what could not be done, as an upload says why a file failed; +/// whether to try again. +async fn fail( + pruned: &mut Pruned, + path: &RemotePath, + error: &anyhow::Error, + control: &TransferControl, +) -> Result { + let message = format!("无法删除:{error:#}"); + let answer = control + .ask(TransferQuestionKind::Error, path.as_str(), &message) + .await?; + if answer.choice() == TransferChoice::Retry { + return Ok(true); + } + pruned.failures.push(TransferDetail::failed( + path.to_string(), + format!("{error:#}"), + )); + Ok(false) +} + +/// What a local folder holds, by name, links not followed; `None` for an +/// entry whose kind cannot be read. +async fn local_kinds(dir: &Path) -> std::io::Result>> { + let mut kinds = HashMap::new(); + let mut entries = tokio::fs::read_dir(dir).await?; + while let Some(entry) = entries.next_entry().await? { + // A name that is not UTF-8 cannot be copied either; what is there + // under some name is left alone rather than taken for missing. + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + let kind = tokio::fs::symlink_metadata(entry.path()) + .await + .ok() + .map(|metadata| local_metadata(&metadata).kind()); + kinds.insert(name, kind); + } + Ok(kinds) +} + +/// The files an upload writes beside its target while it runs. +fn is_transfer_leftover(name: &str) -> bool { + name.ends_with(".filepart") || (name.starts_with(".shellrs-") && name.ends_with(".backup")) +} diff --git a/src/sftp/tests.rs b/src/sftp/tests.rs index 81ff85c..b7c788d 100644 --- a/src/sftp/tests.rs +++ b/src/sftp/tests.rs @@ -2137,3 +2137,137 @@ fn a_binary_file_is_read_whole_for_a_preview_within_its_limit() { ); }); } + +/// The external CLI's sync of `local` into `/srv/app`, resolved as the +/// worker resolves it. +async fn sync_batch(local: &Path, tmp: &Path, control: &TransferControl) -> UploadBatch { + let request = UploadRequest::sync(local.into(), remote_path("/srv/app"), false) + .resolved(remote_path("/srv"), Some("app".into())); + UploadBatch::scan( + &request, + "test@remote", + "host-key", + Journal::new(tmp.join("sync-journal")), + control, + ) + .await + .unwrap() +} + +#[cfg(unix)] +#[test] +fn a_sync_copies_only_what_changed_since_the_last() { + use std::os::unix::fs::PermissionsExt as _; + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let dist = tmp.path().join("dist"); + std::fs::create_dir_all(dist.join("assets")).unwrap(); + std::fs::write(dist.join("index.html"), b"v1").unwrap(); + std::fs::write(dist.join("run.sh"), b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions(dist.join("run.sh"), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + std::fs::write(dist.join("assets/logo.png"), b"png").unwrap(); + std::os::unix::fs::symlink("index.html", dist.join("home.html")).unwrap(); + let remote = Remote::new(true); + remote.dir("/srv", 0o755); + + let answers = Answers::new(vec![]); + let mut first = sync_batch(&dist, tmp.path(), &answers.control).await; + run(&mut first, &remote, &answers.control).await.unwrap(); + let progress = &first.meter.progress; + assert_eq!((progress.succeeded(), progress.skipped()), (6, 0)); + assert_eq!(remote.bytes("/srv/app/assets/logo.png"), b"png"); + // New files keep their permission bits, as with scp. + assert_eq!(remote.mode("/srv/app/run.sh"), 0o755); + + // Nothing changed: nothing copied, nothing asked, folders included. + let mut second = sync_batch(&dist, tmp.path(), &answers.control).await; + run(&mut second, &remote, &answers.control).await.unwrap(); + let progress = &second.meter.progress; + assert_eq!((progress.succeeded(), progress.skipped()), (0, 6)); + assert!(answers.questions.lock().unwrap().is_empty()); + + // One file changed: that one, over what is there. + std::fs::write(dist.join("index.html"), b"version 2").unwrap(); + let answers = Answers::new(vec![TransferChoice::Overwrite]); + let mut third = sync_batch(&dist, tmp.path(), &answers.control).await; + run(&mut third, &remote, &answers.control).await.unwrap(); + let progress = &third.meter.progress; + assert_eq!((progress.succeeded(), progress.skipped()), (1, 5)); + assert_eq!(remote.bytes("/srv/app/index.html"), b"version 2"); + assert_eq!( + *answers.questions.lock().unwrap(), + [TransferQuestionKind::Conflict] + ); + }); +} + +#[cfg(unix)] +#[test] +fn a_sync_with_delete_removes_what_is_not_here_and_nothing_it_cannot_see() { + use std::os::unix::fs::PermissionsExt as _; + runtime().block_on(async { + let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let dist = tmp.path().join("dist"); + std::fs::create_dir_all(dist.join("sub")).unwrap(); + std::fs::create_dir_all(dist.join("locked")).unwrap(); + std::fs::write(dist.join("keep.txt"), b"k").unwrap(); + std::fs::write(dist.join("kind"), b"a file here").unwrap(); + std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o000)) + .unwrap(); + + let remote = Remote::new(true); + remote.dir("/srv/app", 0o755); + remote.file("/srv/app/keep.txt", b"k"); + remote.file("/srv/app/extra.txt", b"x"); + remote.dir("/srv/app/old", 0o755); + remote.file("/srv/app/old/file", b"o"); + // A folder there where a file is here. + remote.dir("/srv/app/kind", 0o755); + remote.file("/srv/app/kind/inner", b"i"); + // What a transfer leaves while it runs. + remote.file("/srv/app/keep.txt.filepart", b"partial"); + remote.file("/srv/app/.shellrs-1234.backup", b"original"); + // A folder this side cannot read, and one that side cannot. + remote.dir("/srv/app/locked", 0o755); + remote.file("/srv/app/locked/extra", b"?"); + remote.dir("/srv/app/sub", 0o755); + remote.file("/srv/app/sub/extra", b"?"); + *remote.denied.borrow_mut() = Some("/srv/app/sub".into()); + + let answers = Answers::new(vec![TransferChoice::Skip]); + let pruned = super::sync::prune(&remote, &dist, &remote_path("/srv/app"), &answers.control) + .await + .unwrap(); + std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + assert_eq!(pruned.deleted, 3); + assert_eq!(pruned.failures.len(), 1); + let keys: Vec<_> = remote.nodes.borrow().keys().cloned().collect(); + assert_eq!( + keys, + [ + "/srv/app", + "/srv/app/.shellrs-1234.backup", + "/srv/app/keep.txt", + "/srv/app/keep.txt.filepart", + "/srv/app/locked", + "/srv/app/locked/extra", + "/srv/app/sub", + "/srv/app/sub/extra", + ] + ); + assert_eq!( + *answers.questions.lock().unwrap(), + [TransferQuestionKind::Error] + ); + + // Counted with the batch that follows: what went, and what failed. + let mut batch = sync_batch(&dist, tmp.path(), &answers.control).await; + let total = batch.meter.progress.total(); + batch.add_pruned(pruned.deleted, pruned.failures); + let progress = &batch.meter.progress; + assert_eq!((progress.deleted(), progress.failed()), (3, 1)); + assert_eq!(progress.total(), total + 1); + }); +} diff --git a/src/sftp/upload.rs b/src/sftp/upload.rs index 61f8f11..a1e6c11 100644 --- a/src/sftp/upload.rs +++ b/src/sftp/upload.rs @@ -35,6 +35,9 @@ pub(crate) struct UploadBatch { /// A new remote file takes the local file's permission bits, as scp /// gives them; otherwise the server's defaults apply. preserve_mode: bool, + /// The external CLI's sync: what is already there as it is here is + /// left alone, folders included, and counted as skipped. + sync: bool, } impl UploadBatch { pub async fn scan( @@ -136,9 +139,20 @@ impl UploadBatch { all_conflicts: None, approved_resumes: HashSet::new(), blocked_directories: Vec::new(), - preserve_mode: request.is_scp(), + preserve_mode: request.is_scp() || request.sync_deletes().is_some(), + sync: request.sync_deletes().is_some(), }) } + + /// Count what a sync deleted before the batch was scanned, and what it + /// failed to delete among the items that failed. + pub fn add_pruned(&mut self, deleted: usize, failures: Vec) { + let progress = &mut self.meter.progress; + progress.deleted += deleted; + progress.total += failures.len(); + progress.failed += failures.len(); + progress.details.extend(failures); + } pub fn verify_host(&self, fingerprint: &str) -> Result<()> { if self.host_key != fingerprint { bail!("服务器主机指纹已改变,已停止续传"); @@ -305,7 +319,7 @@ impl UploadBatch { 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(metadata) if metadata.kind() == EntryKind::Directory => Ok(!self.sync), Some(_) => bail!("目标已存在且不是目录,不会跟随或替换链接"), None => { control.run(fs.mkdir(&item.target)).await?; @@ -339,6 +353,9 @@ impl UploadBatch { record } else { let original = control.run(fs.metadata(&item.target)).await?; + if self.sync && self.unchanged(fs, item, original.as_ref(), control).await? { + return Ok(false); + } if !self .approve(&item.target, &original, false, control) .await? @@ -431,6 +448,35 @@ impl UploadBatch { self.journal.save(&record).await?; self.recover_publish(fs, &mut record, control).await } + /// For a sync: whether `remote`, what is at the item's target, is + /// already what the item is. A file of the same size and modification + /// time (an upload gives the copy the local file's), a link to the same + /// place. A side that does not say when it was modified has changed. + async fn unchanged( + &self, + fs: &F, + item: &UploadItem, + remote: Option<&FileMetadata>, + control: &TransferControl, + ) -> Result { + let Some(remote) = remote else { + return Ok(false); + }; + let local = &item.metadata; + Ok(match (local.kind(), remote.kind()) { + (EntryKind::File, EntryKind::File) => { + local.size() == remote.size() + && local.modified().is_some() + && local.modified() == remote.modified() + } + (EntryKind::Symlink, EntryKind::Symlink) => { + let here = tokio::fs::read_link(&item.source).await?; + let there = control.run(fs.readlink(&item.target)).await?; + here.to_str() == Some(there.as_str()) + } + _ => false, + }) + } async fn upload_file( &mut self, fs: &F, diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs index 2f4657d..ffb8fe5 100644 --- a/src/sftp/worker.rs +++ b/src/sftp/worker.rs @@ -9,7 +9,7 @@ use super::{ journal::{DownloadJournal, Journal}, meter::TransferMeter, model::{scp_local_target, scp_remote_target}, - operations, + operations, sync, upload::UploadBatch, }; use crate::{ @@ -416,7 +416,30 @@ impl SshSftpTransport { events .send(SftpEvent::Progress(TransferProgress::default())) .await?; - let request = if request.is_scp() { + let mut pruned = sync::Pruned::default(); + let request = if let Some(delete) = request.sync_deletes() { + // The local folder itself, through any link to it: + // what it holds is what is copied. + let source = &request.sources()[0]; + let local = tokio::fs::canonicalize(source) + .await + .ok() + .filter(|path| path.is_dir()) + .map(|path| dunce::simplified(&path).to_path_buf()) + .ok_or_else(|| anyhow!("本地目录 {} 不存在", source.display()))?; + let target = + sync_target(connected.as_ref(), request.destination(), &control) + .await?; + if delete { + pruned = + sync::prune(connected.as_ref(), &local, &target, &control).await?; + } + // Copied as the folder it goes to, which is there. + request + .clone() + .with_source(local) + .resolved(target.parent(), Some(target.file_name().to_string())) + } else if request.is_scp() { let destination = request.destination(); let is_directory = destination.is_root() || control @@ -445,17 +468,17 @@ impl SshSftpTransport { .await?; super::UploadRequest::new(request.sources().to_vec(), target)? }; - batch = Some(TransferBatch::Upload( - control - .run(UploadBatch::scan( - &request, - &self.config.endpoint(), - connected.fingerprint(), - self.journal.clone(), - &control, - )) - .await?, - )); + let mut upload = control + .run(UploadBatch::scan( + &request, + &self.config.endpoint(), + connected.fingerprint(), + self.journal.clone(), + &control, + )) + .await?; + upload.add_pruned(pruned.deleted, pruned.failures); + batch = Some(TransferBatch::Upload(upload)); } if let SftpCommand::Download(request) = &operation { batch = None; @@ -635,6 +658,46 @@ impl SshSftpTransport { } } +/// The folder a sync copies into: `destination` when it is a folder, links +/// to it resolved; made when it is not there, in a folder that is, as scp +/// would. Never the root: a sync with `--delete` would empty the server. +async fn sync_target( + fs: &SftpClient, + destination: &RemotePath, + control: &TransferControl, +) -> Result { + let target = match control.run(fs.stat(destination)).await? { + Some(metadata) if metadata.kind() == EntryKind::Directory => { + control.run(fs.canonicalize(destination)).await? + } + Some(_) => anyhow::bail!("远程路径 {destination} 不是目录"), + None => { + let parent = destination.parent(); + let missing = || anyhow!("远程目录 {parent} 不存在"); + let parent = control + .run(fs.canonicalize(&parent)) + .await + .map_err(|_| missing())?; + if !control + .run(fs.stat(&parent)) + .await? + .is_some_and(|metadata| metadata.kind() == EntryKind::Directory) + { + return Err(missing()); + } + let target = parent.join(destination.file_name())?; + if !target.is_root() { + control.run(fs.mkdir(&target)).await?; + } + target + } + }; + if target.is_root() { + anyhow::bail!("不能同步到根目录 /"); + } + Ok(target) +} + /// Measure the connection in use every [`LATENCY_INTERVAL`], and a new one /// at once, transfers or not. The client is held only for a ping, and let go /// as soon as it is no longer the one in use, so 断开 still closes the diff --git a/src/workspace/cli_changes.rs b/src/workspace/cli_changes.rs new file mode 100644 index 0000000..1b40202 --- /dev/null +++ b/src/workspace/cli_changes.rs @@ -0,0 +1,370 @@ +//! The workspace's share of the external CLI: coming forward when ShellRS +//! is opened again, and the changes to hosts and credentials a `shellrs` +//! command asks for. The request threads cannot reach the store; the +//! changes wait in the server until they are made here, one at a time. + +use std::time::Duration; + +use gpui_kit::*; + +use crate::cli::{ + CliChange, CliError, CredentialDeleted, CredentialFields, ErrorCode, HostDeleted, HostFields, + Reply, +}; +use crate::cli::{ + CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets, + find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential, + plan_host, with_saved_credential_secrets, with_saved_passwords, +}; +use crate::host::{CredentialId, GroupDraft, GroupId, HostId, HostStore}; +use crate::secrets::{SecretRef, SharedSecretStore}; + +use super::workspace_view::Workspace; + +/// How often the app looks for what the CLI server has heard. +const CLI_POLL: Duration = Duration::from_millis(100); + +impl Workspace { + /// Bring the window forward whenever ShellRS is opened while it is + /// already running, and make the changes the CLI asks for. The second + /// copy of ShellRS only passes the word on and exits: two of them on + /// one data directory would each keep their own copy of the hosts and + /// write over the other's changes. + /// + /// Asked on a timer, like every other worker: the thread that hears the + /// request never wakes the window itself. A change is answered once + /// made, the server's snapshot already showing it, and only then is the + /// next one taken. + pub(super) fn serve_cli(&self, window: &mut Window, cx: &mut Context) { + cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor().timer(CLI_POLL).await; + let taken = this.update_in(cx, |this, window, cx| { + let server = this.cli_server.as_ref()?; + let links = server.take_activation(); + let change = server.take_change(); + if let Some(links) = links { + crate::app::bring_forward(window, cx); + for link in links { + this.open_link(link, window, cx); + } + } + change.map(|(change, reply)| (this.apply_cli_change(change, window, cx), reply)) + }); + match taken { + Ok(Some((outcome, reply))) => reply.send(outcome.await), + Ok(None) => {} + Err(_) => break, + } + } + }) + .detach(); + } + + /// Make a change a `shellrs` command asks for, checked as the host and + /// credential forms check theirs. Its outcome is the reply the command + /// prints; on success, the host or credential as it is now. + pub fn apply_cli_change( + &mut self, + change: CliChange, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let store = self.store.read(cx); + match change { + CliChange::CreateHost(fields) => self.save_host_from_cli(None, fields, cx), + CliChange::UpdateHost { host, fields } => match find_saved_host(store, &host) { + Ok(id) => self.save_host_from_cli(Some(id), fields, cx), + Err(error) => Task::ready(Err(error)), + }, + CliChange::DeleteHost { host, force } => { + Task::ready(self.delete_host_from_cli(&host, force, window, cx)) + } + CliChange::CreateCredential(fields) => self.save_credential_from_cli(None, fields, cx), + CliChange::UpdateCredential { credential, fields } => { + match find_credential(store, &credential) { + Ok(credential) => { + let id = credential.id; + self.save_credential_from_cli(Some(id), fields, cx) + } + Err(error) => Task::ready(Err(error)), + } + } + CliChange::DeleteCredential { credential } => { + Task::ready(self.delete_credential_from_cli(&credential, cx)) + } + } + } + + fn save_host_from_cli( + &mut self, + existing: Option, + fields: HostFields, + cx: &mut Context, + ) -> Task> { + let plan = match plan_host(fields, existing, self.store.read(cx)) { + Ok(plan) => plan, + Err(error) => return Task::ready(Err(error)), + }; + let store = self.store.clone(); + let secrets = store.read(cx).secrets(); + cx.spawn(async move |_, cx| { + // Read what moves with the host before the change, after which + // the store may delete the entry it moves from. + let password = secret_value(plan.password, &secrets, cx).await; + let proxy_password = secret_value(plan.proxy_password, &secrets, cx).await; + let (id, mut failures, writes) = store.update(cx, |store, cx| { + let (id, failures) = store.capture_failures(cx, |store, cx| { + let mut draft = plan.draft; + draft.group = place_group(store, plan.group, cx); + match existing { + Some(id) => { + store.update(id, draft, cx); + id + } + None => store.insert(draft, cx), + } + }); + let writes: Vec<_> = [password, proxy_password] + .into_iter() + .flatten() + .map(|(secret, value)| store.write_secret(secret, value, cx)) + .collect(); + (id, failures, writes) + }); + for write in writes { + if let Err(error) = write.await { + failures.push(error); + } + } + if !failures.is_empty() { + return Err(save_failed(failures)); + } + let (details, refs) = store + .read_with(cx, |store, _| { + store + .host(id) + .map(|host| (host_details(host, store), host_secrets(host, store))) + }) + .ok_or_else(|| CliError::new(ErrorCode::HostNotFound, "主机保存后不见了"))?; + let details = cx + .background_executor() + .spawn(async move { + with_saved_passwords(details, &refs, |secret| is_saved(&secrets, secret)) + }) + .await; + Ok(Reply::Host(details)) + }) + } + + /// Delete a saved host as the host list does, closing its tabs; a + /// host with tabs open only with `force`, since nobody is asked. + fn delete_host_from_cli( + &mut self, + host: &str, + force: bool, + window: &mut Window, + cx: &mut Context, + ) -> Result { + let store = self.store.read(cx); + let id = find_saved_host(store, host)?; + let Some(host) = store.host(id) else { + return Err(CliError::new(ErrorCode::HostNotFound, "主机已不存在")); + }; + let deleted = HostDeleted { + host: host_details(host, store), + forwards: store.forwards_of(id).count() as u64, + jump_users: store.jump_users(&[id]) as u64, + }; + if !force && self.has_tabs(id, cx) { + return Err(CliError::new( + ErrorCode::HostInUse, + format!( + "主机「{}」有打开的标签:先关闭它们,或加 --force 连同标签一起关闭", + deleted.host.name + ), + )); + } + self.close_host_tabs(id, window, cx); + let (_, failures) = self.store.update(cx, |store, cx| { + store.capture_failures(cx, |store, cx| store.remove(id, cx)) + }); + if !failures.is_empty() { + return Err(save_failed(failures)); + } + Ok(Reply::HostDeleted(deleted)) + } + + fn save_credential_from_cli( + &mut self, + existing: Option, + fields: CredentialFields, + cx: &mut Context, + ) -> Task> { + let plan = match plan_credential(fields, existing, self.store.read(cx)) { + Ok(plan) => plan, + Err(error) => return Task::ready(Err(error)), + }; + let store = self.store.clone(); + let secrets = store.read(cx).secrets(); + let saved = store.update(cx, |store, cx| save_credential(store, existing, plan, cx)); + let (id, mut failures, writes) = match saved { + Ok(saved) => saved, + Err(error) => return Task::ready(Err(error)), + }; + cx.spawn(async move |_, cx| { + for write in writes { + if let Err(error) = write.await { + failures.push(error); + } + } + if !failures.is_empty() { + return Err(save_failed(failures)); + } + let (details, refs) = store + .read_with(cx, |store, _| { + store.credential(id).map(|credential| { + ( + credential_details(credential, store), + credential_secrets(credential), + ) + }) + }) + .ok_or_else(|| CliError::new(ErrorCode::CredentialNotFound, "凭据保存后不见了"))?; + let details = cx + .background_executor() + .spawn(async move { + with_saved_credential_secrets(details, &refs, |secret| { + is_saved(&secrets, secret) + }) + }) + .await; + Ok(Reply::Credential(details)) + }) + } + + /// Delete a credential as the credential list does; the hosts using it + /// log in on their own from now on, and are listed in the reply. + fn delete_credential_from_cli( + &mut self, + credential: &str, + cx: &mut Context, + ) -> Result { + let store = self.store.read(cx); + let found = find_credential(store, credential)?; + let (id, details) = (found.id, credential_details(found, store)); + let (released, failures) = self.store.update(cx, |store, cx| { + store.capture_failures(cx, |store, cx| store.remove_credential(id, cx)) + }); + if !failures.is_empty() { + return Err(save_failed(failures)); + } + let store = self.store.read(cx); + Ok(Reply::CredentialDeleted(CredentialDeleted { + credential: details, + released: released + .iter() + .filter_map(|id| store.host(*id)) + .map(|host| host_info(host, store)) + .collect(), + })) + } +} + +/// The group a host goes into, made first when it is new. +fn place_group( + store: &mut HostStore, + plan: GroupPlan, + cx: &mut Context, +) -> Option { + match plan { + GroupPlan::Existing(group) => group, + GroupPlan::Create { parent, names } => names.into_iter().fold(parent, |parent, name| { + Some(store.insert_group(GroupDraft::new(name, parent), cx)) + }), + } +} + +/// Write the key file, then the credential, then start writing its +/// secrets: the order the credential form keeps, so the credential never +/// names a key file that is not there. +fn save_credential( + store: &mut HostStore, + existing: Option, + plan: CredentialPlan, + cx: &mut Context, +) -> Result { + let mut draft = plan.draft; + if let Some(pasted) = &plan.private_key { + let path = store + .save_private_key(existing, pasted.text()) + .map_err(|error| { + CliError::new(ErrorCode::SaveFailed, format!("私钥未能保存:{error}")) + })?; + draft = draft.with_key_path(path); + } + let (id, failures) = store.capture_failures(cx, |store, cx| match existing { + Some(id) => { + store.update_credential(id, draft, cx); + id + } + None => store.insert_credential(draft, cx), + }); + let Some(credential) = store.credential(id).cloned() else { + return Err(CliError::new( + ErrorCode::CredentialNotFound, + "凭据保存后不见了", + )); + }; + let mut writes = Vec::new(); + if let Some(value) = plan.password.value() { + writes.push(store.write_secret(credential.password_secret(), value, cx)); + } + if let (Some(path), Some(value)) = (credential.key_path.as_deref(), plan.passphrase.value()) { + writes.push(store.write_secret(SecretRef::passphrase(path), value, cx)); + } + Ok((id, failures, writes)) +} + +type SavedCredential = ( + CredentialId, + Vec, + Vec>>, +); + +/// What to write to `secret`, `None` for nothing and `Some(None)` to +/// delete it: a value moving with the host is read from where it was. +async fn secret_value( + change: Option<(SecretRef, SecretChange)>, + secrets: &SharedSecretStore, + cx: &mut AsyncApp, +) -> Option<(SecretRef, Option)> { + let (secret, change) = change?; + let value = match change { + SecretChange::Carry(from) => { + let secrets = secrets.clone(); + let value = cx + .background_executor() + .spawn(async move { secrets.get(&from).ok().flatten() }) + .await?; + Some(value.to_string()) + } + change => change.value()?, + }; + Some((secret, value)) +} + +fn is_saved(secrets: &SharedSecretStore, secret: &SecretRef) -> bool { + secrets.get(secret).is_ok_and(|value| value.is_some()) +} + +fn save_failed(failures: Vec) -> CliError { + CliError::new( + ErrorCode::SaveFailed, + failures + .iter() + .map(SharedString::as_ref) + .collect::>() + .join(";"), + ) +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index a976a8b..b161b96 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -2,6 +2,7 @@ //! the right sidebar's tools, status bar, and the start page (recent hosts) //! the center shows while no tab is open. +mod cli_changes; mod credentials; mod dock_skin; mod editors; diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 5ffd402..ccd9d99 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -35,7 +35,7 @@ use crate::app::{ ToggleHostPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset, ZoomTerminalIn, ZoomTerminalOut, ZoomTerminalReset, }; -use crate::cli::{CliIntegration, CliServer, CliTarget, IntegrationPaths, SshCliBackend}; +use crate::cli::{CliIntegration, CliServer, IntegrationPaths, SshCliBackend}; use crate::connection::{ ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret, SharedConnectionTester, @@ -93,10 +93,6 @@ const FONT_SIZE_STEP: f32 = 2.; /// hits the backdrop. Just below popups (`POPUP_PRIORITY`) keeps a /// notification above any stack of dialogs without covering an open menu. const NOTIFICATION_PRIORITY: usize = gpui_kit::base::POPUP_PRIORITY - 1; -/// How often the window asks whether ShellRS was opened again; see -/// [`CliServer::take_activation`]. -const ACTIVATION_POLL: Duration = Duration::from_millis(200); - /// Show a notification from code that runs while the window is still being /// built: loading what is on disk, starting the services. /// @@ -170,7 +166,7 @@ pub struct Workspace { cli_integration: Entity, /// Answers the `shellrs` command. Production only: UI tests never /// listen on the real socket. - cli_server: Option, + pub(super) cli_server: Option, /// Looks for, downloads and installs newer versions. Without services /// in UI tests, so it never reaches the network unless a test hands it /// fakes. @@ -248,13 +244,14 @@ impl Workspace { integration.set_paths(IntegrationPaths::system(), cx); integration.update_outdated(cx); }); + let secrets = this.store.read(cx).secrets(); match CliServer::start( crate::app::cli_socket_path(), - Arc::new(SshCliBackend::new(connector, sftp)), + Arc::new(SshCliBackend::new(connector, sftp, secrets)), ) { Ok(server) => { this.cli_server = Some(server); - this.come_forward_when_opened_again(window, cx); + this.serve_cli(window, cx); } Err(error) => notify_once_open( Notification::error(error.to_string()).title("外部 CLI 无法启动"), @@ -272,44 +269,12 @@ impl Workspace { this } - /// Bring the window forward whenever ShellRS is opened while it is - /// already running. The second copy only passes the word on and exits: - /// two of them on one data directory would each keep their own copy of - /// the hosts and write over the other's changes. - /// - /// Asked on a timer, like every other worker: the thread that hears the - /// request never wakes the window itself. - fn come_forward_when_opened_again(&self, window: &mut Window, cx: &mut Context) { - cx.spawn_in(window, async move |this, cx| { - loop { - cx.background_executor().timer(ACTIVATION_POLL).await; - let open = this.update_in(cx, |this, window, cx| { - let Some(links) = this - .cli_server - .as_ref() - .and_then(|server| server.take_activation()) - else { - return; - }; - crate::app::bring_forward(window, cx); - for link in links { - this.open_link(link, window, cx); - } - }); - if open.is_err() { - break; - } - } - }) - .detach(); - } - /// Tell the CLI server what it may use: whether 启用外部 CLI is on, and /// the hosts as they are now. fn sync_cli_server(&self, cx: &App) { if let Some(server) = &self.cli_server { server.set_enabled(self.settings.read(cx).settings().external_cli.enabled); - server.set_targets(CliTarget::all(self.store.read(cx))); + server.set_hosts(self.store.read(cx)); } } @@ -1919,7 +1884,12 @@ impl Workspace { /// Close whatever a host has open in the center, leaving the store /// alone. Deleting a host and deleting the group around it both need /// this, the latter for every host in the subtree. - fn close_host_tabs(&mut self, id: HostId, window: &mut Window, cx: &mut Context) { + pub(super) fn close_host_tabs( + &mut self, + id: HostId, + window: &mut Window, + cx: &mut Context, + ) { self.cancel_prompts_for_host(id, window, cx); let terminals: Vec<_> = self.terminals_of(id, cx).cloned().collect(); for terminal in terminals { diff --git a/tests/cli_bin.rs b/tests/cli_bin.rs index 21fb55f..41e8a24 100644 --- a/tests/cli_bin.rs +++ b/tests/cli_bin.rs @@ -14,10 +14,11 @@ use std::{ use shellrs::app::cli_endpoint; use shellrs::cli::{ - CliBackend, CliError, CliServer, CliTarget, HostInfo, OpenLink, TransferCounters, - TransferSummary, + AuthChoice, CliBackend, CliChange, CliError, CliServer, CliTarget, HostDetails, HostInfo, + OpenLink, Reply, RouteDetails, TransferCounters, TransferSummary, }; -use shellrs::host::{AuthKind, Host, HostDraft, HostId, HostLogin}; +use shellrs::host::{AuthKind, HostDraft, HostStore}; +use shellrs::secrets::SecretRef; use shellrs::ssh::ExecStream; /// More than a pipe's buffer, so the app finishes writing before the @@ -72,6 +73,31 @@ impl CliBackend for FakeBackend { ) -> Result { Ok(TransferSummary::default()) } + + fn sync( + &self, + _: &CliTarget, + source: &Path, + destination: &str, + delete: bool, + _: &mut dyn FnMut(TransferCounters) -> io::Result<()>, + ) -> Result { + self.commands + .lock() + .unwrap() + .push(format!("sync {} {destination} {delete}", source.display())); + Ok(TransferSummary { + files: 1, + bytes: 10, + skipped: 5, + deleted: 2, + ..TransferSummary::default() + }) + } + + fn is_saved(&self, _: &SecretRef) -> bool { + false + } } /// Run the command against the app whose data lives in `data_dir`. @@ -91,7 +117,7 @@ fn shellrs(data_dir: &Path, args: &[&str], stdin: &[u8]) -> Output { #[test] fn without_the_app_the_command_says_so_and_exits_255() { let data_dir = tempfile::tempdir().unwrap(); - let output = shellrs(data_dir.path(), &["list"], b""); + let output = shellrs(data_dir.path(), &["hosts", "list"], b""); assert_eq!(output.status.code(), Some(255)); assert!( String::from_utf8(output.stderr) @@ -105,16 +131,21 @@ fn the_command_finds_the_app_through_its_data_directory() { let data_dir = tempfile::tempdir().unwrap(); let backend = Arc::new(FakeBackend::default()); let server = CliServer::start(cli_endpoint(data_dir.path()), backend.clone()).unwrap(); - let web = Host::new( - HostId(1), - HostDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Password, None), - ); - server.set_targets(vec![CliTarget::new(&web, HostLogin::of(&web, None), None)]); + let mut store = HostStore::empty(); + let web = store.insert_unnotified(HostDraft::new( + "web-01", + "10.0.1.12", + 22, + "root", + AuthKind::Password, + None, + )); + server.set_hosts(&store); server.set_enabled(true); - let id = web.public_id.to_string(); + let id = store.host(web).unwrap().public_id.to_string(); // A pipe for stdout means JSON. - let output = shellrs(data_dir.path(), &["list"], b""); + let output = shellrs(data_dir.path(), &["hosts", "list"], b""); assert_eq!(output.status.code(), Some(0)); let hosts: Vec = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(hosts[0].id, id); @@ -153,6 +184,31 @@ fn the_command_finds_the_app_through_its_data_directory() { assert_eq!(output.status.code(), Some(0)); assert_eq!(output.stdout.len(), LARGE_OUTPUT); + // A folder's contents, from where the command runs. + let output = shellrs( + data_dir.path(), + &[ + "sync", + &id, + data_dir.path().to_str().unwrap(), + "/srv/app", + "--delete", + ], + b"", + ); + assert_eq!(output.status.code(), Some(0)); + let summary: TransferSummary = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!((summary.skipped, summary.deleted), (5, 2)); + assert!( + backend + .commands + .lock() + .unwrap() + .last() + .unwrap() + .ends_with(" /srv/app true") + ); + server.set_enabled(false); let output = shellrs(data_dir.path(), &["exec", &id, "true"], b""); assert_eq!(output.status.code(), Some(255)); @@ -233,3 +289,70 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() { ]) ); } + +#[test] +fn a_host_described_on_stdin_reaches_the_app_and_comes_back_saved() { + let data_dir = tempfile::tempdir().unwrap(); + let server = Arc::new( + CliServer::start( + cli_endpoint(data_dir.path()), + Arc::new(FakeBackend::default()), + ) + .unwrap(), + ); + server.set_enabled(true); + // The app's side: take the change up and answer with the host saved. + let app = std::thread::spawn({ + let server = server.clone(); + move || { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Some((change, reply)) = server.take_change() { + let CliChange::CreateHost(fields) = change else { + panic!("not a new host: {change:?}"); + }; + let password = fields.password.clone().flatten(); + reply.send(Ok(Reply::Host(HostDetails { + id: "Jwg5rHvXCxw89paM".into(), + name: fields.name.clone().unwrap_or_default(), + group: fields.group.clone().flatten(), + host: fields.host.clone().unwrap_or_default(), + port: fields.port.unwrap_or(22), + user: "root".into(), + auth: AuthChoice::Password, + credential: None, + password_saved: Some(password.is_some()), + route: RouteDetails::Direct, + notes: String::new(), + os: None, + temporary: false, + }))); + return password.map(|password| password.expose().to_string()); + } + assert!(Instant::now() < deadline, "the command sent nothing"); + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + // As Windows PowerShell pipes it: a BOM, CRLF, and its escapes. + let host = "\u{feff}{\"name\": \"\\u751f\\u4ea7 web\", \"address\": \"10.0.1.12\",\r\n \"group\": \"\u{751f}\u{4ea7}/web\", \"password\": \"s3cret\", \"id\": \"ignored\"}\r\n"; + let output = shellrs(data_dir.path(), &["hosts", "create"], host.as_bytes()); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + let saved: HostDetails = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(saved.name, "生产 web"); + assert_eq!(saved.group.as_deref(), Some("生产/web")); + assert_eq!(saved.password_saved, Some(true)); + assert_eq!(app.join().unwrap().as_deref(), Some("s3cret")); + + // A field the command does not know is refused before anything is sent. + let output = shellrs( + data_dir.path(), + &["hosts", "create"], + br#"{"name": "web", "adress": "10.0.1.12"}"#, + ); + assert_eq!(output.status.code(), Some(255)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.starts_with("shellrs: [bad_request]"), "{stderr}"); + assert!(stderr.contains("adress"), "{stderr}"); + assert!(server.take_change().is_none()); +} diff --git a/tests/ui/cli.rs b/tests/ui/cli.rs new file mode 100644 index 0000000..2056cf8 --- /dev/null +++ b/tests/ui/cli.rs @@ -0,0 +1,331 @@ +//! The changes the external CLI asks for, as the workspace makes them: +//! hosts and credentials created, changed and deleted from `shellrs`. +//! The socket is not in it; `apply_cli_change` is where the server hands +//! each change over. + +use shellrs::cli::{CliChange, CliError, CredentialFields, ErrorCode, HostFields, Reply, Secret}; + +use crate::support::*; + +/// Make `change` as the server's poller would, and wait for its outcome. +fn apply( + cx: &mut TestAppContext, + handle: WindowHandle, + workspace: &Entity, + change: CliChange, +) -> Result { + let outcome = in_frame(cx, handle, |window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.apply_cli_change(change, window, cx) + }) + }); + let outcome = cx.foreground_executor.block_test(outcome); + cx.run_until_parked(); + outcome +} + +fn host_id(workspace: &Entity, id: u64, cx: &mut TestAppContext) -> String { + cx.update(|cx| { + workspace + .read(cx) + .store() + .read(cx) + .host(HostId(id)) + .unwrap() + .public_id + .to_string() + }) +} + +fn store(workspace: &Entity, cx: &mut TestAppContext) -> Entity { + cx.update(|cx| workspace.read(cx).store().clone()) +} + +#[gpui_kit::test] +fn a_host_from_the_cli_lands_in_the_tree_with_its_group_and_password(cx: &mut TestAppContext) { + let secrets = Arc::new(InMemorySecretStore::default()); + let (handle, workspace) = + open_workspace_with_store(cx, HostStore::seed().with_secrets(secrets.clone())); + let fields = HostFields { + name: Some("api-01".into()), + host: Some("10.0.9.1".into()), + group: Some(Some("生产/接口".into())), + password: Some(Some(Secret::new("s3cret"))), + ..HostFields::default() + }; + let Ok(Reply::Host(created)) = apply(cx, handle, &workspace, CliChange::CreateHost(fields)) + else { + panic!("not created"); + }; + assert_eq!(created.group.as_deref(), Some("生产/接口")); + assert_eq!(created.password_saved, Some(true)); + + let store = store(&workspace, cx); + let host = cx.update(|cx| { + let store = store.read(cx); + let host = store + .hosts() + .iter() + .find(|host| host.public_id.as_str() == created.id) + .unwrap(); + let group = store.group(host.group.unwrap()).unwrap(); + assert_eq!(group.name.as_ref(), "接口"); + assert_eq!(group.parent, Some(GroupId(PRODUCTION))); + host.id + }); + assert_eq!( + secrets + .get(&SecretRef::password("root", "10.0.9.1", 22)) + .unwrap() + .as_deref() + .map(String::as_str), + Some("s3cret") + ); + in_frame(cx, handle, |window, _| { + assert!( + window + .within("host-tree") + .try_find(("host-row", host.0)) + .is_some() + ); + }); + + // Checked as the host form checks it. + let nameless = HostFields { + host: Some("10.0.9.2".into()), + ..HostFields::default() + }; + let error = apply(cx, handle, &workspace, CliChange::CreateHost(nameless)).unwrap_err(); + assert_eq!( + (error.code, error.message.as_str()), + (ErrorCode::BadRequest, "请输入名称") + ); +} + +#[gpui_kit::test] +fn a_host_moved_to_another_port_keeps_its_password_until_told_otherwise(cx: &mut TestAppContext) { + let secrets = Arc::new(InMemorySecretStore::default()); + let before = SecretRef::password("postgres", "10.0.2.5", 22); + secrets.set(&before, "pw").unwrap(); + let (handle, workspace) = + open_workspace_with_store(cx, HostStore::seed().with_secrets(secrets.clone())); + let db = host_id(&workspace, DB_01, cx); + let moved = HostFields { + port: Some(2222), + ..HostFields::default() + }; + let Ok(Reply::Host(changed)) = apply( + cx, + handle, + &workspace, + CliChange::UpdateHost { + host: db.clone(), + fields: moved, + }, + ) else { + panic!("not changed"); + }; + assert_eq!((changed.port, changed.password_saved), (2222, Some(true))); + let after = SecretRef::password("postgres", "10.0.2.5", 2222); + assert_eq!( + secrets.get(&after).unwrap().as_deref().map(String::as_str), + Some("pw") + ); + // The entry it left goes, as no other host uses it. + assert!(secrets.get(&before).unwrap().is_none()); + + let forget = HostFields { + password: Some(None), + ..HostFields::default() + }; + let Ok(Reply::Host(changed)) = apply( + cx, + handle, + &workspace, + CliChange::UpdateHost { + host: db, + fields: forget, + }, + ) else { + panic!("not changed"); + }; + assert_eq!(changed.password_saved, Some(false)); + assert!(secrets.get(&after).unwrap().is_none()); +} + +#[gpui_kit::test] +fn a_host_with_tabs_open_goes_only_with_force_and_takes_them_along(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let web = host_id(&workspace, WEB_01, cx); + let delete = |force| CliChange::DeleteHost { + host: web.clone(), + force, + }; + let error = apply(cx, handle, &workspace, delete(false)).unwrap_err(); + assert_eq!(error.code, ErrorCode::HostInUse); + let store = store(&workspace, cx); + assert!(cx.update(|cx| store.read(cx).host(HostId(WEB_01)).is_some())); + in_frame(cx, handle, |window, _| { + assert!( + window + .try_find(("terminal-tab", INITIAL_WEB_TERMINAL)) + .is_some() + ); + }); + + let Ok(Reply::HostDeleted(deleted)) = apply(cx, handle, &workspace, delete(true)) else { + panic!("not deleted"); + }; + assert_eq!(deleted.host.name, "web-01"); + assert!(cx.update(|cx| store.read(cx).host(HostId(WEB_01)).is_none())); + in_frame(cx, handle, |window, _| { + assert!( + window + .try_find(("terminal-tab", INITIAL_WEB_TERMINAL)) + .is_none() + ); + }); + + // Nothing open, nothing to force. + let db = host_id(&workspace, DB_01, cx); + let deleted = apply( + cx, + handle, + &workspace, + CliChange::DeleteHost { + host: db, + force: false, + }, + ); + assert!(matches!(deleted, Ok(Reply::HostDeleted(_)))); +} + +#[gpui_kit::test] +fn a_host_not_saved_is_left_to_its_tabs(cx: &mut TestAppContext) { + let mut seeded = HostStore::seed(); + let temporary = seeded.insert_temporary_unnotified( + HostDraft::new("临时", "10.0.0.3", 22, "root", AuthKind::Password, None), + None, + ); + let (handle, workspace) = open_workspace_with_store(cx, seeded); + let id = cx.update(|cx| { + workspace + .read(cx) + .store() + .read(cx) + .host(temporary) + .unwrap() + .public_id + .to_string() + }); + for change in [ + CliChange::UpdateHost { + host: id.clone(), + fields: HostFields::default(), + }, + CliChange::DeleteHost { + host: id.clone(), + force: true, + }, + ] { + let error = apply(cx, handle, &workspace, change).unwrap_err(); + assert_eq!(error.code, ErrorCode::BadRequest); + assert!( + error.message.contains("没有保存的连接"), + "{}", + error.message + ); + } +} + +#[gpui_kit::test] +fn a_pasted_key_becomes_a_credential_shellrs_keeps(cx: &mut TestAppContext) { + let secrets = Arc::new(InMemorySecretStore::default()); + let data = tempfile::tempdir().unwrap(); + let (handle, workspace) = open_workspace_with_store( + cx, + HostStore::empty() + .with_secrets(secrets.clone()) + .with_key_dir(data.path().join("keys")), + ); + let key = GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap(); + let text = key.encode("deploy", "correct horse").unwrap().to_string(); + let fields = CredentialFields { + name: Some("部署".into()), + user: Some("deploy".into()), + private_key: Some(Secret::new(text.clone())), + passphrase: Some(Some(Secret::new("correct horse"))), + ..CredentialFields::default() + }; + let Ok(Reply::Credential(created)) = + apply(cx, handle, &workspace, CliChange::CreateCredential(fields)) + else { + panic!("not created"); + }; + assert!(created.kept); + // Where it is kept is not said: the file is as good as the key. + assert_eq!(created.key_path, None); + assert_eq!(created.passphrase_saved, Some(true)); + + let store = store(&workspace, cx); + let path = cx.update(|cx| { + let store = store.read(cx); + let credential = &store.credentials()[0]; + assert_eq!(credential.keychain_id.as_str(), created.id); + assert_eq!(credential.kind, CredentialKind::Key); + credential.key_path.clone().unwrap() + }); + assert!(path.starts_with(data.path().join("keys").to_str().unwrap())); + assert_eq!(std::fs::read_to_string(path.as_ref()).unwrap(), text); + assert_eq!( + secrets + .get(&SecretRef::passphrase(path.as_ref())) + .unwrap() + .as_deref() + .map(String::as_str), + Some("correct horse") + ); +} + +#[gpui_kit::test] +fn deleting_a_credential_lets_its_hosts_log_in_on_their_own(cx: &mut TestAppContext) { + let mut seeded = HostStore::empty(); + let credential = seeded.insert_credential_unnotified(CredentialDraft::new( + "运维", + CredentialKind::Password, + "deploy", + )); + let host = seeded.insert_unnotified( + HostDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Password, None) + .with_credential(credential), + ); + let id = seeded + .credential(credential) + .unwrap() + .keychain_id + .to_string(); + let (handle, workspace) = open_workspace_with_store(cx, seeded); + let Ok(Reply::CredentialDeleted(deleted)) = apply( + cx, + handle, + &workspace, + CliChange::DeleteCredential { credential: id }, + ) else { + panic!("not deleted"); + }; + assert_eq!(deleted.credential.name, "运维"); + let released: Vec<&str> = deleted + .released + .iter() + .map(|host| host.name.as_str()) + .collect(); + assert_eq!(released, ["db-01"]); + let store = store(&workspace, cx); + cx.update(|cx| { + let store = store.read(cx); + assert!(store.credentials().is_empty()); + let host = store.host(host).unwrap(); + assert_eq!((host.credential, host.auth), (None, AuthKind::Password)); + assert_eq!(host.user.as_ref(), "deploy"); + }); +} diff --git a/tests/ui/main.rs b/tests/ui/main.rs index b39a4aa..4b78d39 100644 --- a/tests/ui/main.rs +++ b/tests/ui/main.rs @@ -7,6 +7,7 @@ mod support; +mod cli; mod connection; mod credential; mod docker;