use std::fs;
use std::path::Path;
pub const DEFAULT_SSH_PORT: u16 = 22;
pub const MAX_HOSTFILE_BYTES: u64 = 10 * 1024 * 1024;
pub const MAX_HOSTFILE_ENTRIES: usize = 1000;
#[derive(Debug, thiserror::Error)]
pub enum SshTargetError {
#[error("invalid SSH target `{0}`: missing `user@`")]
MissingUser(String),
#[error("invalid SSH target `{0}`: missing host after `user@`")]
MissingHost(String),
#[error(
"invalid SSH target `{input}`: host `{host}` contains unsupported characters \
(allowed: letters, digits, `-`, `.`, `:`, `_`)"
)]
InvalidHostChars { input: String, host: String },
#[error("invalid SSH target `{input}`: port `{port}` is not a valid u16")]
InvalidPort { input: String, port: String },
#[error("hostfile I/O error at {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("hostfile {path} exceeds {limit}-byte limit (found {actual} bytes)")]
FileTooLarge {
path: String,
actual: u64,
limit: u64,
},
}
fn valid_host_chars(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii() && (c.is_alphanumeric() || matches!(c, '-' | '.' | ':' | '_')))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshTarget {
pub user: String,
pub host: String,
pub port: u16,
}
impl SshTarget {
pub fn host_id(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
pub fn display_label(&self) -> String {
if self.port == DEFAULT_SSH_PORT {
format!("ssh://{}@{}", self.user, self.host)
} else {
format!("ssh://{}@{}:{}", self.user, self.host, self.port)
}
}
pub fn hostname(&self) -> &str {
&self.host
}
}
pub fn parse_ssh_target(raw: &str) -> Result<SshTarget, SshTargetError> {
let input = raw.trim();
let at = input
.find('@')
.ok_or_else(|| SshTargetError::MissingUser(input.to_string()))?;
let user = input[..at].to_string();
if user.is_empty() {
return Err(SshTargetError::MissingUser(input.to_string()));
}
let rest = &input[at + 1..];
if rest.is_empty() {
return Err(SshTargetError::MissingHost(input.to_string()));
}
if let Some(stripped) = rest.strip_prefix('[') {
let close = stripped
.find(']')
.ok_or_else(|| SshTargetError::MissingHost(input.to_string()))?;
let host = stripped[..close].to_string();
let tail = &stripped[close + 1..];
let port = parse_port_suffix(tail, input)?;
if !valid_host_chars(&host) {
return Err(SshTargetError::InvalidHostChars {
input: input.to_string(),
host,
});
}
return Ok(SshTarget { user, host, port });
}
if let Some((host_part, port_part)) = rest.rsplit_once(':') {
if host_part.contains(':') {
return Err(SshTargetError::MissingHost(input.to_string()));
}
let port = port_part
.parse::<u16>()
.map_err(|_| SshTargetError::InvalidPort {
input: input.to_string(),
port: port_part.to_string(),
})?;
if host_part.is_empty() {
return Err(SshTargetError::MissingHost(input.to_string()));
}
if !valid_host_chars(host_part) {
return Err(SshTargetError::InvalidHostChars {
input: input.to_string(),
host: host_part.to_string(),
});
}
Ok(SshTarget {
user,
host: host_part.to_string(),
port,
})
} else {
if !valid_host_chars(rest) {
return Err(SshTargetError::InvalidHostChars {
input: input.to_string(),
host: rest.to_string(),
});
}
Ok(SshTarget {
user,
host: rest.to_string(),
port: DEFAULT_SSH_PORT,
})
}
}
fn parse_port_suffix(tail: &str, input: &str) -> Result<u16, SshTargetError> {
if tail.is_empty() {
return Ok(DEFAULT_SSH_PORT);
}
let tail = tail.strip_prefix(':').unwrap_or(tail);
tail.parse::<u16>()
.map_err(|_| SshTargetError::InvalidPort {
input: input.to_string(),
port: tail.to_string(),
})
}
pub fn parse_ssh_arg(arg: &str) -> Result<Vec<SshTarget>, SshTargetError> {
arg.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(parse_ssh_target)
.collect()
}
pub fn parse_hostfile(path: impl AsRef<Path>) -> Result<Vec<SshTarget>, SshTargetError> {
let path = path.as_ref();
let path_str = path.display().to_string();
let metadata = fs::metadata(path).map_err(|source| SshTargetError::Io {
path: path_str.clone(),
source,
})?;
if metadata.len() > MAX_HOSTFILE_BYTES {
return Err(SshTargetError::FileTooLarge {
path: path_str,
actual: metadata.len(),
limit: MAX_HOSTFILE_BYTES,
});
}
let content = fs::read_to_string(path).map_err(|source| SshTargetError::Io {
path: path_str,
source,
})?;
parse_hostfile_content(&content)
}
pub fn parse_hostfile_content(content: &str) -> Result<Vec<SshTarget>, SshTargetError> {
let mut out = Vec::new();
for raw in content.lines() {
let line = match raw.find('#') {
Some(idx) => &raw[..idx],
None => raw,
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let target = parse_ssh_target(trimmed)?;
out.push(target);
if out.len() >= MAX_HOSTFILE_ENTRIES {
break;
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_user_at_host() {
let t = parse_ssh_target("admin@dgx-01").unwrap();
assert_eq!(t.user, "admin");
assert_eq!(t.host, "dgx-01");
assert_eq!(t.port, DEFAULT_SSH_PORT);
assert_eq!(t.host_id(), "admin@dgx-01:22");
assert_eq!(t.display_label(), "ssh://admin@dgx-01");
}
#[test]
fn parses_user_at_host_with_port() {
let t = parse_ssh_target("admin@dgx-01:2222").unwrap();
assert_eq!(t.port, 2222);
assert_eq!(t.display_label(), "ssh://admin@dgx-01:2222");
}
#[test]
fn parses_ipv6_with_brackets_and_port() {
let t = parse_ssh_target("admin@[fd00::1]:2222").unwrap();
assert_eq!(t.host, "fd00::1");
assert_eq!(t.port, 2222);
}
#[test]
fn parses_ipv6_with_brackets_default_port() {
let t = parse_ssh_target("admin@[::1]").unwrap();
assert_eq!(t.host, "::1");
assert_eq!(t.port, DEFAULT_SSH_PORT);
}
#[test]
fn rejects_missing_user() {
let e = parse_ssh_target("dgx-01").unwrap_err();
assert!(matches!(e, SshTargetError::MissingUser(_)));
}
#[test]
fn rejects_empty_user() {
let e = parse_ssh_target("@dgx-01").unwrap_err();
assert!(matches!(e, SshTargetError::MissingUser(_)));
}
#[test]
fn rejects_missing_host() {
let e = parse_ssh_target("user@").unwrap_err();
assert!(matches!(e, SshTargetError::MissingHost(_)));
}
#[test]
fn rejects_bad_port() {
let e = parse_ssh_target("user@host:not-a-number").unwrap_err();
assert!(matches!(e, SshTargetError::InvalidPort { .. }));
}
#[test]
fn rejects_unbracketed_ipv6() {
let e = parse_ssh_target("user@fd00::1").unwrap_err();
assert!(matches!(
e,
SshTargetError::MissingHost(_) | SshTargetError::InvalidPort { .. }
));
}
#[test]
fn rejects_host_with_shell_metacharacters() {
let e = parse_ssh_target("user@;whoami").unwrap_err();
assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));
let e = parse_ssh_target("user@$(cat /etc/passwd)").unwrap_err();
assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));
let e = parse_ssh_target("user@host name").unwrap_err();
assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));
let e = parse_ssh_target("user@ho|st").unwrap_err();
assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));
}
#[test]
fn rejects_ipv6_body_with_invalid_chars() {
let e = parse_ssh_target("user@[::1;payload]").unwrap_err();
assert!(matches!(e, SshTargetError::InvalidHostChars { .. }));
}
#[test]
fn accepts_legitimate_hostnames_and_ips() {
assert!(parse_ssh_target("user@host-1.example.com").is_ok());
assert!(parse_ssh_target("user@10.0.0.1").is_ok());
assert!(parse_ssh_target("user@under_score_host").is_ok());
assert!(parse_ssh_target("user@[fd00::1]").is_ok());
assert!(matches!(
parse_ssh_target("user@[fe80::1%eth0]").unwrap_err(),
SshTargetError::InvalidHostChars { .. }
));
}
#[test]
fn parse_ssh_arg_splits_comma_list() {
let t = parse_ssh_arg("a@h1,b@h2:2222,c@h3").unwrap();
assert_eq!(t.len(), 3);
assert_eq!(t[0].user, "a");
assert_eq!(t[1].port, 2222);
assert_eq!(t[2].host, "h3");
}
#[test]
fn parse_ssh_arg_tolerates_whitespace_and_empty_slots() {
let t = parse_ssh_arg(" a@h1 , , b@h2 ").unwrap();
assert_eq!(t.len(), 2);
assert_eq!(t[0].user, "a");
assert_eq!(t[1].user, "b");
}
#[test]
fn hostfile_ignores_comments_and_blanks() {
let content = "# a comment\n\nuser1@host1\n \nuser2@host2:2222 # inline comment\n";
let t = parse_hostfile_content(content).unwrap();
assert_eq!(t.len(), 2);
assert_eq!(t[0].user, "user1");
assert_eq!(t[1].port, 2222);
}
#[test]
fn hostfile_propagates_parse_errors() {
let content = "this-is-not-valid\n";
assert!(parse_hostfile_content(content).is_err());
}
#[test]
fn hostfile_enforces_entry_cap() {
let mut content = String::new();
for i in 0..(MAX_HOSTFILE_ENTRIES + 100) {
content.push_str(&format!("u@host{i}\n"));
}
let t = parse_hostfile_content(&content).unwrap();
assert_eq!(t.len(), MAX_HOSTFILE_ENTRIES);
}
#[test]
fn reads_real_hostfile_from_disk() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "alice@a\nbob@b:2200\n").unwrap();
let t = parse_hostfile(tmp.path()).unwrap();
assert_eq!(t.len(), 2);
assert_eq!(t[0].user, "alice");
assert_eq!(t[1].port, 2200);
}
}