Skip to main content

agentmux_srv/backend/remote/
ssh_config.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The hosts the user's `~/.ssh/config` names, for the connection picker
5//! (P2 of SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
6//!
7//! Only names are read, never anything else in the file: a `Host` alias that
8//! is a plain name (no `*`, `?` or `!` pattern) is a connection the user can
9//! open, and `ssh` itself resolves everything about it. `Include` is followed
10//! as `ssh` does, relative to `~/.ssh`, with a `*` in the last part of the
11//! path (`Include config.d/*`), the common form.
12
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15
16/// How deep `Include` is followed (ssh's own limit is 16; real configs use one
17/// or two levels).
18pub(super) const MAX_INCLUDE_DEPTH: usize = 8;
19
20/// The plain `Host` names in the user's ssh config, sorted, each once.
21pub 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
28/// [`hosts`] for a given config file, with `Include` relative to `ssh_dir`.
29pub 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
62/// A config line's keyword and its arguments: `Keyword arg ...` or
63/// `Keyword=arg ...`, with double-quoted arguments kept whole. `None` for a
64/// blank line or a comment.
65pub(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            // An unquoted `#` starting a word ends the line, as in ssh's own
79            // parser: `Host box # prod server` names only `box`.
80            '#' 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
96/// A name the user can open as it is: not a pattern, and nothing ssh's own
97/// destination check (conn.rs) would refuse.
98pub(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
104/// The files an `Include` names: `~/` is the home, a relative path is under
105/// `ssh_dir`, and a `*` in the last part matches any run of characters there.
106pub(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        // A trailing comment names no hosts; a `#` inside quotes or a word is
179        // not one, so the names after it count (the words holding `#` are
180        // themselves refused by conn.rs, as shell syntax).
181        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}