Skip to main content

strop_git/
ssh.rs

1//! OpenSSH effective configuration (0033 finding 1): an SSH alias's
2//! real hostname is what `ssh -G` says — `Include`, wildcard `Host`
3//! and `HostName` rules included — not what a partial home-grown
4//! parser guesses from `~/.ssh/config`.
5//!
6//! Evaluation spawns a process, so it is owned IO-worker work (never
7//! the permalink input/render path); `parse_effective_hostname` is
8//! the pure half, testable against canned output.
9
10use std::process::Command;
11
12use crate::permalink::is_safe_host;
13use strop_core::worker::{CancelToken, FailureKind};
14
15/// Why effective-host evaluation failed, typed at the boundary the UI
16/// reports it. None of these ever carries a guessed hostname.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum EffectiveHostError {
19    /// The host text cannot be a hostname or alias — never spawned.
20    InvalidHost,
21    /// The ssh program could not run.
22    Spawn(String),
23    /// `ssh -G` exited non-zero; carries its stderr.
24    Failed(String),
25    /// `ssh -G` produced no usable `hostname` line.
26    NoHostname,
27    Process(strop_core::worker::Failure),
28    Unresolved,
29}
30
31impl std::fmt::Display for EffectiveHostError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            EffectiveHostError::InvalidHost => {
35                write!(f, "not a valid hostname or alias")
36            }
37            EffectiveHostError::Spawn(message) => write!(f, "cannot run ssh: {message}"),
38            EffectiveHostError::Failed(message) => write!(f, "ssh -G failed: {message}"),
39            EffectiveHostError::NoHostname => write!(f, "ssh -G reported no hostname"),
40            EffectiveHostError::Process(failure) => write!(f, "ssh -G: {}", failure.message),
41            EffectiveHostError::Unresolved => {
42                write!(f, "SSH alias has no configured web hostname; check ssh -G")
43            }
44        }
45    }
46}
47
48impl std::error::Error for EffectiveHostError {}
49
50/// The alias's effective hostname per OpenSSH's full configuration.
51/// `ssh -G` resolves and prints the configuration without connecting.
52pub fn effective_host(
53    remote: &crate::permalink::AliasRemote,
54    token: &CancelToken,
55) -> Result<String, EffectiveHostError> {
56    let mut command = Command::new("ssh");
57    if let Some(user) = &remote.user {
58        command.arg("-l").arg(user);
59    }
60    if let Some(port) = remote.port {
61        command.arg("-p").arg(port.to_string());
62    }
63    effective_host_via(&mut command, remote.host(), token)
64}
65
66/// Same, with the ssh program named — the seam hermetic tests drive
67/// with a fake binary. The host is validated before anything spawns
68/// and then rides one argv element after `-G`: no shell, no string
69/// concatenation, no option position.
70fn effective_host_via(
71    command: &mut Command,
72    host: &str,
73    token: &CancelToken,
74) -> Result<String, EffectiveHostError> {
75    let host = is_safe_host(host)
76        .then_some(host)
77        .ok_or(EffectiveHostError::InvalidHost)?;
78    command.arg("-G").arg(host);
79    let output = strop_core::process::capture(command, token).map_err(|failure| {
80        if failure.kind == FailureKind::Spawn {
81            EffectiveHostError::Spawn(failure.message)
82        } else {
83            EffectiveHostError::Process(failure)
84        }
85    })?;
86    if !output.status.success() {
87        let stderr = String::from_utf8_lossy(&output.stderr);
88        return Err(EffectiveHostError::Failed(stderr.trim().to_string()));
89    }
90    let stdout = String::from_utf8_lossy(&output.stdout);
91    let hostname = parse_effective_hostname(&stdout).ok_or(EffectiveHostError::NoHostname)?;
92    if hostname == host && !hostname.contains('.') {
93        return Err(EffectiveHostError::Unresolved);
94    }
95    Ok(hostname)
96}
97
98/// Pull the effective `hostname` value out of `ssh -G` output. First
99/// usable line wins; the value must still be hostname-shaped, so a
100/// corrupt line cannot smuggle text into a URL.
101pub fn parse_effective_hostname(output: &str) -> Option<String> {
102    output.lines().find_map(|line| {
103        let mut parts = line.split_whitespace();
104        match (parts.next(), parts.next()) {
105            (Some("hostname"), Some(host)) if is_safe_host(host) => Some(host.to_string()),
106            _ => None,
107        }
108    })
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn resolve(program: &str, host: &str) -> Result<String, EffectiveHostError> {
116        // Executing a script this suite wrote microseconds earlier can hit
117        // Linux's close-to-exec ETXTBSY race (observed on loaded overlayfs in
118        // the ARM gate). It is transient with no deterministic wait
119        // primitive, so back off and retry the spawn instead of failing.
120        for attempt in 1.. {
121            let outcome = resolve_once(program, host);
122            let transient = matches!(
123                &outcome,
124                Err(EffectiveHostError::Spawn(message)) if message.contains("Text file busy")
125            );
126            if !transient || attempt == 8 {
127                return outcome;
128            }
129            std::thread::sleep(std::time::Duration::from_millis(25 * attempt));
130        }
131        unreachable!()
132    }
133    fn resolve_once(program: &str, host: &str) -> Result<String, EffectiveHostError> {
134        let (tx, rx) = std::sync::mpsc::channel();
135        let program = program.to_owned();
136        let host = host.to_owned();
137        let handle = strop_core::worker::spawn(
138            "ssh-test",
139            move |outcome| {
140                tx.send(outcome).unwrap();
141            },
142            move |token| {
143                strop_core::worker::Outcome::Success(effective_host_via(
144                    &mut Command::new(program),
145                    &host,
146                    &token,
147                ))
148            },
149        );
150        let strop_core::worker::Outcome::Success(result) = rx.recv().unwrap() else {
151            panic!("worker failed")
152        };
153        drop(handle);
154        result
155    }
156
157    /// Write a fake ssh that records its argv and prints canned
158    /// effective configuration. Hermetic: no real ssh, no HOME read,
159    /// no network — `ssh -G` output is decided by the script.
160    #[cfg(unix)]
161    fn fake_ssh(dir: &std::path::Path, hostname: &str) -> std::path::PathBuf {
162        use std::os::unix::fs::PermissionsExt;
163        let path = dir.join("fake-ssh");
164        let script = format!(
165            "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.argv\"\nprintf 'user git\\nhostname {hostname}\\nport 22\\n'\n",
166        );
167        std::fs::write(&path, script).unwrap();
168        let mut permissions = std::fs::metadata(&path).unwrap().permissions();
169        permissions.set_mode(0o755);
170        std::fs::set_permissions(&path, permissions).unwrap();
171        path
172    }
173
174    #[test]
175    fn parses_effective_hostname_from_g_output() {
176        let output = "user git\nhostname bbgithub.dev.bloomberg.com\nport 22\n";
177        assert_eq!(
178            parse_effective_hostname(output).as_deref(),
179            Some("bbgithub.dev.bloomberg.com")
180        );
181        // tab-separated keys (ssh -G has used both shapes)
182        assert_eq!(
183            parse_effective_hostname("hostname\thost.example.com").as_deref(),
184            Some("host.example.com")
185        );
186        assert_eq!(parse_effective_hostname("user git\nport 22"), None);
187        assert_eq!(parse_effective_hostname(""), None);
188    }
189
190    /// A resolved hostname that is not hostname-shaped is refused —
191    /// nothing malformed rides into a URL.
192    #[test]
193    fn refuses_non_hostname_shaped_output() {
194        assert_eq!(parse_effective_hostname("hostname -oProxy"), None);
195        assert_eq!(parse_effective_hostname("hostname "), None);
196    }
197
198    /// The spawn boundary: the host is ONE argv element after `-G` —
199    /// never a shell string, never an option position — and the
200    /// effective hostname maps through.
201    #[cfg(unix)]
202    #[test]
203    fn effective_host_spawns_one_safe_argv_element() {
204        let dir = tempfile::tempdir().unwrap();
205        let ssh = fake_ssh(dir.path(), "bbgithub.dev.bloomberg.com");
206        let argv_file = dir.path().join("fake-ssh.argv");
207
208        let host = resolve(ssh.to_str().unwrap(), "bbgithub").unwrap();
209        assert_eq!(host, "bbgithub.dev.bloomberg.com");
210        assert_eq!(
211            std::fs::read_to_string(&argv_file).unwrap(),
212            "-G\nbbgithub\n",
213            "argv must be exactly [-G, bbgithub]"
214        );
215    }
216
217    /// Option-shaped host text never reaches a process: InvalidHost,
218    /// and the binary was not executed.
219    #[cfg(unix)]
220    #[test]
221    fn option_shaped_hosts_never_spawn() {
222        let dir = tempfile::tempdir().unwrap();
223        let ssh = fake_ssh(dir.path(), "should-not-run");
224        let argv_file = dir.path().join("fake-ssh.argv");
225        for host in ["-oProxyCommand=evil", "", "git@bb", "bb github"] {
226            assert_eq!(
227                resolve(ssh.to_str().unwrap(), host),
228                Err(EffectiveHostError::InvalidHost),
229                "should refuse: {host:?}"
230            );
231        }
232        assert!(!argv_file.exists(), "no process may run for invalid hosts");
233    }
234
235    /// A failing `ssh -G` surfaces its stderr, not a guess.
236    #[cfg(unix)]
237    #[test]
238    fn failing_ssh_reports_stderr() {
239        use std::os::unix::fs::PermissionsExt;
240        let dir = tempfile::tempdir().unwrap();
241        let root = dir.path();
242        let path = root.join("failing-ssh");
243        std::fs::write(
244            &path,
245            "#!/bin/sh\necho 'Bad configuration option.' >&2\nexit 255\n",
246        )
247        .unwrap();
248        let mut permissions = std::fs::metadata(&path).unwrap().permissions();
249        permissions.set_mode(0o755);
250        std::fs::set_permissions(&path, permissions).unwrap();
251
252        match resolve(path.to_str().unwrap(), "bbgithub") {
253            Err(EffectiveHostError::Failed(message)) => {
254                assert!(message.contains("Bad configuration option."), "{message}")
255            }
256            other => panic!("expected Failed, got {other:?}"),
257        }
258    }
259
260    /// A missing ssh program is a Spawn failure, not a guess.
261    #[test]
262    fn missing_program_is_spawn_failure() {
263        let missing = "/nonexistent/strop-test-ssh";
264        match resolve(missing, "bbgithub") {
265            Err(EffectiveHostError::Spawn(_)) => {}
266            other => panic!("expected Spawn, got {other:?}"),
267        }
268    }
269}