1use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18
19use crate::origin::guard;
20
21const MAX_INCLUDE_DEPTH: usize = 16;
23
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
26pub struct Host {
27 pub host: String,
32 pub alias: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
42pub struct Unusable {
43 pub host: String,
44 pub why: String,
45}
46
47#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
49pub struct Found {
50 pub hosts: Vec<Host>,
51 pub unusable: Vec<Unusable>,
52}
53
54pub fn default_path() -> Option<PathBuf> {
56 let home = std::env::var_os("HOME")
57 .or_else(|| std::env::var_os("USERPROFILE"))
58 .filter(|h| !h.is_empty())?;
59 Some(PathBuf::from(home).join(".ssh").join("config"))
60}
61
62pub fn read() -> Result<Found> {
67 let Some(path) = default_path() else {
68 return Ok(Found::default());
69 };
70 read_from(&path)
71}
72
73pub fn read_from(path: &Path) -> Result<Found> {
75 if !path.exists() {
76 return Ok(Found::default());
77 }
78 let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
82 let mut text = String::new();
83 gather(path, &root, 0, &mut text)?;
84 Ok(parse(&text))
85}
86
87fn gather(path: &Path, root: &Path, depth: usize, out: &mut String) -> Result<()> {
89 if depth > MAX_INCLUDE_DEPTH {
90 anyhow::bail!(
94 "ssh_config includes nest more than {MAX_INCLUDE_DEPTH} deep at {}",
95 path.display()
96 );
97 }
98 let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
99 for line in text.lines() {
100 match include_target(line) {
101 Some(pattern) => {
102 for file in expand(pattern, root) {
103 if file.is_file() {
107 gather(&file, root, depth + 1, out)?;
108 }
109 }
110 }
111 None => {
112 out.push_str(line);
113 out.push('\n');
114 }
115 }
116 }
117 Ok(())
118}
119
120fn include_target(line: &str) -> Option<&str> {
122 let (keyword, rest) = keyword_and_rest(line)?;
123 keyword.eq_ignore_ascii_case("include").then_some(rest)
124}
125
126fn keyword_and_rest(line: &str) -> Option<(&str, &str)> {
131 let line = line.trim();
132 if line.is_empty() || line.starts_with('#') {
133 return None;
134 }
135 let end = line
136 .find(|c: char| c.is_ascii_whitespace() || c == '=')
137 .unwrap_or(line.len());
138 let (keyword, rest) = line.split_at(end);
139 Some((keyword, rest.trim_start_matches(['=', ' ', '\t']).trim()))
140}
141
142fn expand(pattern: &str, root: &Path) -> Vec<PathBuf> {
147 let mut out = Vec::new();
148 for word in pattern.split_ascii_whitespace() {
149 let word = word.trim_matches('"');
150 let resolved = if let Some(rest) = word.strip_prefix("~/") {
151 match std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
152 Some(home) => PathBuf::from(home).join(rest),
153 None => continue,
154 }
155 } else if Path::new(word).is_absolute() {
156 PathBuf::from(word)
157 } else {
158 root.join(word)
159 };
160
161 let Some(last) = resolved.file_name().and_then(|n| n.to_str()) else {
162 continue;
163 };
164 if !last.contains(['*', '?']) {
165 out.push(resolved);
166 continue;
167 }
168 let Some(dir) = resolved.parent() else {
169 continue;
170 };
171 let Ok(entries) = std::fs::read_dir(dir) else {
172 continue;
173 };
174 let mut matched: Vec<PathBuf> = entries
177 .flatten()
178 .filter(|e| {
179 e.file_name()
180 .to_str()
181 .is_some_and(|name| glob_matches(last, name))
182 })
183 .map(|e| e.path())
184 .collect();
185 matched.sort();
186 out.extend(matched);
187 }
188 out
189}
190
191fn glob_matches(pattern: &str, name: &str) -> bool {
193 let (p, n): (Vec<char>, Vec<char>) = (pattern.chars().collect(), name.chars().collect());
194 let (mut pi, mut ni) = (0, 0);
197 let (mut star, mut resume) = (None, 0);
198 while ni < n.len() {
199 if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
200 pi += 1;
201 ni += 1;
202 } else if pi < p.len() && p[pi] == '*' {
203 star = Some(pi);
204 resume = ni;
205 pi += 1;
206 } else if let Some(s) = star {
207 pi = s + 1;
208 resume += 1;
209 ni = resume;
210 } else {
211 return false;
212 }
213 }
214 p[pi..].iter().all(|&c| c == '*')
215}
216
217pub fn parse(text: &str) -> Found {
226 let mut found = Found::default();
227 let mut seen: Vec<String> = Vec::new();
228 for line in text.lines() {
229 let Some((keyword, rest)) = keyword_and_rest(line) else {
230 continue;
231 };
232 if !keyword.eq_ignore_ascii_case("host") {
233 continue;
234 }
235 for name in rest.split_ascii_whitespace() {
236 let name = name.trim_matches('"');
237 if name.is_empty() || name.contains(['*', '?']) || name.starts_with('!') {
238 continue;
239 }
240 let alias = name.to_ascii_lowercase();
245 if seen.iter().any(|s| s == &alias) {
246 continue;
247 }
248 seen.push(alias.clone());
249 if guard::is_label(&alias) {
250 found.hosts.push(Host {
251 host: name.to_string(),
252 alias,
253 });
254 } else {
255 found.unusable.push(Unusable {
256 host: name.to_string(),
257 why: "not usable as a hostname label: give it an alias in the config file"
258 .to_string(),
259 });
260 }
261 }
262 }
263 found
264}
265
266#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
273pub struct Settings {
274 pub user: Option<String>,
275 pub hostname: Option<String>,
276 pub port: Option<u16>,
277 #[serde(rename = "proxyJump")]
278 pub proxy_jump: Option<String>,
279}
280
281pub fn parse_settings(text: &str) -> Settings {
286 let mut s = Settings::default();
287 for line in text.lines() {
288 let Some((key, value)) = line.trim().split_once(' ') else {
289 continue;
290 };
291 let value = value.trim();
292 match key.to_ascii_lowercase().as_str() {
293 "user" => s.user = Some(value.to_string()),
294 "hostname" => s.hostname = Some(value.to_string()),
295 "port" => s.port = value.parse().ok(),
296 "proxyjump" if !value.eq_ignore_ascii_case("none") => {
299 s.proxy_jump = Some(value.to_string());
300 }
301 _ => {}
302 }
303 }
304 s
305}
306
307pub async fn describe(host: &str) -> Result<Settings> {
309 let out = tokio::process::Command::new("ssh")
310 .arg("-G")
311 .arg(host)
312 .output()
313 .await
314 .with_context(|| format!("run ssh -G {host}"))?;
315 Ok(parse_settings(&String::from_utf8_lossy(&out.stdout)))
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn the_hosts_of_a_real_looking_config_are_found_in_order() {
326 let found = parse(
327 "Host Panza\n HostName panza.example\n\nHost yukawa-front\n ProxyJump yukawa-mercury\n",
328 );
329 assert_eq!(
330 found.hosts,
331 vec![
332 Host {
333 host: "Panza".to_string(),
334 alias: "panza".to_string()
335 },
336 Host {
337 host: "yukawa-front".to_string(),
338 alias: "yukawa-front".to_string()
339 },
340 ]
341 );
342 assert!(found.unusable.is_empty());
343 }
344
345 #[test]
349 fn patterns_are_not_hosts() {
350 let found = parse("Host *\n ForwardAgent yes\nHost *.example.com\nHost !bad ok\n");
351 assert_eq!(
352 found.hosts,
353 vec![Host {
354 host: "ok".to_string(),
355 alias: "ok".to_string()
356 }]
357 );
358 }
359
360 #[test]
362 fn one_line_can_name_several_hosts() {
363 let found = parse("Host alpha beta gamma\n");
364 let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
365 assert_eq!(aliases, ["alpha", "beta", "gamma"]);
366 }
367
368 #[test]
370 fn the_odd_spellings_ssh_config_allows_are_understood() {
371 let found = parse("# a comment\n\n host=Odd\n\tHOST Other\n");
372 let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
373 assert_eq!(aliases, ["odd", "other"]);
374 }
375
376 #[test]
379 fn a_host_named_twice_in_different_cases_is_one_alias() {
380 let found = parse("Host Panza\nHost panza\n");
381 assert_eq!(found.hosts.len(), 1);
382 assert_eq!(found.hosts[0].host, "Panza");
383 }
384
385 #[test]
387 fn a_host_that_cannot_be_a_label_is_reported_rather_than_dropped() {
388 let found = parse("Host build.example.com\nHost fine\n");
389 let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
390 assert_eq!(aliases, ["fine"]);
391 assert_eq!(found.unusable.len(), 1);
392 assert_eq!(found.unusable[0].host, "build.example.com");
393 }
394
395 #[test]
396 fn ssh_dash_g_output_is_read_for_the_fields_worth_showing() {
397 let s = parse_settings(
398 "user souta\nhostname 10.0.0.2\nport 2222\nproxyjump bastion\nforwardagent yes\n",
399 );
400 assert_eq!(s.user.as_deref(), Some("souta"));
401 assert_eq!(s.hostname.as_deref(), Some("10.0.0.2"));
402 assert_eq!(s.port, Some(2222));
403 assert_eq!(s.proxy_jump.as_deref(), Some("bastion"));
404 }
405
406 #[test]
409 fn proxyjump_none_is_no_proxy_jump() {
410 assert_eq!(parse_settings("proxyjump none\n").proxy_jump, None);
411 }
412
413 #[test]
414 fn globs_match_the_way_include_needs() {
415 assert!(glob_matches("*", "anything"));
416 assert!(glob_matches("*.conf", "work.conf"));
417 assert!(glob_matches("a?c", "abc"));
418 assert!(!glob_matches("a?c", "ac"));
419 assert!(!glob_matches("*.conf", "conf.bak"));
420 assert!(glob_matches("*a*b*", "xxayybzz"));
421 }
422
423 #[test]
424 fn include_pulls_in_another_file() {
425 let dir = std::env::temp_dir().join(format!("ssh-browser-inc-{}", std::process::id()));
426 let sub = dir.join("config.d");
427 std::fs::create_dir_all(&sub).expect("temp dirs");
428 std::fs::write(sub.join("10-work.conf"), "Host from-include\n").expect("write include");
429 std::fs::write(dir.join("config"), "Host direct\nInclude config.d/*\n").expect("write");
430
431 let found = read_from(&dir.join("config")).expect("reads");
432 let aliases: Vec<&str> = found.hosts.iter().map(|h| h.alias.as_str()).collect();
433 assert_eq!(aliases, ["direct", "from-include"]);
434
435 std::fs::remove_dir_all(&dir).ok();
436 }
437}