测试连接改为真实登录,结果以通知呈现
This commit is contained in:
1 parent
6b1cd97317
commit
48ac2b95aa
9 files changed
+1184
-154
No files matched your search
@@ -41,6 +41,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`,`Session.os` 是 `Option<HostOs>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`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 校验写 `<data_dir>/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<SessionStore>`:生产环境由 `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<SessionStore>`:生产环境由 `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` 初始为已连接并已打开终端标签。
|
||||
@@ -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<std::path::PathBuf>,
|
||||
password: Option<Zeroizing<String>>,
|
||||
passphrase: Option<Zeroizing<String>>,
|
||||
}
|
||||
|
||||
impl LoginTest {
|
||||
pub fn new(
|
||||
host: impl Into<String>,
|
||||
port: u16,
|
||||
user: impl Into<String>,
|
||||
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<std::path::PathBuf>) -> Self {
|
||||
self.key_path = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_password(mut self, password: impl Into<String>) -> Self {
|
||||
self.password = Some(Zeroizing::new(password.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_passphrase(mut self, passphrase: impl Into<String>) -> 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(|_| "<redacted>"))
|
||||
.field(
|
||||
"passphrase",
|
||||
&self.passphrase.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.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<dyn Fn(UnknownHostPrompt) -> 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<dyn ConnectionTester>;
|
||||
+179
-97
@@ -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<Option<GroupId>>,
|
||||
error: Option<SharedString>,
|
||||
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<SessionId>,
|
||||
preselect_group: Option<GroupId>,
|
||||
store: Entity<SessionStore>,
|
||||
tester: SharedConnectionTester,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>) {
|
||||
/// 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<LoginTest, &'static str> {
|
||||
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<Self>) {
|
||||
if self.testing_connection {
|
||||
return;
|
||||
}
|
||||
let host = self.host.read(cx).value().trim().to_string();
|
||||
let port = self.port.read(cx).value().trim().parse::<u16>();
|
||||
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::<TrustQuestion>();
|
||||
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<bool>);
|
||||
|
||||
/// A port field's value, when it is one.
|
||||
fn parse_port(port: &str) -> Option<u16> {
|
||||
port.parse::<u16>().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<bool>,
|
||||
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<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<SessionId>,
|
||||
preselect_group: Option<GroupId>,
|
||||
store: Entity<SessionStore>,
|
||||
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() {
|
||||
|
||||
+123
-15
@@ -78,6 +78,21 @@ impl SshConnector {
|
||||
&self,
|
||||
config: &SshConnectionConfig,
|
||||
broker: Arc<SshPrompts>,
|
||||
) -> Result<(client::Handle<SshClientHandler>, 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<SshPrompts>,
|
||||
secrets: &SharedSecretStore,
|
||||
) -> Result<(client::Handle<SshClientHandler>, 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<String>,
|
||||
port: u16,
|
||||
user: impl Into<String>,
|
||||
auth: AuthKind,
|
||||
key_path: Option<PathBuf>,
|
||||
) -> 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<dyn Fn(ConnectionPrompt) -> bool + Send + Sync>,
|
||||
shutdown: watch::Receiver<bool>,
|
||||
prompt_activity: watch::Sender<bool>,
|
||||
/// 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<dyn Fn(ConnectionPrompt) -> 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<bool> {
|
||||
self.shutdown.clone()
|
||||
}
|
||||
|
||||
fn prompt_activity_receiver(&self) -> watch::Receiver<bool> {
|
||||
pub(super) fn prompt_activity_receiver(&self) -> watch::Receiver<bool> {
|
||||
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<ConnectionPromptReply> {
|
||||
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<F>(
|
||||
pub(super) async fn timeout_excluding_prompts<F>(
|
||||
future: F,
|
||||
mut prompt_activity: watch::Receiver<bool>,
|
||||
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<Zeroi
|
||||
|
||||
async fn ask_one_secret(
|
||||
broker: &SshPrompts,
|
||||
need: MissingCredential,
|
||||
title: &str,
|
||||
instructions: &str,
|
||||
label: &str,
|
||||
) -> Result<crate::connection::ConnectionSecret> {
|
||||
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!("交互式认证已取消")
|
||||
|
||||
@@ -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;
|
||||
@@ -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<OnceLock<Weak<SshPrompts>>> = 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<T>(mutex: &Mutex<T>) -> 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::<MissingCredential>() {
|
||||
return need.to_string();
|
||||
}
|
||||
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
|
||||
return describe_connect_error(io);
|
||||
}
|
||||
match cause.downcast_ref::<russh::Error>() {
|
||||
// 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<Zeroizing<String>>),
|
||||
passphrase: Option<(SecretRef, Option<Zeroizing<String>>)>,
|
||||
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<Option<Zeroizing<String>>> {
|
||||
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!("服务器未接受指定的私钥")),
|
||||
"服务器未接受指定的私钥"
|
||||
);
|
||||
}
|
||||
}
|
||||
+182
-1
@@ -323,7 +323,7 @@ async fn open_probe<H: client::Handler>(
|
||||
#[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<InMemorySecretStore>,
|
||||
trust: bool,
|
||||
) -> (Result<(), String>, Vec<String>) {
|
||||
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 {
|
||||
|
||||
@@ -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<SessionStore>,
|
||||
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>,
|
||||
) -> 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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
|
||||
+243
-30
@@ -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<Root>, Entity<Worksp
|
||||
fn open_workspace_with_store(
|
||||
cx: &mut TestAppContext,
|
||||
store: SessionStore,
|
||||
) -> (WindowHandle<Root>, Entity<Workspace>) {
|
||||
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<FakeConnectionTester>,
|
||||
) -> (WindowHandle<Root>, Entity<Workspace>) {
|
||||
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<String>);
|
||||
|
||||
/// 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<Vec<TestedLogin>>,
|
||||
trust_answers: Mutex<Vec<bool>>,
|
||||
}
|
||||
|
||||
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<TestedLogin> {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn trust_answers(&self) -> Vec<bool> {
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user