use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct TempEnv {
key: String,
original: Option<std::ffi::OsString>,
}
impl TempEnv {
unsafe fn set(key: &str, value: &str) -> Self {
let original = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self {
key: key.to_owned(),
original,
}
}
}
impl Drop for TempEnv {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(val) => std::env::set_var(&self.key, val),
None => std::env::remove_var(&self.key),
}
}
}
}
fn expand_tokens_skip_i(s: &str, ctx: &TokenContext<'_>) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '%' {
match chars.peek().copied() {
Some('%') => {
result.push_str("%%");
chars.next();
}
Some('i') => {
result.push_str("%i");
chars.next();
}
Some('C') => {
chars.next();
let hash_input = format!("{}:{}:{}", ctx.host, ctx.port, ctx.local_user);
result.push_str(&simple_hash(&hash_input));
}
Some('d') => {
chars.next();
result.push_str(ctx.home_dir);
}
Some('H') => {
chars.next();
result.push_str(ctx.canonical_host);
}
Some('h' | 'n') => {
chars.next();
result.push_str(ctx.host);
}
Some('L') => {
chars.next();
result.push_str(
ctx.local_hostname
.split('.')
.next()
.unwrap_or(ctx.local_hostname),
);
}
Some('l') => {
chars.next();
result.push_str(ctx.local_hostname);
}
Some('p') => {
chars.next();
result.push_str(ctx.port);
}
Some('r' | 'T') => {
chars.next();
result.push_str(ctx.remote_user);
}
Some('u') => {
chars.next();
result.push_str(ctx.local_user);
}
_ => {
result.push(ch);
}
}
} else {
result.push(ch);
}
}
result
}
#[test]
fn expand_env_vars_no_vars() {
assert_eq!(expand_env_vars("hello"), "hello");
}
#[test]
fn expand_env_vars_simple_var() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_TEST_VAR", "world") };
assert_eq!(expand_env_vars("${TORIDE_TEST_VAR}"), "world");
}
#[test]
fn expand_env_vars_undefined_var() {
assert_eq!(expand_env_vars("${TORIDE_UNDEFINED_VAR_XYZ}"), "");
}
#[test]
fn expand_env_vars_unclosed_brace() {
assert_eq!(expand_env_vars("${UNCLOSED"), "${UNCLOSED");
}
#[test]
fn expand_env_vars_mixed_text() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_TEST_HOST", "example.com") };
assert_eq!(
expand_env_vars("Host: ${TORIDE_TEST_HOST}, Port: 22"),
"Host: example.com, Port: 22"
);
}
#[test]
fn expand_env_vars_multiple_vars() {
let _lock = acquire_env_lock();
let _g0 = unsafe { TempEnv::set("TORIDE_A", "alpha") };
let _g1 = unsafe { TempEnv::set("TORIDE_B", "beta") };
assert_eq!(expand_env_vars("${TORIDE_A}-${TORIDE_B}"), "alpha-beta");
}
#[test]
fn expand_env_vars_empty_string() {
assert_eq!(expand_env_vars(""), "");
}
#[test]
fn expand_env_vars_only_dollar() {
assert_eq!(expand_env_vars("$"), "$");
}
#[test]
fn expand_env_vars_no_braces() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_NO_BRACE", "nobrace") };
assert_eq!(expand_env_vars("$TORIDE_NO_BRACE"), "nobrace");
}
#[test]
fn expand_env_vars_no_braces_with_suffix() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_VAR_SUF", "value") };
assert_eq!(expand_env_vars("${TORIDE_VAR_SUF}_extra"), "value_extra");
assert_eq!(expand_env_vars("$TORIDE_VAR_SUF.extra"), "value.extra");
}
#[test]
fn expand_env_vars_no_braces_undefined() {
assert_eq!(expand_env_vars("$TORIDE_UNDEF_XYZ_123"), "");
}
#[test]
fn expand_env_vars_bare_dollar_before_non_name() {
assert_eq!(expand_env_vars("$ "), "$ ");
assert_eq!(expand_env_vars("$/path"), "$/path");
}
#[test]
fn expand_tilde_home_dir() {
let result = expand_tilde_and_env("~/test");
assert!(!result.starts_with("~/"));
assert!(result.ends_with("/test"));
}
#[test]
fn expand_tilde_no_tilde() {
assert_eq!(expand_tilde_and_env("/absolute/path"), "/absolute/path");
}
#[test]
fn expand_tilde_just_tilde() {
let result = expand_tilde_and_env("~");
assert_ne!(result, "");
assert!(!result.starts_with('~'));
}
#[test]
fn collapse_double_percent_basic() {
assert_eq!(collapse_double_percent("%%"), "%");
assert_eq!(collapse_double_percent("hello%%world"), "hello%world");
assert_eq!(collapse_double_percent("no percent"), "no percent");
}
#[test]
fn collapse_double_percent_multiple() {
assert_eq!(collapse_double_percent("%%%%"), "%%");
}
#[test]
fn expand_tokens_no_tokens() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("hello", &ctx), "hello");
}
#[test]
fn expand_tokens_host() {
let ctx = TokenContext {
host: "example.com",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "example.com",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%h", &ctx), "example.com");
}
#[test]
fn expand_tokens_user() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "alice",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%u", &ctx), "alice");
}
#[test]
fn expand_tokens_port() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "2222",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%p", &ctx), "2222");
}
#[test]
fn expand_tokens_home_dir() {
let ctx = TokenContext {
host: "host",
home_dir: "/home/alice",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%d", &ctx), "/home/alice");
}
#[test]
fn expand_tokens_local_hostname() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "myhost",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%l", &ctx), "myhost");
}
#[test]
fn expand_tokens_remote_user() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "deploy",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%r", &ctx), "deploy");
}
#[test]
fn expand_tokens_unknown_token() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%z", &ctx), "%z");
}
#[test]
fn expand_tokens_trailing_percent() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("hello%", &ctx), "hello%");
}
#[test]
fn expand_tokens_double_percent() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%%", &ctx), "%%");
}
#[test]
fn expand_tokens_mixed() {
let ctx = TokenContext {
host: "example.com",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "example.com",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%h:%p", &ctx), "example.com:22");
}
#[test]
fn simple_glob_match_exact() {
assert!(simple_glob_match("test.conf", "test.conf"));
assert!(!simple_glob_match("test.conf", "other.conf"));
}
#[test]
fn simple_glob_match_star() {
assert!(simple_glob_match("anything", "*"));
}
#[test]
fn simple_glob_match_no_wildcards() {
assert!(simple_glob_match("abc", "abc"));
assert!(!simple_glob_match("abc", "def"));
}
#[test]
fn match_criteria_host_basic() {
assert!(match_criteria_host("host web", "web", "alice", "web"));
assert!(!match_criteria_host("host web", "db", "alice", "web"));
}
#[test]
fn match_criteria_host_no_host_clause() {
assert!(!match_criteria_host("user alice", "web", "bob", "web"));
assert!(match_criteria_host("user alice", "web", "alice", "web"));
}
#[test]
fn match_criteria_host_wildcard() {
assert!(match_criteria_host(
"host *", "anything", "alice", "anything"
));
}
#[test]
fn expand_tokens_consecutive_tokens() {
let ctx = TokenContext {
host: "h",
home_dir: "d",
local_hostname: "l",
remote_user: "r",
local_user: "u",
port: "p",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%h%h%h", &ctx), "hhh");
}
#[test]
fn expand_tokens_adjacent_different_tokens() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%h:%p:%u", &ctx), "host:22:user");
}
#[test]
fn expand_tokens_percent_at_end() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("hello%", &ctx), "hello%");
}
#[test]
fn expand_tokens_percent_at_start() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%h", &ctx), "host");
}
#[test]
fn expand_tokens_only_percent() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%", &ctx), "%");
}
#[test]
fn expand_tokens_escaped_percent_sequence() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%%h", &ctx), "%%h");
}
#[test]
fn expand_tokens_empty_string() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("", &ctx), "");
}
#[test]
fn expand_tokens_no_tokens_complex() {
let ctx = TokenContext {
host: "host",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "host",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(
expand_tokens("/usr/local/bin/ssh", &ctx),
"/usr/local/bin/ssh"
);
}
#[test]
fn expand_tilde_just_slash() {
let result = expand_tilde_and_env("~/");
assert!(!result.starts_with("~/"));
assert!(result.ends_with('/'));
}
#[test]
fn expand_tilde_path_with_spaces() {
let result = expand_tilde_and_env("~/path with spaces");
assert!(!result.starts_with("~/"));
assert!(result.ends_with("/path with spaces"));
}
#[test]
fn expand_env_vars_var_at_start() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_TEST_START", "hello") };
assert_eq!(expand_env_vars("${TORIDE_TEST_START}world"), "helloworld");
}
#[test]
fn expand_env_vars_var_at_end() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_TEST_END", "world") };
assert_eq!(expand_env_vars("hello${TORIDE_TEST_END}"), "helloworld");
}
#[test]
fn expand_env_vars_adjacent_vars() {
let _lock = acquire_env_lock();
let _g0 = unsafe { TempEnv::set("TORIDE_X", "X") };
let _g1 = unsafe { TempEnv::set("TORIDE_Y", "Y") };
assert_eq!(expand_env_vars("${TORIDE_X}${TORIDE_Y}"), "XY");
}
#[test]
fn expand_env_vars_nested_braces() {
let result = expand_env_vars("${${NESTED}}");
let _ = result; }
#[test]
fn match_criteria_host_case_insensitive_keyword() {
assert!(match_criteria_host("HOST web", "web", "alice", "web"));
assert!(match_criteria_host("Host web", "web", "alice", "web"));
assert!(match_criteria_host("host web", "web", "alice", "web"));
}
#[test]
fn match_criteria_host_multiple_host_clauses() {
assert!(match_criteria_host(
"host web host db",
"web",
"alice",
"web"
));
assert!(match_criteria_host(
"host web host db",
"db",
"alice",
"web"
));
assert!(!match_criteria_host(
"host web host db",
"other",
"alice",
"web"
));
}
#[test]
fn match_criteria_host_unknown_keyword_before_host() {
assert!(match_criteria_host(
"user alice host web",
"web",
"alice",
"web"
));
assert!(!match_criteria_host(
"user alice host web",
"web",
"bob",
"web"
));
}
#[test]
fn collapse_double_percent_empty() {
assert_eq!(collapse_double_percent(""), "");
}
#[test]
fn collapse_double_percent_single_percent() {
assert_eq!(collapse_double_percent("%"), "%");
}
#[test]
fn collapse_double_percent_triple() {
assert_eq!(collapse_double_percent("%%%"), "%%");
}
#[test]
fn expand_env_vars_with_spaces_in_value() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_SPACED", "hello world") };
assert_eq!(expand_env_vars("${TORIDE_SPACED}"), "hello world");
}
#[test]
fn expand_env_vars_with_equals_in_value() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_EQUALS", "a=b") };
assert_eq!(expand_env_vars("${TORIDE_EQUALS}"), "a=b");
}
#[test]
fn expand_env_vars_with_special_chars() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_SPECIAL", "hello@world.com") };
assert_eq!(expand_env_vars("${TORIDE_SPECIAL}"), "hello@world.com");
}
#[test]
fn expand_env_vars_undefined_returns_empty() {
assert_eq!(expand_env_vars("${TORIDE_DEFINITELY_NOT_SET_12345}"), "");
}
#[test]
fn expand_env_vars_with_dollar_sign_in_value() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_DOLLAR", "$NOT_A_VAR") };
assert_eq!(expand_env_vars("${TORIDE_DOLLAR}"), "$NOT_A_VAR");
}
#[test]
fn expand_tilde_with_trailing_slash() {
let result = expand_tilde_and_env("~/");
assert!(!result.starts_with("~/"));
assert!(result.ends_with('/'));
}
#[test]
fn expand_tilde_with_deep_path() {
let result = expand_tilde_and_env("~/.ssh/keys/backup");
assert!(!result.starts_with("~/"));
assert!(result.ends_with("/.ssh/keys/backup"));
}
#[test]
fn expand_tilde_not_at_start() {
assert_eq!(expand_tilde_and_env("path/~user"), "path/~user");
}
#[test]
fn expand_tokens_all_tokens_combined() {
let ctx = TokenContext {
host: "example.com",
home_dir: "/home/alice",
local_hostname: "myhost",
remote_user: "deploy",
local_user: "alice",
port: "2222",
canonical_host: "example.com",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
let result = expand_tokens("%h:%p:%u:%d:%l:%r", &ctx);
assert_eq!(result, "example.com:2222:alice:/home/alice:myhost:deploy");
}
#[test]
fn expand_tokens_with_path() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "l",
remote_user: "r",
local_user: "u",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("/keys/%h/%u", &ctx), "/keys/h/u");
}
#[test]
fn expand_tokens_with_env_var() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "l",
remote_user: "r",
local_user: "u",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%d/.ssh", &ctx), "/home/.ssh");
}
#[test]
fn expand_tokens_canonical_host() {
let ctx = TokenContext {
host: "alias",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "real.host.com",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%H", &ctx), "real.host.com");
}
#[test]
fn expand_tokens_identity_file_token() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "alice",
port: "22",
canonical_host: "h",
identity_file: Some("~/.ssh/id_ed25519_work"),
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%i", &ctx), "alice");
}
#[test]
fn expand_tokens_identity_file_without_context() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "local",
remote_user: "remote",
local_user: "alice",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%i", &ctx), "alice");
}
#[test]
fn expand_tokens_local_hostname_short() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "myhost.example.com",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%L", &ctx), "myhost");
}
#[test]
fn expand_tokens_local_hostname_short_no_dot() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "myhost",
remote_user: "remote",
local_user: "user",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%L", &ctx), "myhost");
}
#[test]
fn expand_tokens_remote_user_alias() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "l",
remote_user: "deploy",
local_user: "u",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%T", &ctx), "deploy");
}
#[test]
fn expand_tokens_connection_hash() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "l",
remote_user: "r",
local_user: "u",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
let hash = expand_tokens("%C", &ctx);
assert_ne!(hash, "");
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn expand_tokens_all_new_tokens() {
let ctx = TokenContext {
host: "alias",
home_dir: "/home/alice",
local_hostname: "myhost.example.com",
remote_user: "deploy",
local_user: "alice",
port: "2222",
canonical_host: "real.com",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
let result = expand_tokens("%H:%i:%L:%T:%C", &ctx);
let parts: Vec<&str> = result.split(':').collect();
assert_eq!(parts[0], "real.com"); assert_eq!(parts[1], "alice"); assert_eq!(parts[2], "myhost"); assert_eq!(parts[3], "deploy"); assert_ne!(parts[4], ""); }
#[test]
fn match_criteria_host_with_port() {
assert!(match_criteria_host(
"host [::1]:22",
"[::1]:22",
"alice",
"[::1]:22"
));
}
#[test]
fn match_criteria_host_with_wildcard_port() {
assert!(match_criteria_host(
"host *",
"example.com:22",
"alice",
"example.com:22"
));
}
#[test]
fn match_criteria_host_empty_criteria() {
assert!(!match_criteria_host("", "host", "alice", "host"));
}
#[test]
fn match_criteria_host_only_unknown_keyword() {
assert!(!match_criteria_host("exec true", "host", "bob", "host"));
}
#[test]
fn simple_glob_match_empty_pattern() {
assert!(simple_glob_match("", ""));
assert!(!simple_glob_match("a", ""));
}
#[test]
fn simple_glob_match_star_only() {
assert!(simple_glob_match("", "*"));
assert!(simple_glob_match("anything", "*"));
}
#[test]
fn simple_glob_match_question_only() {
assert!(!simple_glob_match("", "?"));
assert!(simple_glob_match("a", "?")); assert!(!simple_glob_match("ab", "?")); }
#[test]
fn match_criteria_host_negation() {
assert!(!match_criteria_host(
"host *,!badhost",
"badhost",
"alice",
"badhost"
));
assert!(match_criteria_host(
"host *,!badhost",
"goodhost",
"alice",
"goodhost"
));
}
#[test]
fn expand_env_vars_empty_braces() {
assert_eq!(expand_env_vars("${}"), "");
}
#[test]
fn expand_env_vars_trailing_dollar() {
assert_eq!(expand_env_vars("test$"), "test$");
}
#[test]
fn expand_env_vars_no_braces_in_path() {
let _lock = acquire_env_lock();
let _g = unsafe { TempEnv::set("TORIDE_PVAR", "/usr/local") };
assert_eq!(expand_env_vars("$TORIDE_PVAR/bin"), "/usr/local/bin");
}
#[test]
fn expand_tokens_unknown_x() {
let ctx = TokenContext {
host: "h",
home_dir: "d",
local_hostname: "l",
remote_user: "r",
local_user: "u",
port: "p",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%X", &ctx), "%X");
}
#[test]
fn expand_tokens_double_percent_preserved() {
let ctx = TokenContext {
host: "h",
home_dir: "d",
local_hostname: "l",
remote_user: "r",
local_user: "u",
port: "p",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("%%", &ctx), "%%");
}
#[test]
fn expand_tokens_empty() {
let ctx = TokenContext {
host: "h",
home_dir: "d",
local_hostname: "l",
remote_user: "r",
local_user: "u",
port: "p",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens("", &ctx), "");
}
#[test]
fn expand_tokens_skip_i_preserves_percent_i() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "l",
remote_user: "r",
local_user: "alice",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(
expand_tokens_skip_i("~/.ssh/work/%i", &ctx),
"~/.ssh/work/%i"
);
}
#[test]
fn expand_tokens_skip_i_expands_other_tokens() {
let ctx = TokenContext {
host: "example.com",
home_dir: "/home",
local_hostname: "l",
remote_user: "r",
local_user: "alice",
port: "22",
canonical_host: "example.com",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(
expand_tokens_skip_i("~/.ssh/%h/%i", &ctx),
"~/.ssh/example.com/%i"
);
}
#[test]
fn expand_tokens_skip_i_expands_u_but_not_i() {
let ctx = TokenContext {
host: "h",
home_dir: "/home",
local_hostname: "l",
remote_user: "r",
local_user: "alice",
port: "22",
canonical_host: "h",
identity_file: None,
local_host_key: "",
jump_host: "",
remote_host_key: "",
};
assert_eq!(expand_tokens_skip_i("%u/%i", &ctx), "alice/%i");
}
#[test]
fn collapse_double_pct_basic() {
assert_eq!(collapse_double_percent("%%"), "%");
assert_eq!(collapse_double_percent("100%%"), "100%");
assert_eq!(collapse_double_percent("%%test"), "%test");
}
#[test]
fn collapse_double_pct_none() {
assert_eq!(collapse_double_percent("test"), "test");
assert_eq!(collapse_double_percent("%h"), "%h");
}
#[test]
fn collapse_double_pct_empty() {
assert_eq!(collapse_double_percent(""), "");
}
#[test]
fn match_criteria_user_basic() {
assert!(match_criteria_host("user alice", "host", "alice", "host"));
assert!(!match_criteria_host("user alice", "host", "bob", "host"));
}
#[test]
fn match_criteria_user_case_insensitive() {
assert!(match_criteria_host("user Alice", "host", "alice", "host"));
assert!(match_criteria_host("user ALICE", "host", "alice", "host"));
assert!(match_criteria_host("user alice", "host", "ALICE", "host"));
}
#[test]
fn match_criteria_user_multiple_names() {
assert!(match_criteria_host(
"user alice,bob",
"host",
"alice",
"host"
));
assert!(match_criteria_host("user alice,bob", "host", "bob", "host"));
assert!(!match_criteria_host(
"user alice,bob",
"host",
"charlie",
"host"
));
}
#[test]
fn match_criteria_user_with_host() {
assert!(match_criteria_host(
"user alice host web",
"web",
"alice",
"web",
));
assert!(!match_criteria_host(
"user alice host web",
"web",
"bob",
"web",
));
assert!(!match_criteria_host(
"user alice host web",
"db",
"alice",
"web",
));
}
#[test]
fn match_criteria_originalhost_basic() {
assert!(match_criteria_host(
"originalhost web",
"canonical.web",
"alice",
"web",
));
assert!(!match_criteria_host(
"originalhost web",
"canonical.web",
"alice",
"db",
));
}
#[test]
fn match_criteria_originalhost_wildcard() {
assert!(match_criteria_host(
"originalhost *.example.com",
"canonical.example.com",
"alice",
"web.example.com",
));
}
#[test]
fn match_criteria_originalhost_with_host() {
assert!(match_criteria_host(
"originalhost web host canonical.web",
"canonical.web",
"alice",
"web",
));
assert!(!match_criteria_host(
"originalhost web host canonical.web",
"canonical.web",
"alice",
"other",
));
}
#[test]
fn match_criteria_originalhost_negation() {
assert!(!match_criteria_host(
"originalhost *,!badhost",
"canonical",
"alice",
"badhost",
));
assert!(match_criteria_host(
"originalhost *,!badhost",
"canonical",
"alice",
"goodhost",
));
}
#[test]
fn is_canonicalize_enabled_yes() {
let resolved = ResolvedHost {
alias: "test".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![("CanonicalizeHostname".into(), "yes".into())],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
assert!(is_canonicalize_enabled(&resolved));
}
#[test]
fn is_canonicalize_enabled_always() {
let resolved = ResolvedHost {
alias: "test".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![("CanonicalizeHostname".into(), "always".into())],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
assert!(is_canonicalize_enabled(&resolved));
}
#[test]
fn is_canonicalize_enabled_no() {
let resolved = ResolvedHost {
alias: "test".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![("CanonicalizeHostname".into(), "no".into())],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
assert!(!is_canonicalize_enabled(&resolved));
}
#[test]
fn is_canonicalize_enabled_missing() {
let resolved = ResolvedHost {
alias: "test".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
assert!(!is_canonicalize_enabled(&resolved));
}
#[test]
fn is_canonicalize_enabled_case_insensitive() {
let resolved = ResolvedHost {
alias: "test".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![("canonicalizehostname".into(), "YES".into())],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
assert!(is_canonicalize_enabled(&resolved));
}
#[test]
fn expand_resolved_certificate_file() {
let mut resolved = ResolvedHost {
alias: "h".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec!["%d/.ssh/%h-cert.pub".into()],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![("CertificateFile".into(), "%d/.ssh/%h-cert.pub".into())],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
expand_resolved(&mut resolved, "example", Path::new("/tmp"));
let cert = &resolved.certificate_files[0];
assert!(!cert.contains("%d"));
assert!(!cert.contains("%h"));
assert!(cert.contains("example-cert.pub"));
}
#[test]
fn expand_resolved_control_path() {
let mut resolved = ResolvedHost {
alias: "h".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: Some("/tmp/ssh-%h-%p".into()),
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![("ControlPath".into(), "/tmp/ssh-%h-%p".into())],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
expand_resolved(&mut resolved, "example", Path::new("/tmp"));
let cp = resolved.control_path.as_deref().unwrap();
assert!(!cp.contains("%h"));
assert!(cp.contains("example"));
}
#[test]
fn expand_resolved_user_known_hosts_file() {
let mut resolved = ResolvedHost {
alias: "h".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![("UserKnownHostsFile".into(), "%d/.ssh/known_hosts_%h".into())],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
expand_resolved(&mut resolved, "myhost", Path::new("/tmp"));
let val = &resolved.directives[0].1;
assert!(!val.contains("%d"));
assert!(!val.contains("%h"));
assert!(val.contains("myhost"));
}
#[test]
fn expand_resolved_identity_agent() {
let mut resolved = ResolvedHost {
alias: "h".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: Some("${SSH_AUTH_SOCK}".into()),
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![("IdentityAgent".into(), "${SSH_AUTH_SOCK}".into())],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
expand_resolved(&mut resolved, "h", Path::new("/tmp"));
let ia = resolved.identity_agent.as_deref().unwrap();
assert!(!ia.contains("${SSH_AUTH_SOCK}"));
}
#[test]
fn resolve_pass_default_canonicalized_false() {
use super::ast;
let ast = ast::parse("Host example\n HostName example.com\n");
let resolved = resolve_pass(&ast, "example", "example", "user");
assert!(!resolved.canonicalized);
assert_eq!(resolved.host_name.as_deref(), Some("example.com"));
}
#[tokio::test]
async fn resolve_include_cycle_detected() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"Include chain_a\nHost main\n User root\n",
)
.unwrap();
std::fs::write(
ssh_dir.join("chain_a"),
"Host alpha\n User alice\nInclude chain_b\n",
)
.unwrap();
std::fs::write(
ssh_dir.join("chain_b"),
"Host beta\n User bob\nInclude chain_a\n",
)
.unwrap();
let result = resolve(ssh_dir, "alpha", None).await;
assert!(result.is_err(), "should detect include cycle");
match result.unwrap_err() {
toride_ssh_core::Error::ConfigIncludeCycle(path) => {
assert!(
path.contains("chain_a"),
"cycle error should mention the offending file, got: {path}"
);
}
other => panic!("expected ConfigIncludeCycle error, got: {other}"),
}
}
#[tokio::test]
async fn resolve_include_self_referencing() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"Include config\nHost self\n User me\n",
)
.unwrap();
let result = resolve(ssh_dir, "self", None).await;
assert!(result.is_err(), "should detect self-referencing include");
match result.unwrap_err() {
toride_ssh_core::Error::ConfigIncludeCycle(_) => {}
other => panic!("expected ConfigIncludeCycle error, got: {other}"),
}
}
#[tokio::test]
async fn resolve_include_chain_without_cycle() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"Include layer1\nHost main\n User root\n",
)
.unwrap();
std::fs::write(
ssh_dir.join("layer1"),
"Include layer2\nHost web\n User deploy\n",
)
.unwrap();
std::fs::write(ssh_dir.join("layer2"), "Host db\n User admin\n").unwrap();
let resolved = resolve(ssh_dir, "db", None).await;
assert!(resolved.is_ok(), "valid include chain should not error");
let resolved = resolved.unwrap();
assert_eq!(resolved.user.as_deref(), Some("admin"));
}
#[tokio::test]
async fn resolve_include_nonexistent_file() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"Include does_not_exist\nHost test\n User alice\n",
)
.unwrap();
let resolved = resolve(ssh_dir, "test", None).await;
assert!(
resolved.is_ok(),
"missing include file should be silently skipped"
);
assert_eq!(resolved.unwrap().user.as_deref(), Some("alice"));
}
#[tokio::test]
async fn resolve_host_certificate_files_in_directives() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
HostName example.com
CertificateFile ~/.ssh/id_ed25519-cert.pub
CertificateFile ~/.ssh/id_rsa-cert.pub
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
let cert_files: Vec<&str> = resolved
.directives
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case("CertificateFile"))
.map(|(_, v)| v.as_str())
.collect();
assert_eq!(cert_files.len(), 2);
assert!(cert_files[0].contains("id_ed25519-cert.pub"));
assert!(cert_files[1].contains("id_rsa-cert.pub"));
}
#[tokio::test]
async fn resolve_host_identity_agent_in_directives() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
HostName example.com
IdentityAgent /run/user/1000/ssh-agent.sock
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
let agent = resolved
.directives
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("IdentityAgent"));
assert!(agent.is_some(), "IdentityAgent should be in directives");
assert!(agent.unwrap().1.contains("ssh-agent.sock"));
}
#[tokio::test]
async fn resolve_host_proxy_jump() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path().join(".ssh");
std::fs::create_dir_all(&ssh_dir).unwrap();
std::fs::write(
ssh_dir.join("config"),
"\
Host target
HostName target.example.com
ProxyJump jumphost
",
)
.unwrap();
let resolved = resolve(&ssh_dir, "target", None).await.unwrap();
let pj = resolved
.directives
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("ProxyJump"));
assert!(pj.is_some(), "ProxyJump should be in directives list");
assert_eq!(pj.unwrap().1, "jumphost");
}
#[tokio::test]
async fn resolve_host_identity_files_accumulated() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host multi
IdentityFile ~/.ssh/id_work
Host *
IdentityFile ~/.ssh/id_personal
",
)
.unwrap();
let resolved = resolve(ssh_dir, "multi", None).await.unwrap();
assert_eq!(resolved.identity_files.len(), 2);
assert!(resolved.identity_files[0].contains("id_work"));
assert!(resolved.identity_files[1].contains("id_personal"));
}
#[tokio::test]
async fn resolve_host_first_match_wins_for_user() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
User first_user
Host *
User second_user
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(resolved.user.as_deref(), Some("first_user"));
}
#[tokio::test]
async fn resolve_host_port_and_hostname() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
HostName custom.example.com
Port 2222
User admin
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(resolved.host_name.as_deref(), Some("custom.example.com"));
assert_eq!(resolved.port, Some(2222));
assert_eq!(resolved.user.as_deref(), Some("admin"));
}
#[tokio::test]
async fn resolve_host_wildcard_defaults() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host *
User default_user
Port 22
",
)
.unwrap();
let resolved = resolve(ssh_dir, "anything", None).await.unwrap();
assert_eq!(resolved.user.as_deref(), Some("default_user"));
assert_eq!(resolved.port, Some(22));
}
#[tokio::test]
async fn resolve_host_empty_config() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(ssh_dir.join("config"), "").unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert!(resolved.user.is_none());
assert!(resolved.host_name.is_none());
assert!(resolved.port.is_none());
assert_eq!(resolved.identity_files, Vec::<String>::new());
}
#[tokio::test]
async fn resolve_host_no_config_file() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert!(resolved.user.is_none());
assert_eq!(
resolved.directives,
[] as [(std::string::String, std::string::String); 0]
);
}
#[tokio::test]
async fn resolve_host_forward_agent_populated() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
ForwardAgent yes
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(
resolved.forward_agent.as_deref(),
Some("yes"),
"ForwardAgent must be populated in resolved.forward_agent"
);
}
#[tokio::test]
async fn resolve_host_forward_agent_first_match_wins() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
ForwardAgent yes
Host *
ForwardAgent no
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(
resolved.forward_agent.as_deref(),
Some("yes"),
"ForwardAgent uses first-match-wins semantics"
);
}
#[tokio::test]
async fn resolve_host_add_keys_to_agent() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
AddKeysToAgent confirm
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(resolved.add_keys_to_agent.as_deref(), Some("confirm"));
}
#[tokio::test]
async fn resolve_host_use_keychain() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
UseKeychain yes
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(resolved.use_keychain.as_deref(), Some("yes"));
}
#[tokio::test]
async fn resolve_host_control_master_and_path_and_persist() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
ControlMaster auto
ControlPath /tmp/ssh-%h-%p
ControlPersist 10m
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(resolved.control_master.as_deref(), Some("auto"));
assert!(
resolved.control_path.as_deref().unwrap().contains("myhost"),
"ControlPath should have %h expanded"
);
assert!(!resolved.control_path.as_deref().unwrap().contains("%h"));
assert_eq!(resolved.control_persist.as_deref(), Some("10m"));
}
#[tokio::test]
async fn resolve_host_local_forwards_accumulated() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
LocalForward 8080 localhost:80
LocalForward 9090 localhost:90
Host *
LocalForward 3000 localhost:3000
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(resolved.local_forwards.len(), 3);
assert_eq!(resolved.local_forwards[0], "8080 localhost:80");
assert_eq!(resolved.local_forwards[1], "9090 localhost:90");
assert_eq!(resolved.local_forwards[2], "3000 localhost:3000");
}
#[tokio::test]
async fn resolve_host_remote_forwards_accumulated() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
RemoteForward 8080 localhost:80
RemoteForward 9090 localhost:90
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(resolved.remote_forwards.len(), 2);
assert_eq!(resolved.remote_forwards[0], "8080 localhost:80");
assert_eq!(resolved.remote_forwards[1], "9090 localhost:90");
}
#[tokio::test]
async fn resolve_host_dynamic_forwards_accumulated() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
DynamicForward 1080
DynamicForward 1081
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(resolved.dynamic_forwards.len(), 2);
assert_eq!(resolved.dynamic_forwards[0], "1080");
assert_eq!(resolved.dynamic_forwards[1], "1081");
}
#[test]
fn expand_resolved_local_forwards_with_tokens() {
let mut resolved = ResolvedHost {
alias: "h".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec!["8080 %h:80".into()],
remote_forwards: vec![],
dynamic_forwards: vec![],
directives: vec![],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
expand_resolved(&mut resolved, "example.com", Path::new("/tmp"));
let lf = &resolved.local_forwards[0];
assert!(!lf.contains("%h"), "LocalForward tokens should be expanded");
assert!(lf.contains("example.com"));
}
#[test]
fn expand_resolved_remote_forwards_with_tokens() {
let mut resolved = ResolvedHost {
alias: "h".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec!["8080 %h:80".into()],
dynamic_forwards: vec![],
directives: vec![],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
expand_resolved(&mut resolved, "example.com", Path::new("/tmp"));
let rf = &resolved.remote_forwards[0];
assert!(
!rf.contains("%h"),
"RemoteForward tokens should be expanded"
);
assert!(rf.contains("example.com"));
}
#[test]
fn expand_resolved_dynamic_forwards_with_tokens() {
let mut resolved = ResolvedHost {
alias: "h".into(),
host_name: None,
user: None,
port: None,
identity_files: vec![],
certificate_files: vec![],
proxy_jump: None,
identity_agent: None,
forward_agent: None,
add_keys_to_agent: None,
use_keychain: None,
control_master: None,
control_path: None,
control_persist: None,
local_forwards: vec![],
remote_forwards: vec![],
dynamic_forwards: vec!["%p".into()],
directives: vec![],
user_known_hosts_file: None,
identities_only: None,
canonicalized: false,
unevaluated_match_warnings: vec![],
gssapi_authentication: None,
gssapi_delegate_credentials: None,
gssapi_server_identity: None,
gssapi_client_identity: None,
};
expand_resolved(&mut resolved, "h", Path::new("/tmp"));
let df = &resolved.dynamic_forwards[0];
assert!(
!df.contains("%p"),
"DynamicForward tokens should be expanded"
);
}
#[tokio::test]
async fn resolve_canonicalize_hostname_second_pass_no_hostname() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host *
CanonicalizeHostname yes
Host myalias
User first_user
Port 2222
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myalias", None).await.unwrap();
assert!(
resolved.canonicalized,
"second-pass resolution should set canonicalized = true"
);
assert_eq!(resolved.alias, "myalias");
assert_eq!(
resolved.user.as_deref(),
Some("first_user"),
"second pass should apply User from the matching Host block"
);
assert_eq!(
resolved.port,
Some(2222),
"second pass should apply Port from the matching Host block"
);
assert!(
resolved.host_name.is_none(),
"host_name remains None when no HostName directive is set"
);
}
#[tokio::test]
async fn resolve_host_local_forwards_dedup() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
LocalForward 8080 localhost:80
Host *
LocalForward 8080 localhost:80
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(
resolved.local_forwards.len(),
1,
"duplicate forwards should be deduped"
);
}
#[test]
fn glob_paths_simple_star_conf() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("alpha.conf"), "").unwrap();
std::fs::write(base.join("beta.conf"), "").unwrap();
std::fs::write(base.join("gamma.txt"), "").unwrap();
let pattern = format!("{}/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(result.len(), 2, "*.conf should match exactly two files");
assert!(result[0].ends_with("alpha.conf"));
assert!(result[1].ends_with("beta.conf"));
}
#[test]
fn glob_paths_exact_filename() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("specific.conf"), "").unwrap();
std::fs::write(base.join("other.conf"), "").unwrap();
let pattern = format!("{}/specific.conf", base.display());
let result = glob_paths(&pattern);
assert_eq!(result.len(), 1);
assert!(result[0].ends_with("specific.conf"));
}
#[test]
fn glob_paths_no_matches_found() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("readme.md"), "").unwrap();
std::fs::write(base.join("data.json"), "").unwrap();
let pattern = format!("{}/*.conf", base.display());
let result = glob_paths(&pattern);
assert!(
result.is_empty(),
"*.conf should match nothing in a dir with only .md and .json"
);
}
#[test]
fn glob_paths_nonexistent_directory() {
let result = glob_paths("/nonexistent/path/*.conf");
assert_eq!(result, Vec::<std::path::PathBuf>::new());
}
#[test]
fn glob_paths_nested_directory_pattern() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let sub = base.join("configs");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("web.conf"), "").unwrap();
std::fs::write(sub.join("db.conf"), "").unwrap();
std::fs::write(sub.join("notes.txt"), "").unwrap();
std::fs::write(base.join("parent.conf"), "").unwrap();
let pattern = format!("{}/configs/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(
result.len(),
2,
"should only match .conf files inside configs/"
);
assert!(result[0].ends_with("db.conf"));
assert!(result[1].ends_with("web.conf"));
}
#[test]
fn glob_paths_nested_deep_directory_pattern() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let nested = base.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("deep.conf"), "").unwrap();
std::fs::write(nested.join("deep.txt"), "").unwrap();
std::fs::write(base.join("a").join("shallow.conf"), "").unwrap();
let pattern = format!("{}/a/b/*.conf", base.display());
let result = glob_paths(&pattern);
assert_eq!(result.len(), 1, "should only match .conf in a/b/");
assert!(result[0].ends_with("deep.conf"));
}
#[test]
fn glob_paths_question_mark_wildcard() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("a.conf"), "").unwrap();
std::fs::write(base.join("ab.conf"), "").unwrap();
std::fs::write(base.join("abc.conf"), "").unwrap();
let pattern = format!("{}/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(result.len(), 3);
}
#[test]
fn glob_paths_question_mark_single_char() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("a.conf"), "").unwrap();
std::fs::write(base.join("b.conf"), "").unwrap();
std::fs::write(base.join("ab.conf"), "").unwrap();
let pattern = format!("{}/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(result.len(), 3);
let pattern_q = format!("{}/?.conf", base.display());
let mut result_q = glob_paths(&pattern_q);
result_q.sort();
assert_eq!(
result_q.len(),
2,
"?.conf should match only single-char names"
);
assert!(result_q[0].ends_with("a.conf"));
assert!(result_q[1].ends_with("b.conf"));
}
#[test]
fn glob_paths_empty_directory() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let pattern = format!("{}/*", base.display());
let result = glob_paths(&pattern);
assert_eq!(result, Vec::<std::path::PathBuf>::new());
}
#[test]
fn glob_paths_star_matches_all_files() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("one.txt"), "").unwrap();
std::fs::write(base.join("two.conf"), "").unwrap();
std::fs::write(base.join("three.md"), "").unwrap();
let pattern = format!("{}/*", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(result.len(), 3, "* should match all three files");
}
#[test]
fn glob_paths_results_are_sorted() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("zebra.conf"), "").unwrap();
std::fs::write(base.join("alpha.conf"), "").unwrap();
std::fs::write(base.join("middle.conf"), "").unwrap();
let pattern = format!("{}/*.conf", base.display());
let result = glob_paths(&pattern);
assert_eq!(result.len(), 3);
assert!(result[0].ends_with("alpha.conf"));
assert!(result[1].ends_with("middle.conf"));
assert!(result[2].ends_with("zebra.conf"));
}
#[test]
fn glob_paths_double_star_conf_recursive() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("root.conf"), "").unwrap();
std::fs::write(base.join("root.txt"), "").unwrap();
let sub1 = base.join("sub1");
std::fs::create_dir(&sub1).unwrap();
std::fs::write(sub1.join("level1.conf"), "").unwrap();
std::fs::write(sub1.join("level1.txt"), "").unwrap();
let sub2 = sub1.join("sub2");
std::fs::create_dir(&sub2).unwrap();
std::fs::write(sub2.join("level2.conf"), "").unwrap();
let pattern = format!("{}/**/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(
result.len(),
3,
"**/*.conf should match .conf at all levels"
);
assert!(result[0].ends_with("root.conf"));
assert!(result[1].ends_with("level1.conf"));
assert!(result[2].ends_with("level2.conf"));
}
#[test]
fn glob_paths_double_star_at_start() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("a.conf"), "").unwrap();
let nested = base.join("deep");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("b.conf"), "").unwrap();
let pattern = format!("{}/**/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(result.len(), 2);
assert!(result[0].ends_with("a.conf"));
assert!(result[1].ends_with("b.conf"));
}
#[test]
fn glob_paths_double_star_in_middle() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let config_d = base.join("config.d");
std::fs::create_dir(&config_d).unwrap();
std::fs::write(config_d.join("top.conf"), "").unwrap();
let hosts = config_d.join("hosts");
std::fs::create_dir(&hosts).unwrap();
std::fs::write(hosts.join("web.conf"), "").unwrap();
std::fs::write(hosts.join("db.conf"), "").unwrap();
std::fs::write(base.join("outside.conf"), "").unwrap();
let pattern = format!("{}/config.d/**/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(result.len(), 3, "should match all .conf under config.d/");
assert!(result[0].ends_with("db.conf"));
assert!(result[1].ends_with("web.conf"));
assert!(result[2].ends_with("top.conf"));
}
#[test]
fn glob_paths_double_star_no_matches() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("readme.md"), "").unwrap();
let sub = base.join("notes");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("data.json"), "").unwrap();
let pattern = format!("{}/**/*.conf", base.display());
let result = glob_paths(&pattern);
assert!(
result.is_empty(),
"no .conf files should yield empty result"
);
}
#[test]
fn glob_paths_double_star_with_subdir_in_suffix() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let sub1 = base.join("sub");
std::fs::create_dir(&sub1).unwrap();
std::fs::write(sub1.join("a.conf"), "").unwrap();
std::fs::write(sub1.join("a.txt"), "").unwrap();
let other = base.join("other");
std::fs::create_dir(&other).unwrap();
let sub2 = other.join("sub");
std::fs::create_dir(&sub2).unwrap();
std::fs::write(sub2.join("b.conf"), "").unwrap();
std::fs::write(other.join("c.conf"), "").unwrap();
let pattern = format!("{}/**/sub/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(
result.len(),
2,
"only .conf files inside a 'sub' dir should match"
);
assert!(result[0].ends_with("b.conf"));
assert!(result[1].ends_with("a.conf"));
}
#[test]
fn glob_paths_double_star_trailing() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("file.txt"), "").unwrap();
let sub = base.join("dir");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("nested.txt"), "").unwrap();
let pattern = format!("{}/**", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert!(
result.len() >= 2,
"trailing ** should match all entries recursively"
);
assert!(result.iter().any(|p| p.ends_with("file.txt")));
assert!(result.iter().any(|p| p.ends_with("nested.txt")));
}
#[test]
fn glob_paths_no_double_star_unchanged() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("alpha.conf"), "").unwrap();
std::fs::write(base.join("beta.conf"), "").unwrap();
std::fs::write(base.join("gamma.txt"), "").unwrap();
let pattern = format!("{}/*.conf", base.display());
let mut result = glob_paths(&pattern);
result.sort();
assert_eq!(result.len(), 2);
assert!(result[0].ends_with("alpha.conf"));
assert!(result[1].ends_with("beta.conf"));
}
#[test]
fn glob_paths_double_star_empty_tree() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let pattern = format!("{}/**/*.conf", base.display());
let result = glob_paths(&pattern);
assert_eq!(result, Vec::<std::path::PathBuf>::new());
}
#[test]
fn glob_paths_double_star_zero_levels() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("solo.conf"), "").unwrap();
let pattern = format!("{}/**/*.conf", base.display());
let result = glob_paths(&pattern);
assert_eq!(result.len(), 1, "** must match zero directory levels");
assert!(result[0].ends_with("solo.conf"));
}
#[tokio::test]
async fn resolve_gssapi_authentication() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
HostName example.com
GSSAPIAuthentication yes
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(
resolved.gssapi_authentication.as_deref(),
Some("yes"),
"GSSAPIAuthentication should be populated"
);
assert!(resolved.gssapi_delegate_credentials.is_none());
assert!(resolved.gssapi_server_identity.is_none());
assert!(resolved.gssapi_client_identity.is_none());
}
#[tokio::test]
async fn resolve_gssapi_all_fields() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host kdc-host
HostName kdc.example.com
GSSAPIAuthentication yes
GSSAPIDelegateCredentials yes
GSSAPIServerIdentity example.com
GSSAPIClientIdentity alice@EXAMPLE.COM
",
)
.unwrap();
let resolved = resolve(ssh_dir, "kdc-host", None).await.unwrap();
assert_eq!(resolved.gssapi_authentication.as_deref(), Some("yes"));
assert_eq!(resolved.gssapi_delegate_credentials.as_deref(), Some("yes"));
assert_eq!(
resolved.gssapi_server_identity.as_deref(),
Some("example.com")
);
assert_eq!(
resolved.gssapi_client_identity.as_deref(),
Some("alice@EXAMPLE.COM")
);
}
#[tokio::test]
async fn resolve_gssapi_first_match_wins() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
GSSAPIAuthentication yes
Host *
GSSAPIAuthentication no
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
assert_eq!(
resolved.gssapi_authentication.as_deref(),
Some("yes"),
"GSSAPIAuthentication uses first-match-wins semantics"
);
}
#[tokio::test]
async fn resolve_gssapi_wildcard_defaults() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host *
GSSAPIAuthentication no
GSSAPIServerIdentity corp.example.com
",
)
.unwrap();
let resolved = resolve(ssh_dir, "anything", None).await.unwrap();
assert_eq!(resolved.gssapi_authentication.as_deref(), Some("no"));
assert_eq!(
resolved.gssapi_server_identity.as_deref(),
Some("corp.example.com")
);
}
#[tokio::test]
async fn resolve_gssapi_in_directives_list() {
let dir = tempfile::tempdir().unwrap();
let ssh_dir = dir.path();
std::fs::write(
ssh_dir.join("config"),
"\
Host myhost
HostName example.com
GSSAPIAuthentication yes
GSSAPIServerIdentity example.com
",
)
.unwrap();
let resolved = resolve(ssh_dir, "myhost", None).await.unwrap();
let gssapi_directives: Vec<(&str, &str)> = resolved
.directives
.iter()
.filter(|(k, _)| {
k.eq_ignore_ascii_case("GSSAPIAuthentication")
|| k.eq_ignore_ascii_case("GSSAPIServerIdentity")
})
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
gssapi_directives.len(),
2,
"GSSAPI directives should appear in the directives list"
);
}