agentmux_srv/backend/remote/
ssh_config.rs1use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15
16pub(super) const MAX_INCLUDE_DEPTH: usize = 8;
19
20pub fn hosts() -> Vec<String> {
22 let Some(ssh_dir) = dirs::home_dir().map(|h| h.join(".ssh")) else {
23 return Vec::new();
24 };
25 hosts_in(&ssh_dir.join("config"), &ssh_dir)
26}
27
28pub fn hosts_in(config: &Path, ssh_dir: &Path) -> Vec<String> {
30 let mut found = BTreeSet::new();
31 read(config, ssh_dir, 0, &mut found);
32 found.into_iter().collect()
33}
34
35fn read(path: &Path, ssh_dir: &Path, depth: usize, found: &mut BTreeSet<String>) {
36 if depth > MAX_INCLUDE_DEPTH {
37 return;
38 }
39 let Ok(text) = std::fs::read_to_string(path) else {
40 return;
41 };
42 for line in text.lines() {
43 let Some((keyword, args)) = split_line(line) else {
44 continue;
45 };
46 if keyword.eq_ignore_ascii_case("host") {
47 for name in args {
48 if is_plain_name(&name) {
49 found.insert(name);
50 }
51 }
52 } else if keyword.eq_ignore_ascii_case("include") {
53 for pattern in args {
54 for file in expand_include(&pattern, ssh_dir) {
55 read(&file, ssh_dir, depth + 1, found);
56 }
57 }
58 }
59 }
60}
61
62pub(super) fn split_line(line: &str) -> Option<(String, Vec<String>)> {
66 let line = line.trim();
67 if line.is_empty() || line.starts_with('#') {
68 return None;
69 }
70 let split_at = line.find(|c: char| c.is_whitespace() || c == '=')?;
71 let keyword = line[..split_at].to_string();
72 let rest = line[split_at..].trim_start_matches(|c: char| c.is_whitespace() || c == '=');
73 let mut args = Vec::new();
74 let mut current = String::new();
75 let mut quoted = false;
76 for c in rest.chars() {
77 match c {
78 '#' if !quoted && current.is_empty() => break,
81 '"' => quoted = !quoted,
82 c if c.is_whitespace() && !quoted => {
83 if !current.is_empty() {
84 args.push(std::mem::take(&mut current));
85 }
86 }
87 c => current.push(c),
88 }
89 }
90 if !current.is_empty() {
91 args.push(current);
92 }
93 Some((keyword, args))
94}
95
96pub(super) fn is_plain_name(name: &str) -> bool {
99 !name.is_empty()
100 && !name.contains(['*', '?', '!'])
101 && super::conn::ConnTarget::parse(name).is_ok_and(|t| !t.is_local())
102}
103
104pub(super) fn expand_include(pattern: &str, ssh_dir: &Path) -> Vec<PathBuf> {
107 let path = match pattern.strip_prefix("~/") {
108 Some(rest) => match dirs::home_dir() {
109 Some(home) => home.join(rest),
110 None => return Vec::new(),
111 },
112 None if Path::new(pattern).is_absolute() => PathBuf::from(pattern),
113 None => ssh_dir.join(pattern),
114 };
115 let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
116 return Vec::new();
117 };
118 let Some((prefix, suffix)) = name.split_once('*') else {
119 return vec![path];
120 };
121 if suffix.contains('*') {
122 return Vec::new();
123 }
124 let Some(dir) = path.parent() else {
125 return Vec::new();
126 };
127 let Ok(entries) = std::fs::read_dir(dir) else {
128 return Vec::new();
129 };
130 let mut files: Vec<PathBuf> = entries
131 .flatten()
132 .map(|e| e.path())
133 .filter(|p| p.is_file())
134 .filter(|p| {
135 p.file_name().map(|n| n.to_string_lossy()).is_some_and(|n| {
136 n.len() >= prefix.len() + suffix.len()
137 && n.starts_with(prefix)
138 && n.ends_with(suffix)
139 })
140 })
141 .collect();
142 files.sort();
143 files
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn write(dir: &Path, name: &str, text: &str) -> PathBuf {
151 let p = dir.join(name);
152 if let Some(parent) = p.parent() {
153 std::fs::create_dir_all(parent).unwrap();
154 }
155 std::fs::write(&p, text).unwrap();
156 p
157 }
158
159 #[test]
160 fn plain_host_names_are_listed_and_patterns_are_not() {
161 let dir = tempfile::tempdir().unwrap();
162 let config = write(
163 dir.path(),
164 "config",
165 "# a comment\n\
166 Host area54 starpower\n\
167 \x20 HostName 10.0.0.5\n\
168 Host=prod/web\n\
169 host db+replica \"two words\"\n\
170 Host *\n\
171 Host *.example.com !bastion ?x\n\
172 Host -oProxyCommand=calc\n\
173 Host box # prod server\n\
174 Host alpha \"#x\" beta\n\
175 Host gamma x#y delta\n\
176 Match host foo\n",
177 );
178 assert_eq!(
182 hosts_in(&config, dir.path()),
183 [
184 "alpha",
185 "area54",
186 "beta",
187 "box",
188 "db+replica",
189 "delta",
190 "gamma",
191 "prod/web",
192 "starpower"
193 ]
194 );
195 }
196
197 #[test]
198 fn includes_are_followed_relative_to_the_ssh_dir() {
199 let dir = tempfile::tempdir().unwrap();
200 write(dir.path(), "config.d/work", "Host work-box\n");
201 write(
202 dir.path(),
203 "config.d/home.conf",
204 "Host nas\nInclude nested\n",
205 );
206 write(dir.path(), "nested", "Host deep\n");
207 write(dir.path(), "other", "Host other\n");
208 let config = write(dir.path(), "config", "Include config.d/*\nHost top\n");
209 assert_eq!(
210 hosts_in(&config, dir.path()),
211 ["deep", "nas", "top", "work-box"]
212 );
213
214 let config = write(dir.path(), "config2", "Include config.d/*.conf other\n");
215 assert_eq!(hosts_in(&config, dir.path()), ["deep", "nas", "other"]);
216 }
217
218 #[test]
219 fn an_include_loop_ends() {
220 let dir = tempfile::tempdir().unwrap();
221 let config = write(dir.path(), "config", "Host a\nInclude config\n");
222 assert_eq!(hosts_in(&config, dir.path()), ["a"]);
223 }
224
225 #[test]
226 fn no_config_is_no_hosts() {
227 let dir = tempfile::tempdir().unwrap();
228 assert!(hosts_in(&dir.path().join("config"), dir.path()).is_empty());
229 }
230}