Files
shell-rs/tests/cli_bin.rs
T
Xuz d6d052de9e 凭据(密码 / 密钥 / SSH Agent),主机的认证方式分「手动输入 / 使用凭据」
凭据
- 标题栏「ShellRS」后面加第三个图标「凭据」,侧栏的第三个列表:搜索、
  ↑↓ 与回车编辑、双击编辑、右键编辑 / 删除,空列表里有「新建凭据…」
- 三种:密码(用户名 + 密码)、密钥(用户名 + 本机私钥文件路径 + 口令)、
  SSH Agent(用户名)。凭据带用户名,改了之后用它的主机一起变;改用户名、
  类型或私钥文件会让已连接的主机重连,只改名称不会
- 密码进钥匙串,账户名 credential:<随机 keychain_id>,不用会被复用的数字
  id;口令照旧按私钥文件归属
- 删除仍在用的凭据先确认并写明台数,那些主机改成「手动输入 · 自动」、
  保留用户名,已打开的连接不断开,凭据的密码随之删掉
- 库升到 v9:新增 credentials 与 session_credentials 两张表(不给 sessions
  加列,ALTER TABLE 会破坏「升级后与新库逐字相同」),迁移改成按 STEPS
  逐级升级

主机对话框
- 「认证方式」改为「手动输入 / 使用凭据」单选。手动输入原样保留
  自动 / 密码 / 私钥文件;使用凭据时选凭据,用户名和秘密都来自凭据,
  「测试连接」读钥匙串里凭据保存的密码
- 密码、私钥文件、口令三个字段连同异步预填和 secret_change 抽成
  session::SecretFields,主机和凭据两个对话框共用
- 地址与端口同一行的四列布局

登录
- 新的 SessionLogin:store 在 UI 线程查好凭据生成的登录快照,终端、SFTP、
  端口转发的 provider 和外部 CLI 的 CliTarget 都改收它;主机和凭据的修改
  按前后快照是否相等决定是否重连
- SSH Agent 凭据只走 agent,连不上、没有钥匙、服务器都不接受时明确报错;
  agent 位置可注入(AgentLocation),Windows 接 OpenSSH Agent 的命名管道,
  手动的「自动」也走同一个 connect_agent
- LoginTest 分 typed(表单说了算)和 saved(读钥匙串)

测试
- 单元:凭据模型、登录快照、v8→v9 迁移、store 的凭据接口与用户名同步、
  删除规则
- 协议:密码凭据只读自己的钥匙串条目;进程内 russh agent 服务端覆盖
  Agent 登录成功和几种失败
- UI:侧栏切换、新建 / 编辑 / 删除凭据、类型决定字段、主机使用凭据、
  测试连接、终端拿到的登录、改用户名重连;新建主机的校验测试改为等待
  错误行出现(对话框滑入时它在最底下一行)

文档:README 新增「凭据」一节;CLAUDE.md 同步架构和不变量,并更正
转发对话框「选完主机移走焦点」的描述(代码里从未有这个处理)
2026-10-01 07:45:58 +08:00

151 lines
4.7 KiB
Rust

//! The `shellrs-cli` program run as an agent runs it: a separate process
//! with piped stdio, finding the app through `SHELLRS_DATA_DIR`. A fake
//! backend stands in for SSH, so this runs on every platform, including the
//! Windows CI where it is the one test of the named pipe between processes.
use std::{
io::{self, Write as _},
path::Path,
process::{Command, Output, Stdio},
sync::{Arc, Mutex},
};
use shellrs::app::cli_endpoint;
use shellrs::cli::{
CliBackend, CliError, CliServer, CliTarget, SessionInfo, TransferCounters, TransferSummary,
};
use shellrs::session::{AuthKind, Session, SessionDraft, SessionId, SessionLogin};
use shellrs::ssh::ExecStream;
/// More than a pipe's buffer, so the app finishes writing before the
/// command has read it all.
const LARGE_OUTPUT: usize = 256 * 1024;
#[derive(Default)]
struct FakeBackend {
commands: Mutex<Vec<String>>,
}
impl CliBackend for FakeBackend {
fn exec(
&self,
_: &CliTarget,
command: &str,
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
) -> Result<i32, CliError> {
self.commands.lock().unwrap().push(command.to_string());
if command == "large" {
let line = [b'x'; 1023]
.iter()
.chain(b"\n")
.copied()
.collect::<Vec<_>>();
for _ in 0..LARGE_OUTPUT / line.len() {
output(ExecStream::Stdout, &line).unwrap();
}
return Ok(0);
}
output(ExecStream::Stdout, "输出\n".as_bytes()).unwrap();
output(ExecStream::Stderr, b"warning\n").unwrap();
Ok(7)
}
fn upload(
&self,
_: &CliTarget,
_: &Path,
_: &str,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
Ok(TransferSummary::default())
}
fn download(
&self,
_: &CliTarget,
_: &str,
_: &Path,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
Ok(TransferSummary::default())
}
}
/// Run the command against the app whose data lives in `data_dir`.
fn shellrs(data_dir: &Path, args: &[&str], stdin: &[u8]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_shellrs-cli"))
.args(args)
.env("SHELLRS_DATA_DIR", data_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stdin).unwrap();
child.wait_with_output().unwrap()
}
#[test]
fn without_the_app_the_command_says_so_and_exits_255() {
let data_dir = tempfile::tempdir().unwrap();
let output = shellrs(data_dir.path(), &["list"], b"");
assert_eq!(output.status.code(), Some(255));
assert!(
String::from_utf8(output.stderr)
.unwrap()
.starts_with("shellrs: [not_running]")
);
}
#[test]
fn the_command_finds_the_app_through_its_data_directory() {
let data_dir = tempfile::tempdir().unwrap();
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(cli_endpoint(data_dir.path()), backend.clone()).unwrap();
let web = Session::new(
SessionId(1),
SessionDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Auto, None),
);
server.set_targets(vec![CliTarget::new(
&web,
SessionLogin::of(&web, None),
None,
)]);
server.set_enabled(true);
let id = web.public_id.to_string();
// A pipe for stdout means JSON.
let output = shellrs(data_dir.path(), &["list"], b"");
assert_eq!(output.status.code(), Some(0));
let sessions: Vec<SessionInfo> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(sessions[0].id, id);
// What PowerShell pipes in: a BOM and CRLF line ends.
let output = shellrs(
data_dir.path(),
&["exec", &id, "--stdin"],
"\u{feff}cd /srv\r\nls\r\n".as_bytes(),
);
assert_eq!(output.status.code(), Some(7));
assert_eq!(output.stdout, "输出\n".as_bytes());
assert_eq!(output.stderr, b"warning\n");
assert_eq!(
backend.commands.lock().unwrap().last().unwrap(),
"cd /srv\nls\n"
);
// Everything arrives, though the app is done long before.
let output = shellrs(data_dir.path(), &["exec", &id, "large"], b"");
assert_eq!(output.status.code(), Some(0));
assert_eq!(output.stdout.len(), LARGE_OUTPUT);
server.set_enabled(false);
let output = shellrs(data_dir.path(), &["exec", &id, "true"], b"");
assert_eq!(output.status.code(), Some(255));
assert!(
String::from_utf8(output.stderr)
.unwrap()
.starts_with("shellrs: [not_enabled]")
);
}