1use std::process::Command;
11
12use crate::permalink::is_safe_host;
13use strop_core::worker::{CancelToken, FailureKind};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum EffectiveHostError {
19 InvalidHost,
21 Spawn(String),
23 Failed(String),
25 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
50pub 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
66fn 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
98pub 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 #[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 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 #[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 #[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 #[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 #[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 #[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}