diff --git a/CLAUDE.md b/CLAUDE.md index 63ebcc6..a24d824 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 - `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。 - `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `Handle::send_ping()`(`keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`。 +- **会话对话框的「测试连接」是真登录。** 会话模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(表单当前值,秘密用 `Zeroizing`,`Debug` 打码)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的最后一个服务参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 - `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。 - `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 - `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 @@ -72,4 +73,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", session_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`tab-name`(重命名标签对话框)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", session_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`tab-name`(重命名标签对话框)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/src/connection.rs b/src/connection.rs index 7fab5cd..236dc59 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -73,6 +73,14 @@ impl UnknownHostPrompt { pub fn fingerprint(&self) -> &str { &self.fingerprint } + + /// What the trust dialog tells the user about the key. + pub fn description(&self) -> String { + format!( + "主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。", + self.host, self.port, self.algorithm, self.fingerprint, + ) + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -212,3 +220,102 @@ impl std::fmt::Debug for ConnectionPromptReply { } } } + +/// What a connection test logs in with: the session form's current values, +/// saved or not. Secrets stay zeroized and never print. +pub struct LoginTest { + host: String, + port: u16, + user: String, + auth: crate::session::AuthKind, + key_path: Option, + password: Option>, + passphrase: Option>, +} + +impl LoginTest { + pub fn new( + host: impl Into, + port: u16, + user: impl Into, + auth: crate::session::AuthKind, + ) -> Self { + Self { + host: host.into(), + port, + user: user.into(), + auth, + key_path: None, + password: None, + passphrase: None, + } + } + + pub fn with_key_path(mut self, path: impl Into) -> Self { + self.key_path = Some(path.into()); + self + } + + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(Zeroizing::new(password.into())); + self + } + + pub fn with_passphrase(mut self, passphrase: impl Into) -> Self { + self.passphrase = Some(Zeroizing::new(passphrase.into())); + self + } + + pub fn host(&self) -> &str { + &self.host + } + pub fn port(&self) -> u16 { + self.port + } + pub fn user(&self) -> &str { + &self.user + } + pub fn auth(&self) -> crate::session::AuthKind { + self.auth + } + pub fn key_path(&self) -> Option<&std::path::Path> { + self.key_path.as_deref() + } + pub fn password(&self) -> Option<&str> { + self.password.as_deref().map(String::as_str) + } + pub fn passphrase(&self) -> Option<&str> { + self.passphrase.as_deref().map(String::as_str) + } +} + +impl std::fmt::Debug for LoginTest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoginTest") + .field("host", &self.host) + .field("port", &self.port) + .field("user", &self.user) + .field("auth", &self.auth) + .field("key_path", &self.key_path) + .field("password", &self.password.as_ref().map(|_| "")) + .field( + "passphrase", + &self.passphrase.as_ref().map(|_| ""), + ) + .finish() + } +} + +/// Answers whether to trust a host key seen for the first time. It blocks +/// until a person decides; `false` also covers "nobody is there to ask". +pub type TrustCallback = Box bool + Send + Sync>; + +/// Logs in once with a [`LoginTest`] and hangs up, so the session form can +/// check its values before saving them. +pub trait ConnectionTester: Send + Sync + 'static { + /// Blocks: call it on a thread of its own. `Err` holds the reason, worded + /// for the user. + fn test(&self, request: LoginTest, trust: TrustCallback) -> Result<(), String>; +} + +pub type SharedConnectionTester = std::sync::Arc; diff --git a/src/session/session_dialog.rs b/src/session/session_dialog.rs index d1b3525..e7f58a3 100644 --- a/src/session/session_dialog.rs +++ b/src/session/session_dialog.rs @@ -7,14 +7,15 @@ use gpui_kit::component::{ form::{Field, Form}, h_flex, input::{Input, InputContentType, InputEvent, InputState}, + notification::Notification, select::{Select, SelectState}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; -use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use zeroize::Zeroizing; +use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, UnknownHostPrompt}; use crate::secrets::{SecretRef, SharedSecretStore}; use super::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionStore, group_options}; @@ -53,7 +54,8 @@ pub struct SessionForm { group_ids: Vec>, error: Option, testing_connection: bool, - connection_test_result: Option<(bool, SharedString)>, + /// Logs in with the form's current values for 「测试连接」. + tester: SharedConnectionTester, editing_connected: bool, /// Where saved secrets are read from. Writes go through the store, which /// owns the one error channel. @@ -72,6 +74,7 @@ impl SessionForm { editing: Option, preselect_group: Option, store: Entity, + tester: SharedConnectionTester, window: &mut Window, cx: &mut Context, ) -> Self { @@ -183,7 +186,7 @@ impl SessionForm { group_ids, error: None, testing_connection: false, - connection_test_result: None, + tester, editing_connected, secrets, password_loaded: false, @@ -283,47 +286,118 @@ impl SessionForm { .detach(); } - /// Probe the endpoint currently shown in the form without saving it. - fn test_connection(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + /// The login the form's current values describe, saved or not, or why + /// there is nothing to test yet. + fn login_test(&self, cx: &App) -> Result { + let host = self.host.read(cx).value().trim().to_string(); + let port = parse_port(self.port.read(cx).value().trim()); + let user = self.user.read(cx).value().trim().to_string(); + let auth = self + .auth + .read(cx) + .selected_index(cx) + .and_then(|ix| AuthKind::ALL.get(ix.row).copied()) + .unwrap_or_default(); + let key_path = self.key_path.read(cx).value().trim().to_string(); + let port = match (host.is_empty(), port, user.is_empty()) { + (true, _, _) => return Err("请输入主机"), + (_, None, _) => return Err("端口必须是 1 到 65535 之间的数字"), + (_, _, true) => return Err("请输入用户名"), + (_, Some(port), _) => port, + }; + if auth == AuthKind::Key && key_path.is_empty() { + return Err("私钥认证需要选择私钥文件"); + } + let mut request = LoginTest::new(host, port, user, auth); + // Only what the chosen method uses, which is also what the form shows. + if uses_password(auth) { + let password = self.password.read(cx).value(); + if !password.is_empty() { + request = request.with_password(password.to_string()); + } + } + if auth == AuthKind::Key { + request = request.with_key_path(key_path); + let passphrase = self.passphrase.read(cx).value(); + if !passphrase.is_empty() { + request = request.with_passphrase(passphrase.to_string()); + } + } + Ok(request) + } + + /// Log in with the form's current values without saving them, and report + /// the outcome as a notification. The login runs on a thread of its own; + /// a host key seen for the first time is put to the user in a dialog + /// above this one. + fn test_connection(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { if self.testing_connection { return; } - let host = self.host.read(cx).value().trim().to_string(); - let port = self.port.read(cx).value().trim().parse::(); - let error = if host.is_empty() { - Some("请输入主机") - } else if !matches!(port, Ok(1..=u16::MAX)) { - Some("端口必须是 1 到 65535 之间的数字") - } else { - None + let request = match self.login_test(cx) { + Ok(request) => request, + Err(reason) => { + window.push_notification(connection_test_notification(Err(reason.into())), cx); + return; + } }; - if let Some(error) = error { - self.connection_test_result = Some((false, error.into())); - cx.notify(); + let (trust_tx, trust_rx) = std::sync::mpsc::channel::(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let tester = self.tester.clone(); + let spawned = std::thread::Builder::new() + .name("shellr-connection-test".into()) + .spawn(move || { + // Nobody left to answer (the form closed) reads as "no". + let trust: TrustCallback = Box::new(move |prompt| { + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + trust_tx.send((prompt, reply_tx)).is_ok() && reply_rx.recv().unwrap_or(false) + }); + let _ = result_tx.send(tester.test(request, trust)); + }); + if let Err(error) = spawned { + window.push_notification( + connection_test_notification(Err(format!("无法启动连接测试:{error}"))), + cx, + ); return; } - let port = port.expect("validated port"); - let endpoint = format!("{host}:{port}"); self.testing_connection = true; - self.connection_test_result = None; cx.notify(); - cx.spawn(async move |this, cx| { - let result = cx - .background_executor() - .spawn(async move { probe_ssh_service(&host, port) }) - .await; - this.update_in(cx, |this, _, cx| { - this.testing_connection = false; - this.connection_test_result = Some(match result { - Ok(()) => ( - true, - format!("{endpoint}:SSH 服务可达,尚未验证登录认证").into(), - ), - Err(error) => (false, format!("{endpoint}:{error}").into()), - }); - cx.notify(); - }) - .ok(); + // Polled rather than woken by the worker, as with every other worker + // in the application. + cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + while let Ok((prompt, reply)) = trust_rx.try_recv() { + if this + .update_in(cx, |_, window, cx| ask_to_trust(prompt, reply, window, cx)) + .is_err() + { + return; + } + } + let result = match result_rx.try_recv() { + Ok(result) => result, + Err(std::sync::mpsc::TryRecvError::Empty) => { + if this.update(cx, |_, _| ()).is_err() { + return; + } + continue; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + Err("连接测试意外中止".to_string()) + } + }; + this.update_in(cx, |this, window, cx| { + this.testing_connection = false; + window.push_notification(connection_test_notification(result), cx); + cx.notify(); + }) + .ok(); + return; + } }) .detach(); } @@ -555,70 +629,64 @@ impl Render for SessionForm { .child(error), ) }) - .when_some( - self.connection_test_result.clone(), - |form, (success, message)| { - form.child( - div() - .id("connection-test-result") - .test_support() - .aria_label(message.clone()) - .text_sm() - .text_color(if success { - cx.theme().success - } else { - cx.theme().danger - }) - .child(message), - ) - }, - ) } } -/// Check that a TCP endpoint speaks SSH. Authentication is intentionally not -/// attempted, so the form can test unsaved hosts without storing credentials -/// or changing the application's trusted-host list. -fn probe_ssh_service(host: &str, port: u16) -> Result<(), String> { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|error| format!("无法启动连接测试:{error}"))?; - runtime.block_on(async { - tokio::time::timeout(Duration::from_secs(8), async { - let mut stream = tokio::net::TcpStream::connect((host, port)) - .await - .map_err(|error| format!("无法连接:{error}"))?; - stream - .write_all(b"SSH-2.0-shellr-probe\r\n") - .await - .map_err(|error| format!("无法发送 SSH 标识:{error}"))?; - for _ in 0..10 { - let mut line = Vec::new(); - for _ in 0..255 { - let byte = stream - .read_u8() - .await - .map_err(|error| format!("无法读取 SSH 标识:{error}"))?; - if byte == b'\n' { - break; - } - line.push(byte); - } - if line.starts_with(b"SSH-2.0-") || line.starts_with(b"SSH-1.99-") { - return Ok(()); - } - } - Err("目标端口没有返回 SSH 服务标识".to_string()) - }) - .await - .map_err(|_| "连接超时".to_string())? - }) +/// A trust question from the test's worker, with where to send the answer. +type TrustQuestion = (UnknownHostPrompt, std::sync::mpsc::Sender); + +/// A port field's value, when it is one. +fn parse_port(port: &str) -> Option { + port.parse::().ok().filter(|port| *port != 0) +} + +/// Put a first-seen host key to the user, above the session dialog. Closing +/// the dialog any other way than trusting counts as declining. +fn ask_to_trust( + prompt: UnknownHostPrompt, + reply: std::sync::mpsc::Sender, + window: &mut Window, + cx: &mut App, +) { + let description = prompt.description(); + window.open_alert_dialog(cx, move |alert, _, _| { + let answer = |trusted: bool| { + let reply = reply.clone(); + move || { + let _ = reply.send(trusted); + } + }; + let (trust, decline, dismiss) = (answer(true), answer(false), answer(false)); + alert + .title("首次连接此主机") + .description(description.clone()) + .button_props( + DialogButtonProps::default() + .ok_text("信任并继续") + .cancel_text("取消"), + ) + .show_cancel(true) + .on_ok(move |_, _, _| { + trust(); + true + }) + .on_cancel(move |_, _, _| { + decline(); + true + }) + .on_close(move |_, _, _| dismiss()) + }); +} + +/// A connection test has two outcomes: it connected, or it did not and the +/// message says why. +fn connection_test_notification(result: Result<(), String>) -> Notification { + match result { + Ok(()) => Notification::success("连接成功"), + Err(reason) => Notification::error(reason).title("连接失败"), + } } -/// Open the new-session (`editing == None`) or edit-session dialog. -/// `preselect_group` fills in the group field of a new session, so creating -/// one from a group's context menu lands it in that group. /// Whether this authentication kind can end up asking for a password. /// `Auto` walks agent, then keys, then password, so it can. fn uses_password(auth: AuthKind) -> bool { @@ -641,14 +709,19 @@ fn secret_change(value: String, loaded: bool) -> Option> { } } +/// Open the new-session (`editing == None`) or edit-session dialog. +/// `preselect_group` fills in the group field of a new session, so creating +/// one from a group's context menu lands it in that group. `tester` backs the +/// dialog's 「测试连接」 button. pub fn open_session_dialog( editing: Option, preselect_group: Option, store: Entity, + tester: SharedConnectionTester, window: &mut Window, cx: &mut App, ) { - let form = cx.new(|cx| SessionForm::new(editing, preselect_group, store, window, cx)); + let form = cx.new(|cx| SessionForm::new(editing, preselect_group, store, tester, window, cx)); let title: SharedString = if editing.is_some() { "编辑会话" } else { @@ -759,7 +832,16 @@ pub fn confirm_delete_session( #[cfg(test)] mod tests { - use super::{AuthKind, secret_change, uses_password}; + use super::{AuthKind, parse_port, secret_change, uses_password}; + + #[test] + fn a_port_field_is_a_number_from_1_to_65535() { + assert_eq!(parse_port("22"), Some(22)); + assert_eq!(parse_port("65535"), Some(65535)); + for port in ["0", "65536", "ssh", ""] { + assert_eq!(parse_port(port), None, "{port:?}"); + } + } #[test] fn auto_and_password_can_reach_a_password_prompt() { diff --git a/src/ssh/connection.rs b/src/ssh/connection.rs index e0179da..10d8c32 100644 --- a/src/ssh/connection.rs +++ b/src/ssh/connection.rs @@ -78,6 +78,21 @@ impl SshConnector { &self, config: &SshConnectionConfig, broker: Arc, + ) -> Result<(client::Handle, String)> { + self.connect_with(config, broker, &self.secrets).await + } + + /// The keychain this connector reads saved secrets from. + pub(super) fn secrets(&self) -> &SharedSecretStore { + &self.secrets + } + + /// Same as [`Self::connect`], with saved secrets read from `secrets`. + pub(super) async fn connect_with( + &self, + config: &SshConnectionConfig, + broker: Arc, + secrets: &SharedSecretStore, ) -> Result<(client::Handle, String)> { let fingerprint = Arc::new(Mutex::new(String::new())); let handler = SshClientHandler { @@ -104,7 +119,7 @@ impl SshConnector { _ = shutdown.changed() => bail!("连接已取消"), }; tokio::select! { - result = authenticate(&mut handle, config, &self.secrets, &broker) => result?, + result = authenticate(&mut handle, config, secrets, &broker) => result?, _ = shutdown.changed() => bail!("连接已取消"), } let fingerprint = fingerprint @@ -115,6 +130,26 @@ impl SshConnector { } } impl SshConnectionConfig { + pub fn new( + host: impl Into, + port: u16, + user: impl Into, + auth: AuthKind, + key_path: Option, + ) -> Self { + Self { + host: host.into(), + port, + user: user.into(), + auth, + key_path, + } + } + + pub fn host(&self) -> &str { + &self.host + } + pub fn endpoint(&self) -> String { format!("{}@{}:{}", self.user, self.host, self.port) } @@ -125,8 +160,41 @@ pub struct SshPrompts { events: Arc bool + Send + Sync>, shutdown: watch::Receiver, prompt_activity: watch::Sender, + /// Whether a password, passphrase or keyboard-interactive answer may be + /// asked of the user. When not, such a need fails the connection with a + /// [`MissingCredential`] instead. + interactive: bool, } +/// A credential authentication needed and could not ask for, because the +/// connection runs without anyone to answer (a connection test). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MissingCredential { + /// `rejected` when a password was tried and the server refused it. + Password { + rejected: bool, + }, + /// `rejected` when a passphrase was tried and did not unlock the key. + Passphrase { + rejected: bool, + }, + KeyboardInteractive, +} + +impl std::fmt::Display for MissingCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + MissingCredential::Password { rejected: true } => "用户名或密码错误", + MissingCredential::Password { rejected: false } => "未填写密码", + MissingCredential::Passphrase { rejected: true } => "私钥口令错误", + MissingCredential::Passphrase { rejected: false } => "私钥已加密,未填写口令", + MissingCredential::KeyboardInteractive => "服务器要求键盘交互式认证,无法在测试中完成", + }) + } +} + +impl std::error::Error for MissingCredential {} + impl SshPrompts { pub fn new( events: Arc bool + Send + Sync>, @@ -138,14 +206,21 @@ impl SshPrompts { events, shutdown, prompt_activity, + interactive: true, } } + /// Fail on a credential need instead of asking; see [`MissingCredential`]. + pub fn non_interactive(mut self) -> Self { + self.interactive = false; + self + } + pub fn shutdown_receiver(&self) -> watch::Receiver { self.shutdown.clone() } - fn prompt_activity_receiver(&self) -> watch::Receiver { + pub(super) fn prompt_activity_receiver(&self) -> watch::Receiver { self.prompt_activity.subscribe() } @@ -176,6 +251,18 @@ impl SshPrompts { result } + /// Ask for a credential, or fail with `need` when nobody may be asked. + async fn ask_credential( + &self, + need: MissingCredential, + kind: ConnectionPromptKind, + ) -> Result { + if !self.interactive { + return Err(need.into()); + } + self.ask(kind).await + } + async fn emit(&self, kind: ConnectionPromptKind) { let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed); (self.events)(ConnectionPrompt::new(request_id, kind)); @@ -202,7 +289,7 @@ impl SshPrompts { /// Apply the network handshake timeout without counting time spent waiting /// for an explicit answer from the user to a host-trust prompt. -async fn timeout_excluding_prompts( +pub(super) async fn timeout_excluding_prompts( future: F, mut prompt_activity: watch::Receiver, timeout: Duration, @@ -434,7 +521,16 @@ where }; if !partial && methods.contains(&MethodKind::Password) { for _ in 0..AUTH_RETRIES { - let answer = ask_one_secret(broker, "SSH 登录", instructions, "密码").await?; + let answer = ask_one_secret( + broker, + MissingCredential::Password { + rejected: saved_rejected, + }, + "SSH 登录", + instructions, + "密码", + ) + .await?; let result = handle .authenticate_password(&config.user, answer.into_inner()) .await @@ -547,7 +643,16 @@ async fn load_private_key( format!("请输入 {} 的口令", path.display()) }; for _ in 0..AUTH_RETRIES { - let answer = ask_one_secret(broker, "私钥口令", &instructions, "口令").await?; + let answer = ask_one_secret( + broker, + MissingCredential::Passphrase { + rejected: saved_rejected, + }, + "私钥口令", + &instructions, + "口令", + ) + .await?; if let Ok(key) = load_secret_key(path, Some(answer.expose())) { return Ok(Some(key)); } @@ -577,16 +682,20 @@ fn saved_secret(secrets: &SharedSecretStore, secret: &SecretRef) -> Option Result { let reply = broker - .ask(ConnectionPromptKind::authentication( - title, - instructions, - vec![ConnectionPromptField::new(label, false)], - )) + .ask_credential( + need, + ConnectionPromptKind::authentication( + title, + instructions, + vec![ConnectionPromptField::new(label, false)], + ), + ) .await?; match reply { ConnectionPromptReply::Answers(mut answers) if answers.len() == 1 => Ok(answers.remove(0)), @@ -621,11 +730,10 @@ where .map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo)) .collect(); let reply = broker - .ask(ConnectionPromptKind::authentication( - name, - instructions, - fields, - )) + .ask_credential( + MissingCredential::KeyboardInteractive, + ConnectionPromptKind::authentication(name, instructions, fields), + ) .await?; let ConnectionPromptReply::Answers(answers) = reply else { bail!("交互式认证已取消") diff --git a/src/ssh/mod.rs b/src/ssh/mod.rs index 7f58151..e1299b0 100644 --- a/src/ssh/mod.rs +++ b/src/ssh/mod.rs @@ -1,6 +1,8 @@ //! Shared SSH connection service and remote terminal adapter. mod connection; mod probe; +mod tester; mod transport; pub use connection::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts}; +pub use tester::SshConnectionTester; pub use transport::SshTerminalTransportProvider; diff --git a/src/ssh/tester.rs b/src/ssh/tester.rs new file mode 100644 index 0000000..504f38e --- /dev/null +++ b/src/ssh/tester.rs @@ -0,0 +1,309 @@ +//! Log in once with the session form's values, for its 「测试连接」 button. +use std::{ + sync::{Arc, Mutex, OnceLock, Weak}, + time::Duration, +}; + +use anyhow::Result; +use tokio::sync::watch; +use zeroize::Zeroizing; + +use super::connection::{ + MissingCredential, SshConnectionConfig, SshConnector, SshPrompts, timeout_excluding_prompts, +}; +use crate::{ + connection::{ + ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionTester, LoginTest, + TrustCallback, + }, + secrets::{SecretRef, SecretStore, SharedSecretStore}, +}; + +/// How long a test may take, not counting the time a person spends deciding +/// whether to trust the host key. +const TEST_TIMEOUT: Duration = Duration::from_secs(20); + +/// Tests a login through the same connector as terminals and SFTP, so host +/// trust goes through the same file and lock. +pub struct SshConnectionTester { + connector: SshConnector, +} + +impl SshConnectionTester { + pub fn new(connector: SshConnector) -> Self { + Self { connector } + } +} + +impl ConnectionTester for SshConnectionTester { + fn test(&self, request: LoginTest, trust: TrustCallback) -> Result<(), String> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("无法启动连接测试:{error}"))?; + runtime.block_on(self.run(request, trust)) + } +} + +/// What the host-key checks said along the way, which the connect error alone +/// does not tell apart. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct HostTrust { + declined: bool, + key_changed: bool, +} + +impl SshConnectionTester { + async fn run(&self, request: LoginTest, trust: TrustCallback) -> Result<(), String> { + // Resolved apart from connecting, so a mistyped name says so instead + // of surfacing the resolver's own wording. + if tokio::net::lookup_host((request.host(), request.port())) + .await + .is_err() + { + return Err(format!("无法解析主机 {}", request.host())); + } + + let config = SshConnectionConfig::new( + request.host(), + request.port(), + request.user(), + request.auth(), + request.key_path().map(Into::into), + ); + let secrets: SharedSecretStore = + Arc::new(FormSecrets::new(&request, self.connector.secrets().clone())); + let host_trust = Arc::new(Mutex::new(HostTrust::default())); + let (_shutdown_tx, shutdown_rx) = watch::channel(false); + // The events callback answers the trust question through the broker, + // which does not exist yet when the callback is made. + let broker_slot: Arc>> = Arc::new(OnceLock::new()); + let events = { + let trust = Arc::new(trust); + let host_trust = host_trust.clone(); + let broker_slot = broker_slot.clone(); + Arc::new(move |prompt: ConnectionPrompt| match prompt.kind() { + ConnectionPromptKind::UnknownHost(question) => { + let (trust, host_trust, broker_slot) = + (trust.clone(), host_trust.clone(), broker_slot.clone()); + let (request_id, question) = (prompt.request_id(), question.clone()); + // The answer takes a person; keep the connection's runtime + // free while they decide. + tokio::task::spawn_blocking(move || { + let trusted = trust(question); + if !trusted { + lock(&host_trust).declined = true; + } + if let Some(broker) = broker_slot.get().and_then(Weak::upgrade) { + broker.respond( + request_id, + if trusted { + ConnectionPromptReply::TrustAndSave + } else { + ConnectionPromptReply::Cancel + }, + ); + } + }); + true + } + ConnectionPromptKind::HostKeyChanged(_) => { + lock(&host_trust).key_changed = true; + true + } + // Credential questions never get here: the broker is + // non-interactive and fails with a `MissingCredential`. + ConnectionPromptKind::Authentication(_) => false, + }) + }; + let broker = Arc::new(SshPrompts::new(events, shutdown_rx).non_interactive()); + let _ = broker_slot.set(Arc::downgrade(&broker)); + + let connected = timeout_excluding_prompts( + self.connector + .connect_with(&config, broker.clone(), &secrets), + broker.prompt_activity_receiver(), + TEST_TIMEOUT, + ) + .await + .and_then(|result| result); + match connected { + Ok((handle, _)) => { + let _ = handle + .disconnect(russh::Disconnect::ByApplication, "connection test", "zh-CN") + .await; + Ok(()) + } + Err(error) => Err(describe_test_failure(&error, *lock(&host_trust))), + } + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|error| error.into_inner()) +} + +/// Why a test failed, in the words the notification shows. +fn describe_test_failure(error: &anyhow::Error, host_trust: HostTrust) -> String { + if host_trust.key_changed { + return "主机密钥与已保存的不一致,已阻止连接。请先核实服务器身份".into(); + } + if host_trust.declined { + return "未信任该主机的密钥".into(); + } + for cause in error.chain() { + if let Some(need) = cause.downcast_ref::() { + return need.to_string(); + } + if let Some(io) = cause.downcast_ref::() { + return describe_connect_error(io); + } + match cause.downcast_ref::() { + // russh wraps the socket's own error rather than chaining it. + Some(russh::Error::IO(io)) => return describe_connect_error(io), + Some(russh::Error::ConnectionTimeout) => return "连接超时".into(), + _ => {} + } + } + error.to_string() +} + +/// A connect error in plain words. The common kinds get an explanation; +/// anything else keeps the system's own description. +fn describe_connect_error(error: &std::io::Error) -> String { + use std::io::ErrorKind; + match error.kind() { + ErrorKind::ConnectionRefused => "连接被拒绝,该端口上没有服务在监听".into(), + ErrorKind::TimedOut => "连接超时".into(), + ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => "主机不可达".into(), + _ => format!("无法连接:{error}"), + } +} + +/// The secrets a test logs in with. The form is authoritative for the login +/// password and for the passphrase of the key it names, whether or not they +/// were saved; anything else (a default key's passphrase) is read from the +/// keychain. Nothing is ever written. +struct FormSecrets { + password: (SecretRef, Option>), + passphrase: Option<(SecretRef, Option>)>, + fallback: SharedSecretStore, +} + +impl FormSecrets { + fn new(request: &LoginTest, fallback: SharedSecretStore) -> Self { + let owned = |value: Option<&str>| value.map(|value| Zeroizing::new(value.to_string())); + Self { + password: ( + SecretRef::password(request.user(), request.host(), request.port()), + owned(request.password()), + ), + passphrase: request + .key_path() + .map(|path| (SecretRef::passphrase(path), owned(request.passphrase()))), + fallback, + } + } +} + +impl SecretStore for FormSecrets { + fn get(&self, secret: &SecretRef) -> anyhow::Result>> { + if *secret == self.password.0 { + return Ok(self.password.1.clone()); + } + if let Some((key, value)) = &self.passphrase + && secret == key + { + return Ok(value.clone()); + } + self.fallback.get(secret) + } + + fn set(&self, _: &SecretRef, _: &str) -> anyhow::Result<()> { + Ok(()) + } + + fn delete(&self, _: &SecretRef) -> anyhow::Result<()> { + Ok(()) + } + + fn is_available(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use std::io::{Error, ErrorKind}; + + use anyhow::anyhow; + + use super::{HostTrust, MissingCredential, describe_test_failure}; + + fn reason(error: anyhow::Error) -> String { + describe_test_failure(&error, HostTrust::default()) + } + + #[test] + fn host_trust_outranks_the_connect_error() { + let error = anyhow!("无法建立 SSH 连接,请检查主机、端口和主机密钥"); + let changed = HostTrust { + key_changed: true, + ..HostTrust::default() + }; + assert!(describe_test_failure(&error, changed).starts_with("主机密钥与已保存的不一致")); + let declined = HostTrust { + declined: true, + ..HostTrust::default() + }; + assert_eq!( + describe_test_failure(&error, declined), + "未信任该主机的密钥" + ); + } + + #[test] + fn missing_credentials_name_what_was_wrong() { + assert_eq!( + reason(MissingCredential::Password { rejected: true }.into()), + "用户名或密码错误" + ); + assert_eq!( + reason(MissingCredential::Password { rejected: false }.into()), + "未填写密码" + ); + assert_eq!( + reason(MissingCredential::Passphrase { rejected: true }.into()), + "私钥口令错误" + ); + assert_eq!( + reason(MissingCredential::Passphrase { rejected: false }.into()), + "私钥已加密,未填写口令" + ); + assert!(reason(MissingCredential::KeyboardInteractive.into()).contains("键盘交互")); + } + + #[test] + fn network_errors_are_explained_under_any_context() { + let refused = anyhow::Error::from(Error::from(ErrorKind::ConnectionRefused)) + .context("无法建立 SSH 连接"); + assert_eq!(reason(refused), "连接被拒绝,该端口上没有服务在监听"); + assert_eq!( + reason(Error::from(ErrorKind::HostUnreachable).into()), + "主机不可达" + ); + assert_eq!(reason(russh::Error::ConnectionTimeout.into()), "连接超时"); + let wrapped = + anyhow::Error::from(russh::Error::IO(Error::from(ErrorKind::ConnectionRefused))) + .context("无法建立 SSH 连接"); + assert_eq!(reason(wrapped), "连接被拒绝,该端口上没有服务在监听"); + } + + #[test] + fn anything_else_keeps_the_connector_wording() { + assert_eq!( + reason(anyhow!("服务器未接受指定的私钥")), + "服务器未接受指定的私钥" + ); + } +} diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index 8dbde6e..fd8fa49 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -323,7 +323,7 @@ async fn open_probe( #[cfg(test)] mod tests { use super::*; - use crate::secrets::{InMemorySecretStore, NoSecretStore, SecretStore as _}; + use crate::secrets::{InMemorySecretStore, NoSecretStore, SecretRef, SecretStore as _}; use crate::session::HostOs; use crate::ssh::probe::{PROBE_COMMAND, WINDOWS_PROBE_COMMAND}; use crate::terminal::TerminalSecret; @@ -910,6 +910,187 @@ mod tests { ) } + /// Run a connection test the way the session form does, recording + /// whether (and about what) it asked to trust the host. + fn test_login( + request: crate::connection::LoginTest, + known_hosts: &Path, + keychain: Arc, + trust: bool, + ) -> (Result<(), String>, Vec) { + use crate::connection::ConnectionTester as _; + let asked = Arc::new(Mutex::new(Vec::new())); + let tester = crate::ssh::SshConnectionTester::new(SshConnector::new(known_hosts, keychain)); + let result = tester.test(request, { + let asked = asked.clone(); + Box::new(move |prompt| { + asked.lock().unwrap().push(prompt.fingerprint().to_string()); + trust + }) + }); + let asked = asked.lock().unwrap().clone(); + (result, asked) + } + + fn login_request(port: u16) -> crate::connection::LoginTest { + crate::connection::LoginTest::new("127.0.0.1", port, "tester", AuthKind::Password) + } + + #[test] + fn a_connection_test_logs_in_with_the_form_password_and_trusts_on_request() { + let Some(server) = start_server(TestAuth::Password) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + let keychain = Arc::new(InMemorySecretStore::default()); + + let (result, asked) = test_login( + login_request(server.port).with_password(TEST_PASSWORD), + &known_hosts, + keychain.clone(), + true, + ); + assert_eq!(result, Ok(())); + assert_eq!(asked.len(), 1, "陌生主机应当问一次是否信任"); + assert!( + std::fs::read_to_string(&known_hosts) + .unwrap() + .contains("127.0.0.1"), + "信任之后应当写进信任文件" + ); + assert!(keychain.is_empty(), "测试连接不该写钥匙串"); + + // Trusted now: the second test asks nothing. + let (result, asked) = test_login( + login_request(server.port).with_password(TEST_PASSWORD), + &known_hosts, + keychain, + false, + ); + assert_eq!(result, Ok(())); + assert!(asked.is_empty()); + } + + #[test] + fn a_wrong_form_password_fails_even_with_the_right_one_saved() { + let Some(server) = start_server(TestAuth::Password) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + // What the user reported: the keychain holds the working password, + // the form has been edited to a wrong one. + let keychain = Arc::new(InMemorySecretStore::default()); + let saved = SecretRef::password("tester", "127.0.0.1", server.port); + keychain.set(&saved, TEST_PASSWORD).unwrap(); + + let (result, _) = test_login( + login_request(server.port).with_password("wrong-password"), + &known_hosts, + keychain.clone(), + true, + ); + assert_eq!(result, Err("用户名或密码错误".to_string())); + + let (result, _) = test_login( + login_request(server.port), + &known_hosts, + keychain.clone(), + true, + ); + assert_eq!(result, Err("未填写密码".to_string())); + assert_eq!( + keychain.get(&saved).unwrap().as_deref().map(String::as_str), + Some(TEST_PASSWORD), + "保存的密码原样不动" + ); + assert_eq!(keychain.len(), 1); + } + + #[test] + fn declining_to_trust_fails_the_test_and_saves_nothing() { + let Some(server) = start_server(TestAuth::Password) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + + let (result, asked) = test_login( + login_request(server.port).with_password(TEST_PASSWORD), + &known_hosts, + Arc::new(InMemorySecretStore::default()), + false, + ); + assert_eq!(result, Err("未信任该主机的密钥".to_string())); + assert_eq!(asked.len(), 1); + assert!( + !known_hosts.exists() + || !std::fs::read_to_string(&known_hosts) + .unwrap() + .contains("127.0.0.1") + ); + } + + #[test] + fn a_changed_host_key_fails_the_test_without_asking() { + let Some(server) = start_server(TestAuth::Password) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + let mut rng = russh::keys::key::safe_rng(); + let stale_key = + russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap(); + learn_known_hosts_path( + "127.0.0.1", + server.port, + stale_key.public_key(), + &known_hosts, + ) + .unwrap(); + + let (result, asked) = test_login( + login_request(server.port).with_password(TEST_PASSWORD), + &known_hosts, + Arc::new(InMemorySecretStore::default()), + true, + ); + assert!( + result + .as_ref() + .is_err_and(|reason| reason.starts_with("主机密钥与已保存的不一致")), + "{result:?}" + ); + assert!(asked.is_empty(), "密钥变了不该再问要不要信任"); + } + + #[test] + fn a_closed_port_fails_the_test_with_the_reason() { + let Ok(listener) = std::net::TcpListener::bind(("127.0.0.1", 0)) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let port = listener.local_addr().unwrap().port(); + drop(listener); + let directory = tempfile::tempdir().unwrap(); + let (result, asked) = test_login( + login_request(port).with_password(TEST_PASSWORD), + &directory.path().join("known_hosts"), + Arc::new(InMemorySecretStore::default()), + true, + ); + assert_eq!( + result, + Err("连接被拒绝,该端口上没有服务在监听".to_string()) + ); + assert!(asked.is_empty()); + } + #[test] fn a_saved_password_connects_without_asking() { let Some(server) = start_server(TestAuth::Password) else { diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 68f17d8..59b8085 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -28,6 +28,7 @@ use crate::app::{ ToggleSessionPanel, ToggleTheme, ToggleUploadSelection, UploadSelectedFiles, ZoomIn, ZoomOut, ZoomReset, }; +use crate::connection::SharedConnectionTester; use crate::explorer::{ExplorerPanel, ExplorerPanelEvent, confirm_close_upload}; use crate::session::{ ConnectionState, GroupId, SessionId, SessionPanel, SessionStore, SessionStoreEvent, @@ -100,6 +101,8 @@ pub struct Workspace { remote_terminal_provider: SharedRemoteTerminalTransportProvider, sftp_provider: SharedSftpTransportProvider, local_directory_provider: SharedLocalDirectoryProvider, + /// Backs the session dialog's 「测试连接」. + connection_tester: SharedConnectionTester, next_remote_terminal_id: u64, next_local_terminal_id: u64, /// The center tab displayed most recently; `CloseActiveTab` closes it. @@ -132,6 +135,7 @@ impl Workspace { let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::with_connector( connector.clone(), )); + let tester = Arc::new(crate::ssh::SshConnectionTester::new(connector.clone())); let sftp = Arc::new(SshSftpTransportProvider::new( connector, crate::app::data_dir().join("upload-resume"), @@ -142,6 +146,7 @@ impl Workspace { local_terminal_factory, sftp, Arc::new(SystemLocalDirectoryProvider), + tester, window, cx, ) @@ -160,6 +165,7 @@ impl Workspace { crate::app::data_dir().join("known_hosts"), store.read(cx).secrets(), ); + let tester = Arc::new(crate::ssh::SshConnectionTester::new(connector.clone())); let sftp = Arc::new(SshSftpTransportProvider::new( connector, crate::app::data_dir().join("upload-resume"), @@ -170,18 +176,23 @@ impl Workspace { local_terminal_factory, sftp, Arc::new(SystemLocalDirectoryProvider), + tester, window, cx, ) } /// Inject every filesystem and transport service; tests require neither a server nor a keychain. + // One parameter per injected service, which is the point of this + // constructor; bundling them would only move the list elsewhere. + #[allow(clippy::too_many_arguments)] pub fn new_with_services( store: Entity, remote_terminal_provider: SharedRemoteTerminalTransportProvider, local_terminal_factory: SharedTerminalTransportFactory, sftp_provider: SharedSftpTransportProvider, local_directory_provider: SharedLocalDirectoryProvider, + connection_tester: SharedConnectionTester, window: &mut Window, cx: &mut Context, ) -> Self { @@ -303,6 +314,7 @@ impl Workspace { remote_terminal_provider, sftp_provider, local_directory_provider, + connection_tester, next_remote_terminal_id, next_local_terminal_id: 1, active_tab: None, @@ -408,13 +420,7 @@ impl Workspace { let workspace = cx.entity().downgrade(); match prompt.kind().clone() { TerminalPromptKind::UnknownHost(prompt) => { - let description = format!( - "主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。", - prompt.host(), - prompt.port(), - prompt.algorithm(), - prompt.fingerprint(), - ); + let description = prompt.description(); window.open_alert_dialog(cx, move |alert, _, _| { alert .title("首次连接此主机") @@ -1154,7 +1160,14 @@ impl Workspace { } fn on_new_session(&mut self, _: &NewSession, window: &mut Window, cx: &mut Context) { - open_session_dialog(None, None, self.store.clone(), window, cx); + open_session_dialog( + None, + None, + self.store.clone(), + self.connection_tester.clone(), + window, + cx, + ); } fn on_edit_session( @@ -1164,7 +1177,14 @@ impl Workspace { cx: &mut Context, ) { if self.store.read(cx).session(action.0).is_some() { - open_session_dialog(Some(action.0), None, self.store.clone(), window, cx); + open_session_dialog( + Some(action.0), + None, + self.store.clone(), + self.connection_tester.clone(), + window, + cx, + ); } } @@ -1268,7 +1288,14 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - open_session_dialog(None, Some(action.0), self.store.clone(), window, cx); + open_session_dialog( + None, + Some(action.0), + self.store.clone(), + self.connection_tester.clone(), + window, + cx, + ); } fn on_new_group(&mut self, _: &NewGroup, window: &mut Window, cx: &mut Context) { diff --git a/tests/ui.rs b/tests/ui.rs index cbb0238..e12c995 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -1,13 +1,11 @@ //! UI integration tests: the production `Workspace` rendered in a headless //! window, driven through real pointer and keyboard events. -use std::io::Write as _; -use std::net::TcpListener; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; -use gpui_kit::component::{ActiveTheme as _, Root}; +use gpui_kit::component::{ActiveTheme as _, Root, WindowExt as _}; use gpui_kit::test::{TestAppContextExt, TestWindowExt}; use gpui_kit::{ AppContext as _, ClipboardItem, ElementId, Entity, InputEvent as _, MouseButton, @@ -19,6 +17,7 @@ use shellr::app::{ CopySessionHost, DeleteGroup, DeleteSession, EditSession, ExpandAllGroups, NewLocalTerminal, NewSessionInGroup, OpenExplorer, RenameGroup, RenameTerminal, }; +use shellr::connection::{ConnectionPromptKind, ConnectionTester, LoginTest, TrustCallback}; use shellr::secrets::{InMemorySecretStore, SecretRef, SecretStore as _}; use shellr::session::{ AuthKind, ConnectionState, GroupDraft, GroupId, HostOs, SessionDatabase, SessionDraft, @@ -58,6 +57,15 @@ fn open_workspace(cx: &mut TestAppContext) -> (WindowHandle, Entity (WindowHandle, Entity) { + open_workspace_with_tester(cx, store, Arc::new(FakeConnectionTester::default())) +} + +/// Same, with the session dialog's connection test answered by `tester`. +fn open_workspace_with_tester( + cx: &mut TestAppContext, + store: SessionStore, + tester: Arc, ) -> (WindowHandle, Entity) { cx.update(shellr::init); let mut workspace = None; @@ -73,6 +81,7 @@ fn open_workspace_with_store( Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()), Arc::new(FakeLocalDirectory), + tester.clone(), window, cx, ) @@ -83,6 +92,92 @@ fn open_workspace_with_store( (handle, workspace.expect("workspace created")) } +/// `(host, port, user, password)` of one connection test. +type TestedLogin = (String, u16, String, Option); + +/// Stands in for the SSH login behind 「测试连接」: records what the form sent, +/// optionally asks to trust a made-up host key, and answers with `result`. +struct FakeConnectionTester { + asks_trust: bool, + result: Result<(), String>, + requests: Mutex>, + trust_answers: Mutex>, +} + +impl Default for FakeConnectionTester { + fn default() -> Self { + Self { + asks_trust: false, + result: Ok(()), + requests: Mutex::default(), + trust_answers: Mutex::default(), + } + } +} + +impl FakeConnectionTester { + fn failing(reason: &str) -> Self { + Self { + result: Err(reason.to_string()), + ..Self::default() + } + } + + fn asking_trust() -> Self { + Self { + asks_trust: true, + ..Self::default() + } + } + + fn requests(&self) -> Vec { + self.requests + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } + + fn trust_answers(&self) -> Vec { + self.trust_answers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } +} + +impl ConnectionTester for FakeConnectionTester { + fn test(&self, request: LoginTest, trust: TrustCallback) -> Result<(), String> { + self.requests + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(( + request.host().to_string(), + request.port(), + request.user().to_string(), + request.password().map(str::to_string), + )); + if self.asks_trust { + let ConnectionPromptKind::UnknownHost(prompt) = ConnectionPromptKind::unknown_host( + request.host(), + request.port(), + "ssh-ed25519", + "SHA256:test-fingerprint", + ) else { + unreachable!("unknown_host builds an UnknownHost prompt"); + }; + let trusted = trust(prompt); + self.trust_answers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(trusted); + if !trusted { + return Err("未信任该主机的密钥".to_string()); + } + } + self.result.clone() + } +} + #[derive(Clone, Copy, Default)] enum FakeBehavior { #[default] @@ -238,6 +333,7 @@ fn open_workspace_with_factory( factory.clone(), Arc::new(FakeSftpProvider::default()), Arc::new(FakeLocalDirectory), + Arc::new(FakeConnectionTester::default()), window, cx, ) @@ -265,6 +361,7 @@ fn open_workspace_with_remote_factory( Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()), Arc::new(FakeLocalDirectory), + Arc::new(FakeConnectionTester::default()), window, cx, ) @@ -878,17 +975,75 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { }); } +/// What the user reported: the keychain has the working password, the edit +/// dialog's field has been changed to a wrong one, and 「测试连接」 must try the +/// field — without saving anything. #[gpui_kit::test] -async fn session_dialog_tests_ssh_service_without_saving(cx: &mut TestAppContext) { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let port = listener.local_addr().unwrap().port(); - let (reply, resume) = mpsc::channel(); - let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - resume.recv_timeout(Duration::from_secs(3)).unwrap(); - stream.write_all(b"SSH-2.0-test-server\r\n").unwrap(); +async fn testing_a_connection_logs_in_with_what_the_form_shows(cx: &mut TestAppContext) { + let secrets = Arc::new(InMemorySecretStore::default()); + let (store, id, endpoint) = store_with_secrets(secrets.clone()); + secrets.set(&endpoint, "hunter2").unwrap(); + let tester = Arc::new(FakeConnectionTester::failing("用户名或密码错误")); + let (handle, workspace) = open_workspace_with_tester(cx, store, tester.clone()); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(EditSession(id)), cx); + }) + .unwrap(); + // The saved password is read on a background thread, then fills the field. + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("session-password", cx); + window.press("cmd-a", cx); + window.input("wrong-password", cx); + window.click("session-port", cx); + window.press("cmd-a", cx); + window.input("2222", cx); + window.click("test-connection", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { + window.render_frame(cx); + window.notifications(cx).len() == 1 + }) + .await; + + assert_eq!( + tester.requests(), + [( + "10.0.2.5".to_string(), + 2222, + "postgres".to_string(), + Some("wrong-password".to_string()) + )] + ); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + // Nothing was saved, and the dialog stays open to fix the field. + assert!(window.find("commit").visible()); + }) + .unwrap(); + cx.update(|cx| { + let store = workspace.read(cx).store().read(cx); + assert_eq!(store.session(id).unwrap().port, 22); }); - let (handle, workspace) = open_workspace_with_store(cx, SessionStore::empty()); + assert_eq!( + secrets + .get(&endpoint) + .unwrap() + .as_deref() + .map(String::as_str), + Some("hunter2"), + "测试连接不写钥匙串" + ); +} + +#[gpui_kit::test] +async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext) { + let tester = Arc::new(FakeConnectionTester::default()); + let (handle, _) = open_workspace_with_tester(cx, SessionStore::empty(), tester.clone()); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); @@ -903,45 +1058,102 @@ async fn session_dialog_tests_ssh_service_without_saving(cx: &mut TestAppContext let commit = window.find("commit").bounds(); assert!(test.right() < cancel.left()); assert!(cancel.right() < commit.left()); - assert!(commit.size.width < window.find("session-host").bounds().size.width); - + assert!(window.notifications(cx).is_empty()); window.click("test-connection", cx); }) .unwrap(); + cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert_eq!( - window.find("connection-test-result").label(), - Some("请输入主机") - ); + // Reported at once as a failed test; the form shows no result of its own. + assert_eq!(window.notifications(cx).len(), 1); + assert!(window.try_find("form-error").is_none()); window.click("session-host", cx); - window.input("127.0.0.1", cx); - window.click("session-port", cx); + window.input("10.0.3.7", cx); + window.click("session-user", cx); #[cfg(target_os = "macos")] window.press("cmd-a", cx); #[cfg(not(target_os = "macos"))] window.press("ctrl-a", cx); - window.input(&port.to_string(), cx); + window.press("backspace", cx); window.click("test-connection", cx); }) .unwrap(); + cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert!(window.find("test-connection").visible()); - assert!(window.try_find("connection-test-result").is_none()); + assert_eq!(window.notifications(cx).len(), 2, "没有用户名也不该去连"); + }) + .unwrap(); + assert!(tester.requests().is_empty()); +} + +#[gpui_kit::test] +async fn a_first_seen_host_key_is_put_to_the_user_above_the_session_dialog( + cx: &mut TestAppContext, +) { + let tester = Arc::new(FakeConnectionTester::asking_trust()); + let (store, id) = one_session_store(AuthKind::Password); + let (handle, _) = open_workspace_with_tester(cx, store, tester.clone()); + + // Trust: the question reaches the tester as a yes. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(EditSession(id)), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("test-connection", cx); }) .unwrap(); - reply.send(()).unwrap(); cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { window.render_frame(cx); - window - .try_find("connection-test-result") - .and_then(|result| result.label().map(str::to_string)) - .is_some_and(|message| message.contains("SSH 服务可达")) + window.try_find("ok").is_some() }) .await; - server.join().unwrap(); - cx.update(|cx| assert!(workspace.read(cx).store().read(cx).sessions().is_empty())); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("ok", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { + window.render_frame(cx); + window.notifications(cx).len() == 1 + }) + .await; + assert_eq!(tester.trust_answers(), [true]); + + // Escape dismisses the question, which declines it; the session dialog + // underneath stays open. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find("ok").is_none()); + window.click("test-connection", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { + window.render_frame(cx); + window.try_find("ok").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("escape", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { + window.render_frame(cx); + window.notifications(cx).len() == 2 + }) + .await; + assert_eq!(tester.trust_answers(), [true, false]); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("commit").visible()); + }) + .unwrap(); } #[gpui_kit::test] @@ -2996,6 +3208,7 @@ fn open_workspace_with_sftp( Arc::new(FakeTerminalFactory::default()), provider, Arc::new(FakeLocalDirectory), + Arc::new(FakeConnectionTester::default()), window, cx, )