pty_mcp/ssh/
capability_probe.rs1use 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 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}