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        let (tx, rx) = std::sync::mpsc::channel();
117        let program = program.to_owned();
118        let host = host.to_owned();
119        let handle = strop_core::worker::spawn(
120            "ssh-test",
121            move |outcome| {
122                tx.send(outcome).unwrap();
123            },
124            move |token| {
125                strop_core::worker::Outcome::Success(effective_host_via(
126                    &mut Command::new(program),
127                    &host,
128                    &token,
129                ))
130            },
131        );
132        let strop_core::worker::Outcome::Success(result) = rx.recv().unwrap() else {
133            panic!("worker failed")
134        };
135        drop(handle);
136        result
137    }
138
139    /// Write a fake ssh that records its argv and prints canned
140    /// effective configuration. Hermetic: no real ssh, no HOME read,
141    /// no network — `ssh -G` output is decided by the script.
142    #[cfg(unix)]
143    fn fake_ssh(dir: &std::path::Path, hostname: &str) -> std::path::PathBuf {
144        use std::os::unix::fs::PermissionsExt;
145        let path = dir.join("fake-ssh");
146        let script = format!(
147            "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.argv\"\nprintf 'user git\\nhostname {hostname}\\nport 22\\n'\n",
148        );
149        std::fs::write(&path, script).unwrap();
150        let mut permissions = std::fs::metadata(&path).unwrap().permissions();
151        permissions.set_mode(0o755);
152        std::fs::set_permissions(&path, permissions).unwrap();
153        path
154    }
155
156    #[test]
157    fn parses_effective_hostname_from_g_output() {
158        let output = "user git\nhostname bbgithub.dev.bloomberg.com\nport 22\n";
159        assert_eq!(
160            parse_effective_hostname(output).as_deref(),
161            Some("bbgithub.dev.bloomberg.com")
162        );
163        // tab-separated keys (ssh -G has used both shapes)
164        assert_eq!(
165            parse_effective_hostname("hostname\thost.example.com").as_deref(),
166            Some("host.example.com")
167        );
168        assert_eq!(parse_effective_hostname("user git\nport 22"), None);
169        assert_eq!(parse_effective_hostname(""), None);
170    }
171
172    /// A resolved hostname that is not hostname-shaped is refused —
173    /// nothing malformed rides into a URL.
174    #[test]
175    fn refuses_non_hostname_shaped_output() {
176        assert_eq!(parse_effective_hostname("hostname -oProxy"), None);
177        assert_eq!(parse_effective_hostname("hostname "), None);
178    }
179
180    /// The spawn boundary: the host is ONE argv element after `-G` —
181    /// never a shell string, never an option position — and the
182    /// effective hostname maps through.
183    #[cfg(unix)]
184    #[test]
185    fn effective_host_spawns_one_safe_argv_element() {
186        let dir = tempfile::tempdir().unwrap();
187        let ssh = fake_ssh(dir.path(), "bbgithub.dev.bloomberg.com");
188        let argv_file = dir.path().join("fake-ssh.argv");
189
190        let host = resolve(ssh.to_str().unwrap(), "bbgithub").unwrap();
191        assert_eq!(host, "bbgithub.dev.bloomberg.com");
192        assert_eq!(
193            std::fs::read_to_string(&argv_file).unwrap(),
194            "-G\nbbgithub\n",
195            "argv must be exactly [-G, bbgithub]"
196        );
197    }
198
199    /// Option-shaped host text never reaches a process: InvalidHost,
200    /// and the binary was not executed.
201    #[cfg(unix)]
202    #[test]
203    fn option_shaped_hosts_never_spawn() {
204        let dir = tempfile::tempdir().unwrap();
205        let ssh = fake_ssh(dir.path(), "should-not-run");
206        let argv_file = dir.path().join("fake-ssh.argv");
207        for host in ["-oProxyCommand=evil", "", "git@bb", "bb github"] {
208            assert_eq!(
209                resolve(ssh.to_str().unwrap(), host),
210                Err(EffectiveHostError::InvalidHost),
211                "should refuse: {host:?}"
212            );
213        }
214        assert!(!argv_file.exists(), "no process may run for invalid hosts");
215    }
216
217    /// A failing `ssh -G` surfaces its stderr, not a guess.
218    #[cfg(unix)]
219    #[test]
220    fn failing_ssh_reports_stderr() {
221        use std::os::unix::fs::PermissionsExt;
222        let dir = tempfile::tempdir().unwrap();
223        let root = dir.path();
224        let path = root.join("failing-ssh");
225        std::fs::write(
226            &path,
227            "#!/bin/sh\necho 'Bad configuration option.' >&2\nexit 255\n",
228        )
229        .unwrap();
230        let mut permissions = std::fs::metadata(&path).unwrap().permissions();
231        permissions.set_mode(0o755);
232        std::fs::set_permissions(&path, permissions).unwrap();
233
234        match resolve(path.to_str().unwrap(), "bbgithub") {
235            Err(EffectiveHostError::Failed(message)) => {
236                assert!(message.contains("Bad configuration option."), "{message}")
237            }
238            other => panic!("expected Failed, got {other:?}"),
239        }
240    }
241
242    /// A missing ssh program is a Spawn failure, not a guess.
243    #[test]
244    fn missing_program_is_spawn_failure() {
245        let missing = "/nonexistent/strop-test-ssh";
246        match resolve(missing, "bbgithub") {
247            Err(EffectiveHostError::Spawn(_)) => {}
248            other => panic!("expected Spawn, got {other:?}"),
249        }
250    }
251}