Skip to main content

pty_mcp/ssh/
capability_probe.rs

1use std::{
2    path::{Path, PathBuf},
3    process::Command,
4};
5
6use crate::config::{SshConfig, SshResolvedBinPaths};
7
8use super::model::{MacFuseCapability, SshBinaryCapability, SshCapabilityView};
9
10#[derive(Debug, Default, Clone)]
11pub struct SshCapabilityProbe;
12
13impl SshCapabilityProbe {
14    pub fn new() -> Self {
15        Self
16    }
17
18    pub fn probe(&self, config: &SshConfig) -> SshCapabilityView {
19        let paths = config.resolved_bin_paths();
20        let ssh = probe_binary(paths.ssh.as_deref(), &["-V"]);
21        let sshfs = probe_binary(paths.sshfs.as_deref(), &["--version", "-V"]);
22        let unmount = probe_binary(paths.umount.as_deref(), &["--version", "-V"]);
23
24        let diskutil = probe_diskutil(&paths);
25        let macfuse = detect_macfuse(&sshfs);
26
27        SshCapabilityView {
28            platform: std::env::consts::OS.to_string(),
29            ssh,
30            sshfs,
31            unmount,
32            diskutil,
33            macfuse,
34        }
35    }
36
37    pub fn probe_with_config(&self, config: &SshConfig) -> SshCapabilityView {
38        self.probe(config)
39    }
40}
41
42fn probe_diskutil(paths: &SshResolvedBinPaths) -> Option<SshBinaryCapability> {
43    if !cfg!(target_os = "macos") {
44        return None;
45    }
46
47    paths
48        .diskutil
49        .as_deref()
50        .map(|path| probe_binary(Some(path), &["-version", "version"]))
51}
52
53fn probe_binary(path: Option<&Path>, version_args: &[&str]) -> SshBinaryCapability {
54    let Some(path) = path else {
55        return SshBinaryCapability::default();
56    };
57
58    let mut capability = SshBinaryCapability {
59        available: false,
60        path: Some(path.display().to_string()),
61        version: None,
62    };
63
64    if !path.is_file() {
65        return capability;
66    }
67
68    capability.available = true;
69    capability.version = version_args.iter().find_map(|arg| {
70        let output = Command::new(path).arg(arg).output().ok()?;
71        if !output.status.success() {
72            return None;
73        }
74        parse_version(&output.stdout, &output.stderr)
75    });
76    capability
77}
78
79fn parse_version(stdout: &[u8], stderr: &[u8]) -> Option<String> {
80    first_non_empty_line(stdout).or_else(|| first_non_empty_line(stderr))
81}
82
83fn first_non_empty_line(bytes: &[u8]) -> Option<String> {
84    let text = String::from_utf8_lossy(bytes);
85    text.lines()
86        .map(str::trim)
87        .find(|line| !line.is_empty())
88        .map(|line| line.chars().take(256).collect::<String>())
89}
90
91fn detect_macfuse(sshfs: &SshBinaryCapability) -> Option<MacFuseCapability> {
92    if !cfg!(target_os = "macos") {
93        return None;
94    }
95
96    let mut capability = MacFuseCapability {
97        available: false,
98        provider: None,
99        version: None,
100    };
101
102    if let Some(sshfs_version) = sshfs.version.as_deref() {
103        let lower = sshfs_version.to_ascii_lowercase();
104        if lower.contains("macfuse") {
105            capability.available = true;
106            capability.provider = Some("macFUSE".to_string());
107            capability.version = extract_provider_version(sshfs_version, "macfuse");
108            return Some(capability);
109        }
110        if lower.contains("osxfuse") {
111            capability.available = true;
112            capability.provider = Some("osxfuse".to_string());
113            capability.version = extract_provider_version(sshfs_version, "osxfuse");
114            return Some(capability);
115        }
116    }
117
118    // Best-effort fallback when sshfs version output does not expose provider details.
119    let known_provider_paths = [
120        "/Library/Filesystems/macfuse.fs",
121        "/Library/Filesystems/osxfuse.fs",
122    ];
123    if known_provider_paths
124        .iter()
125        .map(PathBuf::from)
126        .any(|path| path.exists())
127    {
128        capability.available = true;
129        capability.provider = Some("macFUSE".to_string());
130    }
131
132    Some(capability)
133}
134
135fn extract_provider_version(raw: &str, keyword: &str) -> Option<String> {
136    let lower = raw.to_ascii_lowercase();
137    let position = lower.find(keyword)?;
138    let suffix = &raw[position + keyword.len()..];
139    let token = suffix
140        .trim_matches(|c: char| c.is_whitespace() || c == ':' || c == '-' || c == '/')
141        .split_whitespace()
142        .next()
143        .unwrap_or_default()
144        .trim_matches(|c: char| c == '(' || c == ')' || c == ',');
145
146    if token.is_empty() {
147        None
148    } else {
149        Some(token.to_string())
150    }
151}