diff --git a/rust/src/agent_sessions/remote.rs b/rust/src/agent_sessions/remote.rs index e9d0cc7b71..0ffe3c1df4 100644 --- a/rust/src/agent_sessions/remote.rs +++ b/rust/src/agent_sessions/remote.rs @@ -15,13 +15,13 @@ impl RemoteSessionFetcher { let valid = Self::sanitized_hosts(hosts); let valid_keys = valid .iter() - .map(|host| host.to_ascii_lowercase()) + .map(|host| Self::host_dedup_key(host)) .collect::>(); let mut invalid = hosts .iter() .filter(|host| { Self::validate_host(host).is_err() - && !valid_keys.contains(&host.trim().to_ascii_lowercase()) + && !valid_keys.contains(&Self::host_dedup_key(host.trim())) }) .map(|_| { AgentSessionHostResult::failed( @@ -175,7 +175,7 @@ impl RemoteSessionFetcher { continue; }; - let key = host.to_ascii_lowercase(); + let key = Self::host_dedup_key(&host); if seen.insert(key) { sanitized.push(host); } @@ -188,6 +188,19 @@ impl RemoteSessionFetcher { Self::sanitized_hosts(&manual.iter().chain(automatic).cloned().collect::>()) } + /// Deduplicate host names case-insensitively while preserving SSH username + /// case. SSH may treat `Alice@host` and `alice@host` as different users, + /// whereas the host component remains case-insensitive for this input. + fn host_dedup_key(host: &str) -> String { + let host = host.trim(); + if let Some(separator) = host.rfind('@') { + let (prefix, hostname) = host.split_at(separator + 1); + format!("{prefix}{}", hostname.to_ascii_lowercase()) + } else { + host.to_ascii_lowercase() + } + } + pub fn validate_host(host: &str) -> Result { let host = host.trim(); if host.is_empty() { diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs index 51b51f9760..2c2da7b174 100644 --- a/rust/src/agent_sessions/tests.rs +++ b/rust/src/agent_sessions/tests.rs @@ -105,6 +105,34 @@ bad line assert_eq!(hosts, vec!["good".to_string()]); } + #[test] + fn ssh_username_case_is_preserved_while_host_case_dedupes() { + let hosts = RemoteSessionFetcher::sanitized_hosts(&[ + "Alice@HOST".to_string(), + "Alice@host".to_string(), + "alice@host".to_string(), + "alice@HOST".to_string(), + "HOST".to_string(), + "host".to_string(), + ]); + + assert_eq!( + hosts, + vec![ + "Alice@HOST".to_string(), + "alice@host".to_string(), + "HOST".to_string() + ] + ); + assert_eq!( + RemoteSessionFetcher::merge_hosts( + &["Alice@HOST".to_string()], + &["alice@host".to_string()] + ), + vec!["Alice@HOST".to_string(), "alice@host".to_string()] + ); +} + #[test] fn tailscale_parser_returns_online_peer_dns_names() { let json = r#"{