agentmux_srv/backend/remote/
conn.rs1#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ConnTarget {
20 Local,
21 Wsl(String),
23 Ssh(SshDest),
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SshDest {
30 pub destination: String,
32 pub port: Option<u16>,
34}
35
36impl SshDest {
37 pub fn name(&self) -> String {
39 match self.port {
40 Some(p) => format!("{}:{p}", self.destination),
41 None => self.destination.clone(),
42 }
43 }
44}
45
46pub const WSL_PREFIX: &str = "wsl://";
48
49const SHELL_CHARS: &[char] = &[
52 '`', '$', ';', '|', '&', '<', '>', '(', ')', '\'', '"', '\\', '*', '?', '!', '{', '}', '#', '~',
53];
54
55const MAX_CONN_NAME_LEN: usize = 300;
58
59impl ConnTarget {
60 pub fn parse(name: &str) -> Result<ConnTarget, String> {
63 let name = name.trim();
64 if name.is_empty() || name == "local" {
65 return Ok(ConnTarget::Local);
66 }
67 if name.len() > MAX_CONN_NAME_LEN {
68 return Err("connection name is too long".to_string());
69 }
70 if name.chars().any(|c| c.is_whitespace() || c.is_control()) {
71 return Err(
72 "a connection name cannot contain spaces or control characters".to_string(),
73 );
74 }
75 if let Some(distro) = name.strip_prefix(WSL_PREFIX) {
76 return parse_wsl(distro).map(ConnTarget::Wsl);
77 }
78 parse_ssh(name).map(ConnTarget::Ssh)
79 }
80
81 pub fn name(&self) -> String {
84 match self {
85 ConnTarget::Local => "local".to_string(),
86 ConnTarget::Wsl(d) => format!("{WSL_PREFIX}{d}"),
87 ConnTarget::Ssh(s) => s.name(),
88 }
89 }
90
91 pub fn is_local(&self) -> bool {
92 matches!(self, ConnTarget::Local)
93 }
94}
95
96pub fn same_connection(a: &str, b: &str) -> bool {
99 match (ConnTarget::parse(a), ConnTarget::parse(b)) {
100 (Ok(ConnTarget::Wsl(a)), Ok(ConnTarget::Wsl(b))) => a.eq_ignore_ascii_case(&b),
101 (Ok(a), Ok(b)) => a == b,
102 _ => a == b,
103 }
104}
105
106fn parse_wsl(distro: &str) -> Result<String, String> {
107 if distro.is_empty() {
110 return Err("a WSL connection needs a distribution name, e.g. wsl://Ubuntu".to_string());
111 }
112 if !distro
113 .chars()
114 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
115 {
116 return Err(format!("'{distro}' is not a WSL distribution name"));
117 }
118 if distro.starts_with('-') {
119 return Err(format!("'{distro}' is not a WSL distribution name"));
120 }
121 Ok(distro.to_string())
122}
123
124fn parse_ssh(name: &str) -> Result<SshDest, String> {
125 if name.starts_with('-') {
126 return Err("a connection name cannot start with '-'".to_string());
127 }
128 let (destination, port) = split_port(name)?;
131 let host = destination
132 .rsplit_once('@')
133 .map(|(_, h)| h)
134 .unwrap_or(destination);
135 if host.is_empty() {
136 return Err("a connection needs a host".to_string());
137 }
138 if let Some((user, _)) = destination.rsplit_once('@') {
139 if user.is_empty() {
140 return Err("the user before '@' is empty".to_string());
141 }
142 }
143 if destination.chars().any(|c| SHELL_CHARS.contains(&c)) {
150 return Err(format!(
151 "'{name}' is not a host name, user@host, or ssh config alias"
152 ));
153 }
154 Ok(SshDest {
155 destination: destination.to_string(),
156 port,
157 })
158}
159
160fn split_port(name: &str) -> Result<(&str, Option<u16>), String> {
161 if name.ends_with(']') {
163 return Ok((name, None));
164 }
165 if let Some(idx) = name.rfind("]:") {
166 let port = parse_port(&name[idx + 2..])?;
167 return Ok((&name[..idx + 1], Some(port)));
168 }
169 if name.matches(':').count() >= 2 {
171 return Ok((name, None));
172 }
173 match name.rsplit_once(':') {
174 Some((dest, port)) => Ok((dest, Some(parse_port(port)?))),
175 None => Ok((name, None)),
176 }
177}
178
179fn parse_port(s: &str) -> Result<u16, String> {
180 match s.parse::<u16>() {
181 Ok(p) if p > 0 => Ok(p),
182 _ => Err(format!("'{s}' is not a port number")),
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn ssh(dest: &str, port: Option<u16>) -> ConnTarget {
191 ConnTarget::Ssh(SshDest {
192 destination: dest.to_string(),
193 port,
194 })
195 }
196
197 #[test]
198 fn local_is_the_default() {
199 for n in ["", "local", " local "] {
200 assert_eq!(ConnTarget::parse(n), Ok(ConnTarget::Local), "{n:?}");
201 }
202 }
203
204 #[test]
205 fn wsl_names_parse() {
206 assert_eq!(
207 ConnTarget::parse("wsl://Ubuntu"),
208 Ok(ConnTarget::Wsl("Ubuntu".into()))
209 );
210 assert_eq!(
211 ConnTarget::parse("wsl://Ubuntu-24.04"),
212 Ok(ConnTarget::Wsl("Ubuntu-24.04".into()))
213 );
214 assert!(ConnTarget::parse("wsl://").is_err());
215 assert!(ConnTarget::parse("wsl://a/b").is_err());
216 assert!(ConnTarget::parse("wsl://-d").is_err());
217 }
218
219 #[test]
220 fn ssh_destinations_parse_as_written() {
221 assert_eq!(ConnTarget::parse("area54"), Ok(ssh("area54", None)));
222 assert_eq!(
223 ConnTarget::parse("asaf@area54"),
224 Ok(ssh("asaf@area54", None))
225 );
226 assert_eq!(
227 ConnTarget::parse("asaf@area54:2222"),
228 Ok(ssh("asaf@area54", Some(2222)))
229 );
230 assert_eq!(
231 ConnTarget::parse("deploy@10.0.0.5"),
232 Ok(ssh("deploy@10.0.0.5", None))
233 );
234 assert_eq!(
235 ConnTarget::parse("[fe80::1]:22"),
236 Ok(ssh("[fe80::1]", Some(22)))
237 );
238 assert_eq!(ConnTarget::parse("fe80::1"), Ok(ssh("fe80::1", None)));
239 assert_eq!(
240 ConnTarget::parse("user@fe80::1%eth0"),
241 Ok(ssh("user@fe80::1%eth0", None))
242 );
243 }
244
245 #[test]
247 fn ssh_config_aliases_with_slashes_or_plus_are_accepted() {
248 assert_eq!(ConnTarget::parse("prod/web"), Ok(ssh("prod/web", None)));
249 assert_eq!(
250 ConnTarget::parse("deploy@db+replica"),
251 Ok(ssh("deploy@db+replica", None))
252 );
253 }
254
255 #[test]
258 fn unsafe_or_malformed_names_are_refused() {
259 for bad in [
260 "-oProxyCommand=calc",
261 "-p 22 host",
262 "host;rm -rf ~",
263 "host name",
264 "host\nname",
265 "user@",
266 "@host",
267 "host:0",
268 "host:99999",
269 "host:abc",
270 "$(whoami)@host",
271 "host`id`",
272 ] {
273 assert!(ConnTarget::parse(bad).is_err(), "{bad:?} should be refused");
274 }
275 assert!(ConnTarget::parse(&"a".repeat(MAX_CONN_NAME_LEN + 1)).is_err());
276 }
277
278 #[test]
279 fn same_connection_ignores_local_spelling_and_distro_case() {
280 assert!(same_connection("local", ""));
281 assert!(same_connection("wsl://Ubuntu", " wsl://Ubuntu "));
282 assert!(same_connection("wsl://Ubuntu", "wsl://ubuntu"));
283 assert!(!same_connection("local", "wsl://Ubuntu"));
284 assert!(!same_connection("wsl://Ubuntu", "wsl://Debian"));
285 assert!(
286 !same_connection("Area54", "area54"),
287 "ssh aliases keep their case"
288 );
289 }
290
291 #[test]
292 fn names_round_trip() {
293 for n in [
294 "local",
295 "wsl://Ubuntu",
296 "area54",
297 "asaf@area54:2222",
298 "[fe80::1]:22",
299 ] {
300 assert_eq!(ConnTarget::parse(n).unwrap().name(), n);
301 }
302 assert_eq!(ConnTarget::parse("").unwrap().name(), "local");
303 }
304}