跳转至

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(&reg_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");
    }
}