crates\nfarms-core\src\remote.rs¶
源码路径:
crates\nfarms-core\src\remote.rs· 762 行
//! SecureCRT / WinSCP 集成 —— Zero Trust VPN 下的两个「深度集成」页。
//!
//! **什么叫深度集成**:不是放一个「打开 SecureCRT」的按钮就完事,而是
//! 真去读它们的会话配置 —— SecureCRT 读 `%APPDATA%\VanDyke\Config\Sessions\*.ini`,
//! WinSCP 读注册表 `HKCU\Software\Martin Prikryl\WinSCP 2\Sessions` ——
//! 把会话列出来,点一下就用**它自己的会话名**拉起它,认证仍由它自己处理。
//!
//! 两条刻意的边界:
//!
//! 1. **不碰密码**。SecureCRT 的密码是它自己加密存的,WinSCP 存的是
//! 可逆编码但也不该由第三方程式解开。要连就让它自己去连。
//! 2. **只读不写**。这里不修改两个工具的配置 —— 往别人的配置文件里写东西,
//! 出问题最难查。
//!
//! 读注册表**没有引 winreg**:走 `reg.exe query` 拿文本再解析,
//! 零依赖,而且和 `tools::capture` 是同一套「跑子进程读输出」的经验。
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use crate::tools;
/// 一个远程会话(两个工具的会话统一成这一个形状)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
/// 会话名(可能在子目录里,用 `/` 分隔)
pub name: String,
pub host: String,
pub port: u16,
pub user: String,
pub protocol: String,
/// 来自哪个工具:securecrt / winscp
pub source: &'static str,
}
impl Session {
/// `user@host:port (SSH2)` 这种一行摘要。
pub fn summary(&self) -> String {
let mut s = String::new();
if !self.user.is_empty() {
s.push_str(&self.user);
s.push('@');
}
s.push_str(&self.host);
if self.port != 0 {
s.push_str(&format!(":{}", self.port));
}
if !self.protocol.is_empty() {
s.push_str(&format!(" ({})", self.protocol));
}
s
}
}
// ---------------------------------------------------------------- 定位
/// SecureCRT 的安装位置候选。
///
/// 只看 `Program Files` 会漏掉装在别处的机器 —— 和 `find_winscp` 一样补一段
/// 注册表兜底。注册表查询做成参数注入,唯一理由是单测:真注册表在没装
/// SecureCRT 的机器上查不出东西,「到底有没有去问注册表」只有换成假查询才测得出来。
fn securecrt_candidates_with(query: &dyn Fn(&str, &str) -> Option<String>) -> Vec<PathBuf> {
let mut cands: Vec<PathBuf> = Vec::new();
for base in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
if let Ok(p) = std::env::var(base) {
cands.push(PathBuf::from(p).join(r"VanDyke Software\SecureCRT\SecureCRT.exe"));
}
}
cands.push(PathBuf::from(r"C:\Program Files\VanDyke Software\SecureCRT\SecureCRT.exe"));
cands.push(PathBuf::from(
r"C:\Program Files (x86)\VanDyke Software\SecureCRT\SecureCRT.exe",
));
// 注册表兜底:SecureCRT 的 Windows 安装包是 MSI(见 VanDyke 的 silent install 说明),
// 值名在不同版本 / 打包方式下不一定一致,所以多列几个来源;查不到只是拿到 None,
// 不会比「只扫 Program Files」更差。
for (key, value) in [
(r"HKLM\SOFTWARE\VanDyke Software\SecureCRT", "InstallDir"),
(
r"HKLM\SOFTWARE\WOW6432Node\VanDyke Software\SecureCRT",
"InstallDir",
),
(
r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\SecureCRT",
"InstallLocation",
),
(
r"HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\SecureCRT",
"InstallLocation",
),
] {
if let Some(v) = query(key, value) {
if !v.trim().is_empty() {
cands.push(securecrt_exe_from(&v));
}
}
}
cands
}
/// 注册表里的值可能是安装目录(再拼 `SecureCRT.exe`),也可能直接是 exe 全路径。
///
/// 引号在这里剥掉:注册表里的路径常写成 `"C:\Program Files\..."`,引号留着会拼出
/// 一个永远不存在的路径,`is_file()` 也就永远是假。剥引号放在这一层而不是
/// `reg_query_value` 里,是为了让单测注入的假查询也走同一条规则。
fn securecrt_exe_from(value: &str) -> PathBuf {
let p = PathBuf::from(value.trim().trim_matches('"'));
let is_exe = p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("exe"))
== Some(true);
if is_exe { p } else { p.join("SecureCRT.exe") }
}
/// SecureCRT 的安装路径。
///
/// **结果缓存**:界面每帧都会经 `securecrt_session_dir` 问一次会话目录,而定位过程
/// 含若干次注册表查询(每次都是一个 `reg.exe` 子进程)。不缓存的话,没装 SecureCRT
/// 的机器上会退化成每帧好几个子进程。一个进程存活期间安装位置不会变,所以只探一次。
pub fn find_securecrt() -> Option<PathBuf> {
static CACHED: OnceLock<Option<PathBuf>> = OnceLock::new();
CACHED
.get_or_init(|| {
securecrt_candidates_with(®_query_value)
.into_iter()
.find(|p| p.is_file())
.or_else(|| find_on_path("SecureCRT.exe"))
})
.clone()
}
/// WinSCP 的安装位置 —— 除了常见目录,还要看注册表里的 `InstallLocation`
/// (WinSCP 允许装到任意目录,只扫 Program Files 会漏)。
pub fn find_winscp() -> Option<PathBuf> {
for base in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
if let Ok(p) = std::env::var(base) {
let c = PathBuf::from(p).join(r"WinSCP\WinSCP.exe");
if c.is_file() {
return Some(c);
}
}
}
// 注册表兜底:HKLM\SOFTWARE\Martin Prikryl\WinSCP 2 的 InstallLocation
for key in [
r"HKLM\SOFTWARE\Martin Prikryl\WinSCP 2",
r"HKLM\SOFTWARE\WOW6432Node\Martin Prikryl\WinSCP 2",
] {
if let Some(dir) = reg_query_value(key, "InstallLocation") {
let c = PathBuf::from(dir).join("WinSCP.exe");
if c.is_file() {
return Some(c);
}
}
}
find_on_path("WinSCP.exe")
}
// ---------------------------------------------------------------- 会话
/// SecureCRT 的会话目录。
///
/// 两个候选(按优先级):
/// 1. `%APPDATA%\VanDyke\Config\Sessions` —— 常规安装;
/// 2. `SecureCRT.exe` 同级的 `Config\Sessions` —— **便携模式**。便携版把整个
/// `Config\` 放在程序目录下,只认 APPDATA 的写法在那种机器上一个会话都读不到,
/// 而界面上只会显示「没有读到会话」,用户根本看不出是路径找错了。
pub fn securecrt_session_dir() -> Option<PathBuf> {
securecrt_session_dir_candidates(std::env::var("APPDATA").ok(), find_securecrt())
.into_iter()
.find(|d| d.is_dir())
}
/// 会话目录候选,顺序即优先级。
///
/// 参数化(AppData / exe 都由调用方给)是为了能测:便携模式那条规则要在
/// 没有 APPDATA、也没装 SecureCRT 的环境里也验证得了。
fn securecrt_session_dir_candidates(
appdata: Option<String>,
exe: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(appdata) = appdata {
out.push(PathBuf::from(appdata).join(r"VanDyke\Config\Sessions"));
}
if let Some(dir) = exe.as_deref().and_then(Path::parent) {
out.push(dir.join(r"Config\Sessions"));
}
out
}
/// 读 SecureCRT 的全部会话。
///
/// `.ini` 里是 `S:"Hostname"=x` / `D:"[SSH2] Port"=00000016` 这种形状,
/// **端口是十六进制的 DWORD** —— 当成十进制读会得到 16 而不是 22,
/// 这是个很容易一路错下去的坑。
pub fn securecrt_sessions() -> Vec<Session> {
let Some(dir) = securecrt_session_dir() else {
return Vec::new();
};
let mut out = Vec::new();
walk_ini(&dir, &dir, &mut out, 0);
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// `walk_ini` 的最大递归深度。
///
/// 6 是防**病态目录结构**的上限,不是 SecureCRT 的约定:真实会话目录只在
/// `Sessions\` 下按文件夹分组,几层就到头;但 `Sessions` 可能被用户指到一个
/// 更深的目录(甚至是 junction 造成的环),没有上限就会一路递归下去。
/// 第 6 层仍会被遍历,第 7 层起静默丢弃。
const WALK_MAX_DEPTH: usize = 6;
fn walk_ini(root: &Path, dir: &Path, out: &mut Vec<Session>, depth: usize) {
if depth > WALK_MAX_DEPTH {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk_ini(root, &p, out, depth + 1);
continue;
}
if p.extension().and_then(|x| x.to_str()).map(|x| x.eq_ignore_ascii_case("ini")) != Some(true) {
continue;
}
// 会话名 = 相对 Sessions\ 的路径去掉 .ini,分隔符统一成 /
let name = p
.strip_prefix(root)
.unwrap_or(&p)
.with_extension("")
.to_string_lossy()
.replace('\\', "/");
// 这里用 read + from_utf8_lossy,不用 read_to_string:老会话文件可能是
// ANSI/GBK 编码,read_to_string 会整份 Err,一个 `continue` 就让这个会话
// 从列表里消失 —— 调用方只看到「少了一个」,分不清是文件不存在还是解码失败。
// 有损解码至少保住会话名(它来自文件名,和文件编码无关)以及 ASCII 的
// host / user / 端口,乱码只可能落在非 ASCII 字段上。
let Ok(bytes) = std::fs::read(&p) else {
continue;
};
let text = String::from_utf8_lossy(&bytes);
out.push(parse_securecrt_ini(&name, &text));
}
}
/// 解析一个 SecureCRT 会话 ini。
pub fn parse_securecrt_ini(name: &str, text: &str) -> Session {
let mut host = String::new();
let mut user = String::new();
let mut port: u16 = 0;
let mut protocol = String::new();
for line in text.lines() {
let l = line.trim();
let Some((k, v)) = l.split_once('=') else {
continue;
};
let k = k.trim();
let v = v.trim().trim_matches('"');
match k {
r#"S:"Hostname""# => host = v.to_string(),
r#"S:"Username""# => user = v.to_string(),
r#"S:"Protocol Name""# => protocol = v.to_string(),
// D: 打头的是十六进制 DWORD
_ if k.starts_with("D:") && k.contains("Port") => {
// 只收 u16 范围。越界时**不钳到 65535**:把一个明显非法的值变成
// 「看着合法」的端口,比不显示端口更糟 —— 真拿它去连就是连错地方。
// 保持 0(等于「没写端口」):summary() 会略过它,拉新连接时也不会带 /P。
if let Ok(n) = u32::from_str_radix(v.trim_start_matches("0x"), 16) {
port = u16::try_from(n).unwrap_or(0);
}
}
_ => {}
}
}
Session {
name: name.to_string(),
host,
port,
user,
protocol,
source: "securecrt",
}
}
/// 读 WinSCP 的全部已存会话(注册表)。
pub fn winscp_sessions() -> Vec<Session> {
let root = r"HKCU\Software\Martin Prikryl\WinSCP 2\Sessions";
let text = reg_query(root);
let mut out = parse_winscp_reg(&text);
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// 解析 `reg query ... /s` 的输出。
///
/// 形状是「一行键名 + 若干行值」:
/// ```text
/// HKEY_CURRENT_USER\...\Sessions\myserver
/// HostName REG_SZ example.com
/// PortNumber REG_DWORD 0x16
/// ```
pub fn parse_winscp_reg(text: &str) -> Vec<Session> {
let mut out: Vec<Session> = Vec::new();
let prefix = r"HKEY_CURRENT_USER\Software\Martin Prikryl\WinSCP 2\Sessions";
for line in text.lines() {
let l = line.trim_end();
if l.starts_with("HKEY_") {
// 只认当前根键自己以及它下面的子键。以前是拿 prefix.len() 硬切,
// **不是**这个键的行不会被跳过,而是被切出中间一段当成会话名
// (`.min(l.len())` 只防 panic,不防切错)。
// 光用 strip_prefix 还不够:`…\SessionsOther` 也以 `…\Sessions` 开头,
// 所以还得要求前缀后面紧跟 `\`(子键)或直接结束(根键自己)。
// 大小写不敏感:reg query 回显的键名大小写不一定和请求一致,
// 但会话名本身要原样保留。
let Some(rest) = strip_prefix_ascii_case(l, prefix) else {
continue;
};
let name = match rest.strip_prefix('\\') {
Some(sub) => sub.to_string(),
// `rest` 为空 = 根键自己那一行;`rest` 不以 `\` 开头 =
// 同前缀的兄弟键。两者都不是会话,整行跳过。
None => continue,
};
if name.is_empty() {
continue;
}
out.push(Session {
name,
host: String::new(),
port: 0,
user: String::new(),
protocol: String::new(),
source: "winscp",
});
continue;
}
let Some(cur) = out.last_mut() else {
continue;
};
let cols: Vec<&str> = l.split_whitespace().collect();
if cols.len() < 3 {
continue;
}
let (key, kind) = (cols[0], cols[1]);
// 值本身可能带空格,所以**不能**按空白切列 ——
// `splitn(3, is_whitespace)` 是按单个空白字符切,行首那几个空格
// 会把列号整个打乱。正确地做法是定位类型标记,取它后面的全部。
let value = match l.find(kind) {
Some(p) => l[p + kind.len()..].trim(),
None => "",
};
match key {
"HostName" => cur.host = value.to_string(),
"UserName" => cur.user = value.to_string(),
"PortNumber" => {
let v = value.trim_start_matches("0x");
// 同 parse_securecrt_ini:越界的端口当成「没写」,不要截断成一个合法端口
cur.port = u32::from_str_radix(v, 16)
.ok()
.and_then(|n| u16::try_from(n).ok())
.unwrap_or(0);
}
"FSProtocol" => {
let v = value.trim_start_matches("0x");
// 解析失败时留空,不默认成 SFTP:界面上那个协议名是给人判断
// 「这条会话是怎么连的」用的,假装成 SFTP 会让人以为连接方式没问题。
// 空串在 summary() 里直接不显示。
cur.protocol = match u32::from_str_radix(v, 16) {
Ok(n) => fs_protocol_name(n),
Err(_) => String::new(),
};
}
_ => {}
}
}
out
}
/// WinSCP 的 `FSProtocol` 数值 → 名字。
///
/// 未知数值兜底成 `协议9(未知)` 这种可辨认的文本,而不是 "SFTP":兜底成最常用的
/// 那个协议,等于把一个不认识的连接方式伪装成正常值,用户没有任何线索发现异常。
fn fs_protocol_name(n: u32) -> String {
match n {
0 => "SCP".to_string(),
1 => "SFTP".to_string(),
2 => "FTP".to_string(),
3 => "WebDAV".to_string(),
4 => "S3".to_string(),
_ => format!("协议{n}(未知)"),
}
}
/// 按 ASCII 大小写不敏感地剥掉前缀;剥不掉返回 `None`(不会 panic:
/// `get(..n)` 在 `n` 不是字符边界或超出长度时就是 `None`)。
fn strip_prefix_ascii_case<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let head = s.get(..prefix.len())?;
if head.eq_ignore_ascii_case(prefix) {
s.get(prefix.len()..)
} else {
None
}
}
// ---------------------------------------------------------------- 拉起
/// 用 SecureCRT 打开一个已存会话(`/S "<名字>"`)。
///
/// 用会话名而不是自己拼 `host:port`:会话里可能还带着隧道、跳板、
/// 端口转发、字符编码这些设置,自己拼 host 会全丢掉。
pub fn launch_securecrt(exe: &Path, session: &Session) -> Result<(), String> {
let name = session.name.replace('/', "\\"); // SecureCRT 用反斜杠表示子目录
Command::new(exe)
.arg("/S")
.arg(&name)
.spawn()
.map_err(|e| format!("启动 SecureCRT 失败: {e}"))?;
Ok(())
}
/// 用 SecureCRT 直接开一个新连接(没有存会话时用)。
pub fn launch_securecrt_host(
exe: &Path,
host: &str,
user: &str,
port: u16,
protocol: &str,
) -> Result<(), String> {
let mut c = Command::new(exe);
match protocol.to_ascii_uppercase().as_str() {
"TELNET" => {
c.arg("/TELNET");
}
"SSH1" => {
c.arg("/SSH1");
}
_ => {
c.arg("/SSH2");
}
}
if !user.is_empty() {
c.arg("/L").arg(user);
}
if port != 0 {
c.arg("/P").arg(port.to_string());
}
c.arg(host)
.spawn()
.map_err(|e| format!("启动 SecureCRT 失败: {e}"))?;
Ok(())
}
/// 用 WinSCP 打开一个已存会话(直接把会话名当参数)。
pub fn launch_winscp(exe: &Path, session: &Session) -> Result<(), String> {
Command::new(exe)
.arg(&session.name)
.spawn()
.map_err(|e| format!("启动 WinSCP 失败: {e}"))?;
Ok(())
}
/// 用 WinSCP 直接开一个新连接(URL 形式;密码仍由它自己问)。
pub fn launch_winscp_url(
exe: &Path,
host: &str,
user: &str,
port: u16,
protocol: &str,
) -> Result<(), String> {
let scheme = match protocol.to_ascii_uppercase().as_str() {
"SCP" => "scp",
"FTP" => "ftp",
"WEBDAV" => "dav",
_ => "sftp",
};
let mut url = format!("{scheme}://");
if !user.is_empty() {
url.push_str(user);
url.push('@');
}
url.push_str(host);
if port != 0 {
url.push_str(&format!(":{port}"));
}
url.push('/');
Command::new(exe)
.arg(url)
.spawn()
.map_err(|e| format!("启动 WinSCP 失败: {e}"))?;
Ok(())
}
// ---------------------------------------------------------------- 注册表
/// 跑 `reg query <key> /s` 并把输出拿回来。读不到就返回空串(键不存在是常态)。
fn reg_query(key: &str) -> String {
tools::capture(&format!("reg query \"{key}\" /s"), 20_000)
}
/// 取某个键下的一个 REG_SZ 值。
fn reg_query_value(key: &str, value_name: &str) -> Option<String> {
let text = tools::capture(&format!("reg query \"{key}\" /v {value_name}"), 20_000);
for line in text.lines() {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 3 && cols[0].eq_ignore_ascii_case(value_name) {
// 同 parse_winscp_reg:按类型标记定位值,别按空白切列
let kind = cols[1];
return line
.find(kind)
// 注册表里的路径常被写成带引号的形式(`"C:\Program Files\..."`),
// 引号留着就会拼出一个不存在的路径,直接 is_file() 永远为假。
.map(|p| line[p + kind.len()..].trim().trim_matches('"').to_string());
}
}
None
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.filter(|d| !d.as_os_str().is_empty())
.map(|d| d.join(name))
.find(|c| c.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn securecrt_port_is_hex_not_decimal() {
// 22 端口在 SecureCRT 里写作 00000016(十六进制)——
// 当成十进制读会得到 16,连接就会打到错误端口上
let text = "S:\"Protocol Name\"=SSH2\nS:\"Hostname\"=10.0.0.1\nS:\"Username\"=root\nD:\"[SSH2] Port\"=00000016\n";
let s = parse_securecrt_ini("prod/db", text);
assert_eq!(s.host, "10.0.0.1");
assert_eq!(s.user, "root");
assert_eq!(s.protocol, "SSH2");
assert_eq!(s.port, 22, "00000016 是十六进制");
assert_eq!(s.name, "prod/db");
assert_eq!(s.summary(), "root@10.0.0.1:22 (SSH2)");
}
#[test]
fn securecrt_missing_fields_do_not_panic() {
let s = parse_securecrt_ini("empty", "");
assert_eq!(s.host, "");
assert_eq!(s.port, 0);
assert_eq!(s.summary(), "");
}
#[test]
fn winscp_registry_output_is_parsed() {
let text = "\
HKEY_CURRENT_USER\\Software\\Martin Prikryl\\WinSCP 2\\Sessions\\myserver\r
HostName REG_SZ example.com\r
UserName REG_SZ deploy\r
PortNumber REG_DWORD 0x16\r
FSProtocol REG_DWORD 0x1\r
\r
HKEY_CURRENT_USER\\Software\\Martin Prikryl\\WinSCP 2\\Sessions\\legacy\r
HostName REG_SZ old.example.com\r
FSProtocol REG_DWORD 0x2\r
";
let got = parse_winscp_reg(text);
assert_eq!(got.len(), 2);
assert_eq!(got[0].name, "myserver");
assert_eq!(got[0].host, "example.com");
assert_eq!(got[0].user, "deploy");
assert_eq!(got[0].port, 22);
assert_eq!(got[0].protocol, "SFTP");
assert_eq!(got[1].name, "legacy");
assert_eq!(got[1].protocol, "FTP");
}
#[test]
fn winscp_empty_output_is_empty_list() {
assert!(parse_winscp_reg("").is_empty());
assert!(parse_winscp_reg("ERROR: The system was unable to find the specified registry key").is_empty());
}
#[test]
fn missing_tools_report_none_instead_of_panicking() {
// 这两个函数在没装的机器上必须安全返回 None
let _ = find_securecrt();
let _ = find_winscp();
let _ = securecrt_session_dir();
}
#[test]
fn securecrt_port_out_of_u16_range_is_not_truncated() {
// 00010001 = 65537:裸 `as u16` 会截成 1,连接就会打到 1 端口上
let s = parse_securecrt_ini("big", "D:\"[SSH2] Port\"=00010001\n");
assert_eq!(s.port, 0, "越界端口当成「没写」,不能截断成一个合法端口");
}
#[test]
fn winscp_port_out_of_u16_range_is_not_truncated() {
let text = "\
HKEY_CURRENT_USER\\Software\\Martin Prikryl\\WinSCP 2\\Sessions\\big\r
PortNumber REG_DWORD 0x10001\r
";
let got = parse_winscp_reg(text);
assert_eq!(got.len(), 1);
assert_eq!(got[0].port, 0, "0x10001 = 65537 截成 1 就是打到错误端口");
}
#[test]
fn winscp_unknown_fsprotocol_is_not_reported_as_sftp() {
// 以前 `unwrap_or(1)` + `_ => "SFTP"` 把不认识的值一律显示成 SFTP
let text = "\
HKEY_CURRENT_USER\\Software\\Martin Prikryl\\WinSCP 2\\Sessions\\weird\r
FSProtocol REG_DWORD 0x9\r
HKEY_CURRENT_USER\\Software\\Martin Prikryl\\WinSCP 2\\Sessions\\broken\r
FSProtocol REG_SZ not-a-number\r
";
let got = parse_winscp_reg(text);
assert_eq!(got.len(), 2);
assert_ne!(got[0].protocol, "SFTP", "未知协议不能伪装成 SFTP");
assert!(got[0].protocol.contains('9'), "至少要把原始数值透出来");
assert_eq!(got[1].protocol, "", "解析不出数值时留空,也不要默认成 SFTP");
}
#[test]
fn winscp_root_key_line_is_skipped_and_values_may_contain_spaces() {
let text = "\
HKEY_CURRENT_USER\\Software\\Martin Prikryl\\WinSCP 2\\Sessions\r
HKEY_CURRENT_USER\\Software\\Martin Prikryl\\WinSCP 2\\Sessions\\myserver\r
HostName REG_SZ host with spaces.example.com\r
";
let got = parse_winscp_reg(text);
assert_eq!(got.len(), 1, "根键自己那一行不是会话");
assert_eq!(got[0].name, "myserver");
assert_eq!(got[0].host, "host with spaces.example.com");
}
#[test]
fn winscp_line_from_another_key_is_skipped_not_sliced() {
// 前缀长度硬切的写法会把 "Other\oops" 切出来当会话名
let text = "\
HKEY_CURRENT_USER\\Software\\Martin Prikryl\\WinSCP 2\\SessionsOther\\oops\r
HostName REG_SZ nope.example.com\r
";
assert!(
parse_winscp_reg(text).is_empty(),
"不是 Sessions\\ 前缀下的键名必须整行跳过"
);
}
#[test]
fn winscp_key_path_case_difference_still_parses() {
// reg query 回显的大小写不一定和请求一致;会话名要保留原样
let text = "\
HKEY_CURRENT_USER\\SOFTWARE\\MARTIN PRIKRYL\\WINSCP 2\\SESSIONS\\Prod\\Web\r
HostName REG_SZ case.example.com\r
";
let got = parse_winscp_reg(text);
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, r"Prod\Web", "会话名保留注册表里的原样");
assert_eq!(got[0].host, "case.example.com");
}
#[test]
fn portable_config_dir_is_a_session_dir_candidate() {
let got = securecrt_session_dir_candidates(
Some(r"C:\Users\u\AppData\Roaming".to_string()),
Some(PathBuf::from(r"C:\Tools\SecureCRT\SecureCRT.exe")),
);
assert_eq!(
got,
vec![
PathBuf::from(r"C:\Users\u\AppData\Roaming\VanDyke\Config\Sessions"),
PathBuf::from(r"C:\Tools\SecureCRT\Config\Sessions"),
],
"便携模式:exe 同级的 Config\\Sessions 必须是第二个候选"
);
}
#[test]
fn portable_config_dir_works_without_appdata() {
let got = securecrt_session_dir_candidates(None, Some(PathBuf::from(r"D:\portable\SecureCRT.exe")));
assert_eq!(got, vec![PathBuf::from(r"D:\portable\Config\Sessions")]);
}
#[test]
fn securecrt_registry_install_dir_is_a_candidate() {
// 只扫 Program Files 会漏掉装在别处的机器;用假查询代替真注册表
let fake = |_key: &str, value: &str| -> Option<String> {
(value == "InstallDir").then(|| r"D:\Apps\SecureCRT".to_string())
};
let cands = securecrt_candidates_with(&fake);
assert!(
cands.contains(&PathBuf::from(r"D:\Apps\SecureCRT\SecureCRT.exe")),
"注册表里的安装目录必须成为候选:{cands:?}"
);
}
#[test]
fn securecrt_registry_quoted_exe_path_is_used_as_is() {
// 有的键直接存 exe 全路径(还可能带引号),不能再拼一次 SecureCRT.exe
let fake = |_key: &str, value: &str| -> Option<String> {
(value == "InstallDir")
.then(|| r#""D:\Apps\SecureCRT\SecureCRT.exe""#.to_string())
};
let cands = securecrt_candidates_with(&fake);
assert!(cands.contains(&PathBuf::from(r"D:\Apps\SecureCRT\SecureCRT.exe")));
assert!(!cands.contains(&PathBuf::from(r"D:\Apps\SecureCRT\SecureCRT.exe\SecureCRT.exe")));
}
#[test]
fn non_utf8_ini_still_lists_the_session() {
// 老会话文件可能是 ANSI/GBK 编码。read_to_string 会整份失败,
// 一个 `continue` 就让这个会话从列表里消失。
let root = std::env::temp_dir().join(format!("nfarms-remote-enc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("sub");
std::fs::create_dir_all(&dir).expect("建临时目录");
let mut bytes = b"S:\"Hostname\"=10.0.0.9\r\nS:\"Note\"=".to_vec();
bytes.extend_from_slice(&[0xD6, 0xD0, 0xCE, 0xC4]); // GBK「中文」,不是合法 UTF-8
std::fs::write(dir.join("prod.ini"), &bytes).expect("写 ini");
let mut out = Vec::new();
walk_ini(&root, &root, &mut out, 0);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(out.len(), 1, "非 UTF-8 的 ini 不能被静默丢掉");
assert_eq!(out[0].name, "sub/prod");
assert_eq!(out[0].host, "10.0.0.9", "ASCII 字段仍然要读出来");
}
#[test]
fn walk_ini_stops_at_depth_limit() {
// 上限是 WALK_MAX_DEPTH:第 6 层仍遍历,第 7 层起丢弃
let root = std::env::temp_dir().join(format!("nfarms-remote-depth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut at_limit = root.clone();
for _ in 0..WALK_MAX_DEPTH {
at_limit = at_limit.join("d");
}
let too_deep = at_limit.join("d");
std::fs::create_dir_all(&too_deep).expect("建深层目录");
std::fs::write(at_limit.join("ok.ini"), "S:\"Hostname\"=ok\r\n").expect("写 ini");
std::fs::write(too_deep.join("deep.ini"), "S:\"Hostname\"=deep\r\n").expect("写 ini");
let mut out = Vec::new();
walk_ini(&root, &root, &mut out, 0);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(out.len(), 1, "只应读到第 {WALK_MAX_DEPTH} 层那一份");
assert_eq!(out[0].host, "ok");
}
}