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 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 #[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 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 #[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 #[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 #[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 #[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 #[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}