use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use super::ConfigError;
use super::algos::{AlgoCategory, resolve_algo_list};
use super::glob::{HostPattern, host_matches};
use super::match_block::{ExecPolicy, MatchCondition, MatchContext, all_match, parse_match_line};
use super::parser::{ParsedLine, tokenize};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StrictMode {
Yes,
No,
AcceptNew,
Ask,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RequestTty {
No,
Yes,
Force,
Auto,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AddressFamily {
Any,
Inet,
Inet6,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GatewayPorts {
No,
Yes,
ClientSpecified,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IdentityAgent {
None,
Path(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ControlMaster {
No,
Yes,
Auto,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ControlPersist {
No,
Yes,
Seconds(u64),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ObscureKeystrokeTiming {
Off,
On {
interval_ms: u32,
},
}
impl ObscureKeystrokeTiming {
pub const DEFAULT_INTERVAL_MS: u32 = 20;
pub fn default_on() -> Self {
ObscureKeystrokeTiming::On {
interval_ms: Self::DEFAULT_INTERVAL_MS,
}
}
pub fn is_on(&self) -> bool {
matches!(self, ObscureKeystrokeTiming::On { .. })
}
pub fn interval_ms(&self) -> Option<u32> {
match self {
ObscureKeystrokeTiming::On { interval_ms } => Some(*interval_ms),
ObscureKeystrokeTiming::Off => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DynamicForwardSpec {
pub bind_addr: Option<String>,
pub listen_port: u16,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LocalForwardSpec {
pub bind_addr: Option<String>,
pub listen_port: u16,
pub remote_host: String,
pub remote_port: u16,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteForwardSpec {
pub bind_addr: Option<String>,
pub remote_port: u16,
pub local_host: String,
pub local_port: u16,
}
#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct ClientOptions {
pub host_name: Option<String>,
pub port: Option<u16>,
pub user: Option<String>,
pub identity_files: Vec<String>,
pub certificate_files: Vec<String>,
pub identities_only: Option<bool>,
pub strict_host_key: Option<StrictMode>,
pub user_known_hosts: Option<String>,
pub hash_known_hosts: Option<bool>,
pub local_forwards: Vec<LocalForwardSpec>,
pub remote_forwards: Vec<RemoteForwardSpec>,
pub forward_agent: Option<bool>,
pub forward_x11: Option<bool>,
pub forward_x11_trusted: Option<bool>,
pub request_tty: Option<RequestTty>,
pub log_level: Option<u8>,
pub ciphers: Option<Vec<String>>,
pub macs: Option<Vec<String>>,
pub kex_algorithms: Option<Vec<String>>,
pub host_key_algorithms: Option<Vec<String>>,
pub pubkey_accepted_algorithms: Option<Vec<String>>,
pub ca_signature_algorithms: Option<Vec<String>>,
pub proxy_command: Option<String>,
pub proxy_jump: Option<String>,
pub compression: Option<bool>,
pub set_env: Vec<(String, String)>,
pub send_env: Vec<String>,
pub connect_timeout: Option<u32>,
pub server_alive_interval: Option<u32>,
pub server_alive_count_max: Option<u32>,
pub tcp_keep_alive: Option<bool>,
pub add_keys_to_agent: Option<bool>,
pub preferred_authentications: Option<Vec<String>>,
pub pubkey_authentication: Option<bool>,
pub number_of_password_prompts: Option<u32>,
pub batch_mode: Option<bool>,
pub exit_on_forward_failure: Option<bool>,
pub clear_all_forwardings: Option<bool>,
pub dynamic_forwards: Vec<DynamicForwardSpec>,
pub gateway_ports: Option<GatewayPorts>,
pub address_family: Option<AddressFamily>,
pub bind_address: Option<String>,
pub identity_agent: Option<IdentityAgent>,
pub control_master: Option<ControlMaster>,
pub control_path: Option<String>,
pub control_persist: Option<ControlPersist>,
pub obscure_keystroke_timing: Option<ObscureKeystrokeTiming>,
}
#[derive(Clone, Debug)]
pub(crate) enum Block {
Host {
patterns: Vec<HostPattern>,
opts: ClientOptions,
},
Match {
conditions: Vec<MatchCondition>,
opts: ClientOptions,
},
}
impl Block {
fn opts(&self) -> &ClientOptions {
match self {
Block::Host { opts, .. } | Block::Match { opts, .. } => opts,
}
}
fn opts_mut(&mut self) -> &mut ClientOptions {
match self {
Block::Host { opts, .. } | Block::Match { opts, .. } => opts,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct SshClientConfig {
pub(crate) blocks: Vec<Block>,
enable_match_exec: bool,
}
impl SshClientConfig {
pub fn parse(src: &str) -> Result<Self, ConfigError> {
let lines = tokenize(src)?;
let blocks = parse_blocks(lines)?;
Ok(SshClientConfig {
blocks,
enable_match_exec: false,
})
}
#[cfg(feature = "std")]
pub fn load<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ConfigError> {
let lines = super::include::tokenize_file_with_includes(path.as_ref(), 0)?;
let blocks = parse_blocks(lines)?;
Ok(SshClientConfig {
blocks,
enable_match_exec: false,
})
}
#[cfg(feature = "std")]
pub fn load_with_base<P: AsRef<std::path::Path>>(
src: &str,
base_dir: P,
) -> Result<Self, ConfigError> {
let lines = tokenize(src)?;
let expanded = super::include::expand_includes(lines, base_dir.as_ref(), 0)?;
let blocks = parse_blocks(expanded)?;
Ok(SshClientConfig {
blocks,
enable_match_exec: false,
})
}
pub fn append(&mut self, other: SshClientConfig) {
self.blocks.extend(other.blocks);
}
pub fn enable_match_exec(mut self, allow: bool) -> Self {
self.enable_match_exec = allow;
self
}
pub fn is_match_exec_enabled(&self) -> bool {
self.enable_match_exec
}
pub fn lookup(&self, host: &str) -> ClientOptions {
self.lookup_with(MatchContext {
host,
original_host: None,
user: None,
local_user: None,
..MatchContext::default()
})
}
pub fn lookup_with(&self, ctx: MatchContext<'_>) -> ClientOptions {
let policy = if self.enable_match_exec {
ExecPolicy::Allow
} else {
ExecPolicy::Deny
};
let mut out = ClientOptions::default();
for block in &self.blocks {
let matches = match block {
Block::Host { patterns, .. } => host_matches(patterns, ctx.host),
Block::Match { conditions, .. } => all_match(conditions, &ctx, policy),
};
if matches {
merge_into(&mut out, block.opts());
}
}
out
}
}
pub(crate) fn parse_blocks(lines: Vec<ParsedLine>) -> Result<Vec<Block>, ConfigError> {
let mut blocks: Vec<Block> = vec![Block::Host {
patterns: vec![HostPattern::Any],
opts: ClientOptions::default(),
}];
for line in lines {
match line.keyword.as_str() {
"host" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: "host".to_string(),
msg: "Host requires at least one pattern".into(),
});
}
blocks.push(Block::Host {
patterns: HostPattern::parse_all(&line.args),
opts: ClientOptions::default(),
});
}
"match" => {
let conditions = parse_match_line(&line.args, line.line_no)?;
blocks.push(Block::Match {
conditions,
opts: ClientOptions::default(),
});
}
"include" => {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: "Include requires file-based loading; use SshClientConfig::load() instead"
.into(),
});
}
_ => {
let current = blocks.last_mut().expect("global block always present");
apply_keyword(current.opts_mut(), &line)?;
}
}
}
Ok(blocks)
}
fn apply_keyword(opts: &mut ClientOptions, line: &ParsedLine) -> Result<(), ConfigError> {
let kw = line.keyword.as_str();
let args = &line.args;
match kw {
"hostname" => {
opts.host_name = Some(one_arg(line)?);
}
"port" => {
opts.port = Some(parse_u16(line)?);
}
"user" => {
opts.user = Some(one_arg(line)?);
}
"identityfile" => {
opts.identity_files.push(one_arg(line)?);
}
"certificatefile" => {
opts.certificate_files.push(one_arg(line)?);
}
"identitiesonly" => {
opts.identities_only = Some(parse_yes_no(line)?);
}
"stricthostkeychecking" => {
opts.strict_host_key = Some(parse_strict(line)?);
}
"userknownhostsfile" => {
opts.user_known_hosts = Some(one_arg(line)?);
}
"hashknownhosts" => {
opts.hash_known_hosts = Some(parse_yes_no(line)?);
}
"localforward" => {
opts.local_forwards.push(parse_local_forward(line)?);
}
"remoteforward" => {
opts.remote_forwards.push(parse_remote_forward(line)?);
}
"forwardagent" => {
opts.forward_agent = Some(parse_yes_no(line)?);
}
"forwardx11" => {
opts.forward_x11 = Some(parse_yes_no(line)?);
}
"forwardx11trusted" => {
opts.forward_x11_trusted = Some(parse_yes_no(line)?);
}
"requesttty" => {
opts.request_tty = Some(parse_request_tty(line)?);
}
"loglevel" => {
opts.log_level = Some(parse_log_level(line)?);
}
"ciphers" => {
opts.ciphers = Some(resolve_algo_list(
AlgoCategory::Cipher,
args,
line.line_no,
"Ciphers",
)?);
}
"macs" => {
opts.macs = Some(resolve_algo_list(
AlgoCategory::Mac,
args,
line.line_no,
"MACs",
)?);
}
"kexalgorithms" => {
opts.kex_algorithms = Some(resolve_algo_list(
AlgoCategory::Kex,
args,
line.line_no,
"KexAlgorithms",
)?);
}
"hostkeyalgorithms" => {
opts.host_key_algorithms = Some(resolve_algo_list(
AlgoCategory::HostKey,
args,
line.line_no,
"HostKeyAlgorithms",
)?);
}
"pubkeyacceptedalgorithms" | "pubkeyacceptedkeytypes" => {
opts.pubkey_accepted_algorithms = Some(resolve_algo_list(
AlgoCategory::PubkeyAccepted,
args,
line.line_no,
"PubkeyAcceptedAlgorithms",
)?);
}
"proxycommand" => {
if args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "ProxyCommand requires a command (or 'none')".into(),
});
}
if args.len() == 1 && args[0].eq_ignore_ascii_case("none") {
opts.proxy_command = None;
} else {
opts.proxy_command = Some(args.join(" "));
}
}
"proxyjump" => {
let v = one_arg(line)?;
if v.eq_ignore_ascii_case("none") {
opts.proxy_jump = None;
} else {
opts.proxy_jump = Some(v);
}
}
"compression" => {
let on = parse_yes_no(line)?;
if on && !cfg!(feature = "compress") {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: "Compression yes requires the `compress` feature, which is not \
compiled in"
.into(),
});
}
opts.compression = Some(on);
}
"setenv" => {
if args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "SetEnv requires at least one NAME=VALUE".into(),
});
}
for tok in args {
let (name, value) = tok.split_once('=').ok_or_else(|| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected NAME=VALUE, got {tok:?}"),
})?;
if name.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("empty variable name in {tok:?}"),
});
}
opts.set_env.push((name.to_string(), value.to_string()));
}
}
"sendenv" => {
if args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "SendEnv requires at least one pattern".into(),
});
}
for pat in args {
opts.send_env.push(pat.clone());
}
}
"connecttimeout" => {
let secs = parse_u32(line)?;
if secs == 0 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "ConnectTimeout must be a positive number of seconds".into(),
});
}
opts.connect_timeout = Some(secs);
}
"serveraliveinterval" => {
opts.server_alive_interval = Some(parse_u32(line)?);
}
"serveralivecountmax" => {
opts.server_alive_count_max = Some(parse_u32(line)?);
}
"tcpkeepalive" => {
opts.tcp_keep_alive = Some(parse_yes_no(line)?);
}
"addkeystoagent" => {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"yes" | "true" | "on" => opts.add_keys_to_agent = Some(true),
"no" | "false" | "off" => opts.add_keys_to_agent = Some(false),
"confirm" | "ask" => {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: "AddKeysToAgent confirm/ask requires interactive confirmation, \
which is not implemented"
.into(),
});
}
other => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected yes/no/confirm/ask, got {other:?}"),
});
}
}
}
"preferredauthentications" => {
opts.preferred_authentications = Some(parse_preferred_auth(line)?);
}
"pubkeyauthentication" => {
opts.pubkey_authentication = Some(parse_yes_no(line)?);
}
"numberofpasswordprompts" => {
opts.number_of_password_prompts = Some(parse_u32(line)?);
}
"batchmode" => {
opts.batch_mode = Some(parse_yes_no(line)?);
}
"exitonforwardfailure" => {
opts.exit_on_forward_failure = Some(parse_yes_no(line)?);
}
"clearallforwardings" => {
opts.clear_all_forwardings = Some(parse_yes_no(line)?);
}
"dynamicforward" => {
opts.dynamic_forwards.push(parse_dynamic_forward(line)?);
}
"gatewayports" => {
let s = one_arg(line)?.to_ascii_lowercase();
opts.gateway_ports = Some(match s.as_str() {
"no" | "false" | "off" => GatewayPorts::No,
"yes" | "true" | "on" => GatewayPorts::Yes,
"clientspecified" => GatewayPorts::ClientSpecified,
other => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected no/yes/clientspecified, got {other:?}"),
});
}
});
}
"addressfamily" => {
let s = one_arg(line)?.to_ascii_lowercase();
opts.address_family = Some(match s.as_str() {
"any" => AddressFamily::Any,
"inet" => AddressFamily::Inet,
"inet6" => AddressFamily::Inet6,
other => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected any/inet/inet6, got {other:?}"),
});
}
});
}
"bindaddress" => {
opts.bind_address = Some(one_arg(line)?);
}
"identityagent" => {
let v = one_arg(line)?;
opts.identity_agent = Some(if v.eq_ignore_ascii_case("none") {
IdentityAgent::None
} else {
IdentityAgent::Path(v)
});
}
"controlmaster" => {
let s = one_arg(line)?.to_ascii_lowercase();
opts.control_master = Some(match s.as_str() {
"no" | "false" | "off" => ControlMaster::No,
"yes" | "true" | "on" => ControlMaster::Yes,
"auto" => ControlMaster::Auto,
"ask" | "autoask" => {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: "ControlMaster ask/autoask requires interactive confirmation, \
which is not implemented"
.into(),
});
}
other => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected no/yes/auto, got {other:?}"),
});
}
});
}
"controlpath" => {
let v = one_arg(line)?;
if v.eq_ignore_ascii_case("none") {
opts.control_path = None;
} else {
opts.control_path = Some(v);
}
}
"controlpersist" => {
opts.control_persist = Some(parse_control_persist(line)?);
}
"obscurekeystroketiming" => {
opts.obscure_keystroke_timing = Some(parse_obscure_keystroke_timing(line)?);
}
"casignaturealgorithms" => {
opts.ca_signature_algorithms = Some(resolve_algo_list(
AlgoCategory::CaSignature,
args,
line.line_no,
&line.keyword,
)?);
}
_ => {
return Err(ConfigError::UnknownKeyword {
line: line.line_no,
keyword: kw.to_string(),
});
}
}
let _ = args;
Ok(())
}
fn merge_into(dst: &mut ClientOptions, src: &ClientOptions) {
macro_rules! take_scalar {
($field:ident) => {
if dst.$field.is_none() {
dst.$field = src.$field.clone();
}
};
}
take_scalar!(host_name);
take_scalar!(port);
take_scalar!(user);
take_scalar!(identities_only);
take_scalar!(strict_host_key);
take_scalar!(user_known_hosts);
take_scalar!(hash_known_hosts);
take_scalar!(forward_agent);
take_scalar!(forward_x11);
take_scalar!(forward_x11_trusted);
take_scalar!(request_tty);
take_scalar!(log_level);
take_scalar!(ciphers);
take_scalar!(macs);
take_scalar!(kex_algorithms);
take_scalar!(host_key_algorithms);
take_scalar!(pubkey_accepted_algorithms);
take_scalar!(ca_signature_algorithms);
take_scalar!(proxy_command);
take_scalar!(proxy_jump);
take_scalar!(compression);
take_scalar!(connect_timeout);
take_scalar!(server_alive_interval);
take_scalar!(server_alive_count_max);
take_scalar!(tcp_keep_alive);
take_scalar!(add_keys_to_agent);
take_scalar!(preferred_authentications);
take_scalar!(pubkey_authentication);
take_scalar!(number_of_password_prompts);
take_scalar!(batch_mode);
take_scalar!(exit_on_forward_failure);
take_scalar!(clear_all_forwardings);
take_scalar!(gateway_ports);
take_scalar!(address_family);
take_scalar!(bind_address);
take_scalar!(identity_agent);
take_scalar!(control_master);
take_scalar!(control_path);
take_scalar!(control_persist);
take_scalar!(obscure_keystroke_timing);
dst.identity_files
.extend(src.identity_files.iter().cloned());
dst.certificate_files
.extend(src.certificate_files.iter().cloned());
dst.local_forwards
.extend(src.local_forwards.iter().cloned());
dst.remote_forwards
.extend(src.remote_forwards.iter().cloned());
dst.dynamic_forwards
.extend(src.dynamic_forwards.iter().cloned());
dst.set_env.extend(src.set_env.iter().cloned());
dst.send_env.extend(src.send_env.iter().cloned());
}
fn one_arg(line: &ParsedLine) -> Result<String, ConfigError> {
if line.args.len() != 1 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected 1 value, got {}", line.args.len()),
});
}
Ok(line.args[0].clone())
}
fn parse_u16(line: &ParsedLine) -> Result<u16, ConfigError> {
let s = one_arg(line)?;
s.parse::<u16>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected a port number, got {s:?}"),
})
}
fn parse_u32(line: &ParsedLine) -> Result<u32, ConfigError> {
let s = one_arg(line)?;
s.parse::<u32>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected a non-negative integer, got {s:?}"),
})
}
fn parse_preferred_auth(line: &ParsedLine) -> Result<Vec<String>, ConfigError> {
let mut methods: Vec<String> = Vec::new();
for tok in &line.args {
for m in tok.split(',') {
let m = m.trim();
if !m.is_empty() {
methods.push(m.to_ascii_lowercase());
}
}
}
if methods.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "PreferredAuthentications requires at least one method".into(),
});
}
const KNOWN: &[&str] = &[
"publickey",
"password",
"keyboard-interactive",
"none",
"gssapi-with-mic",
"hostbased",
];
const IMPLEMENTED: &[&str] = &["publickey", "password", "none"];
for m in &methods {
if !KNOWN.contains(&m.as_str()) {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("unknown authentication method {m:?}"),
});
}
}
if !methods.iter().any(|m| IMPLEMENTED.contains(&m.as_str())) {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: "PreferredAuthentications names only methods this client does not \
implement (publickey/password are the supported methods)"
.into(),
});
}
Ok(methods)
}
fn parse_dynamic_forward(line: &ParsedLine) -> Result<DynamicForwardSpec, ConfigError> {
if line.args.len() != 1 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected 1 token ([bind:]port), got {}", line.args.len()),
});
}
let (bind_addr, listen_port) = split_bind_port(&line.args[0], line)?;
Ok(DynamicForwardSpec {
bind_addr,
listen_port,
})
}
fn parse_control_persist(line: &ParsedLine) -> Result<ControlPersist, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"no" | "false" | "off" => return Ok(ControlPersist::No),
"yes" | "true" | "on" => return Ok(ControlPersist::Yes),
_ => {}
}
let bad = |msg: String| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg,
};
let (digits, scale): (&str, u64) = match s.as_bytes().last() {
Some(b's') => (&s[..s.len() - 1], 1),
Some(b'm') => (&s[..s.len() - 1], 60),
Some(b'h') => (&s[..s.len() - 1], 3600),
_ => (s.as_str(), 1),
};
if digits.is_empty() {
return Err(bad(format!("expected yes/no/<N>[smh], got {s:?}")));
}
let n: u64 = digits
.parse()
.map_err(|_| bad(format!("expected yes/no/<N>[smh], got {s:?}")))?;
let secs = n
.checked_mul(scale)
.ok_or_else(|| bad("ControlPersist duration overflows".into()))?;
if secs == 0 {
Ok(ControlPersist::No)
} else {
Ok(ControlPersist::Seconds(secs))
}
}
fn parse_obscure_keystroke_timing(
line: &ParsedLine,
) -> Result<ObscureKeystrokeTiming, ConfigError> {
let raw = one_arg(line)?;
let s = raw.to_ascii_lowercase();
let bad = |msg: String| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg,
};
match s.as_str() {
"no" | "false" | "off" => return Ok(ObscureKeystrokeTiming::Off),
"yes" | "true" | "on" => return Ok(ObscureKeystrokeTiming::default_on()),
_ => {}
}
let Some(spec) = s.strip_prefix("interval:") else {
return Err(bad(format!("expected yes/no/interval:<spec>, got {raw:?}")));
};
if spec.is_empty() {
return Err(bad("interval: requires a value".into()));
}
let (digits, scale_ms): (&str, u64) = if let Some(d) = spec.strip_suffix("ms") {
(d, 1)
} else if let Some(d) = spec.strip_suffix('s') {
(d, 1000)
} else if let Some(d) = spec.strip_suffix('m') {
(d, 60_000)
} else if let Some(d) = spec.strip_suffix('h') {
(d, 3_600_000)
} else {
(spec, 1)
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad(format!(
"interval: expected <ms> or <N>[ms|s|m|h], got {spec:?}"
)));
}
let n: u64 = digits
.parse()
.map_err(|_| bad(format!("interval: invalid number {spec:?}")))?;
let ms = n
.checked_mul(scale_ms)
.ok_or_else(|| bad("interval: duration overflows".into()))?;
if ms == 0 {
return Err(bad("interval: must be greater than zero".into()));
}
let interval_ms = u32::try_from(ms).map_err(|_| bad("interval: too large".into()))?;
Ok(ObscureKeystrokeTiming::On { interval_ms })
}
fn parse_yes_no(line: &ParsedLine) -> Result<bool, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"yes" | "true" | "on" => Ok(true),
"no" | "false" | "off" => Ok(false),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected yes/no, got {s:?}"),
}),
}
}
fn parse_strict(line: &ParsedLine) -> Result<StrictMode, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"yes" => Ok(StrictMode::Yes),
"no" | "off" => Ok(StrictMode::No),
"accept-new" => Ok(StrictMode::AcceptNew),
"ask" => Ok(StrictMode::Ask),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected yes/no/accept-new/ask/off, got {s:?}"),
}),
}
}
fn parse_request_tty(line: &ParsedLine) -> Result<RequestTty, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"no" => Ok(RequestTty::No),
"yes" => Ok(RequestTty::Yes),
"force" => Ok(RequestTty::Force),
"auto" => Ok(RequestTty::Auto),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected no/yes/force/auto, got {s:?}"),
}),
}
}
fn parse_log_level(line: &ParsedLine) -> Result<u8, ConfigError> {
let s = one_arg(line)?.to_ascii_uppercase();
match s.as_str() {
"QUIET" | "FATAL" | "ERROR" | "INFO" => Ok(0),
"VERBOSE" | "DEBUG" | "DEBUG1" => Ok(1),
"DEBUG2" => Ok(2),
"DEBUG3" => Ok(3),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected QUIET..DEBUG3, got {s:?}"),
}),
}
}
fn parse_local_forward(line: &ParsedLine) -> Result<LocalForwardSpec, ConfigError> {
if line.args.len() != 2 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected 2 tokens, got {}", line.args.len()),
});
}
let (bind_addr, listen_port) = split_bind_port(&line.args[0], line)?;
let (remote_host, remote_port) = split_host_port(&line.args[1], line)?;
Ok(LocalForwardSpec {
bind_addr,
listen_port,
remote_host,
remote_port,
})
}
fn parse_remote_forward(line: &ParsedLine) -> Result<RemoteForwardSpec, ConfigError> {
if line.args.len() != 2 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected 2 tokens, got {}", line.args.len()),
});
}
let (bind_addr, remote_port) = split_bind_port(&line.args[0], line)?;
let (local_host, local_port) = split_host_port(&line.args[1], line)?;
Ok(RemoteForwardSpec {
bind_addr,
remote_port,
local_host,
local_port,
})
}
fn split_bind_port(s: &str, line: &ParsedLine) -> Result<(Option<String>, u16), ConfigError> {
if let Some(rest) = s.strip_prefix('[') {
if let Some((addr, port)) = rest.split_once("]:") {
let port = port.parse::<u16>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("bad port in {s:?}"),
})?;
return Ok((Some(addr.to_string()), port));
}
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("malformed bracketed bind:port {s:?}"),
});
}
match s.rsplit_once(':') {
Some((addr, port)) => {
let port = port.parse::<u16>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("bad port in {s:?}"),
})?;
Ok((Some(addr.to_string()), port))
}
None => {
let port = s.parse::<u16>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected port or addr:port, got {s:?}"),
})?;
Ok((None, port))
}
}
}
fn split_host_port(s: &str, line: &ParsedLine) -> Result<(String, u16), ConfigError> {
if let Some(rest) = s.strip_prefix('[') {
if let Some((addr, port)) = rest.split_once("]:") {
let port = port.parse::<u16>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("bad port in {s:?}"),
})?;
return Ok((addr.to_string(), port));
}
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("malformed bracketed host:port {s:?}"),
});
}
match s.rsplit_once(':') {
Some((host, port)) => {
let port = port.parse::<u16>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("bad port in {s:?}"),
})?;
Ok((host.to_string(), port))
}
None => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected host:port, got {s:?}"),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal() {
let src = "\
Host gw
HostName 198.51.100.7
User admin
Port 2222
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(eff.host_name.as_deref(), Some("198.51.100.7"));
assert_eq!(eff.port, Some(2222));
assert_eq!(eff.user.as_deref(), Some("admin"));
}
#[test]
fn algorithm_keywords_parse() {
let src = "\
Host gw
Ciphers aes128-ctr,aes256-ctr
MACs hmac-sha2-256
KexAlgorithms curve25519-sha256
HostKeyAlgorithms ssh-ed25519
PubkeyAcceptedAlgorithms ssh-ed25519,rsa-sha2-512
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(
eff.ciphers.as_deref(),
Some(&["aes128-ctr".to_string(), "aes256-ctr".to_string()][..])
);
assert_eq!(
eff.macs.as_deref(),
Some(&["hmac-sha2-256".to_string()][..])
);
assert_eq!(
eff.kex_algorithms.as_deref(),
Some(&["curve25519-sha256".to_string()][..])
);
assert_eq!(
eff.host_key_algorithms.as_deref(),
Some(&["ssh-ed25519".to_string()][..])
);
assert_eq!(
eff.pubkey_accepted_algorithms.as_deref(),
Some(&["ssh-ed25519".to_string(), "rsa-sha2-512".to_string()][..])
);
}
#[test]
fn host_key_algorithms_plus_ssh_rsa_accepted() {
let src = "Host legacy\n HostKeyAlgorithms +ssh-rsa\n";
let cfg = SshClientConfig::parse(src).expect("+ssh-rsa must parse");
let eff = cfg.lookup("legacy");
let list = eff
.host_key_algorithms
.as_deref()
.expect("host_key_algorithms set");
assert!(
list.iter().any(|n| n == "ssh-rsa"),
"+ssh-rsa must appear in the resolved list: {list:?}"
);
assert!(
list.iter().any(|n| n == "ssh-ed25519"),
"defaults must be preserved ahead of the appended legacy name"
);
}
#[test]
fn host_key_algorithms_bare_ssh_rsa_accepted() {
let src = "Host legacy\n HostKeyAlgorithms ssh-rsa\n";
let cfg = SshClientConfig::parse(src).expect("bare ssh-rsa must parse");
let eff = cfg.lookup("legacy");
assert_eq!(
eff.host_key_algorithms.as_deref(),
Some(&["ssh-rsa".to_string()][..])
);
}
#[test]
fn pubkey_accepted_ssh_rsa_still_rejected() {
let src = "Host legacy\n PubkeyAcceptedAlgorithms +ssh-rsa\n";
let err = SshClientConfig::parse(src).unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn unknown_cipher_rejected_with_line() {
let src = "Host gw\n Ciphers totally-bogus\n";
let err = SshClientConfig::parse(src).unwrap_err();
match err {
ConfigError::BadValue { line, keyword, msg } => {
assert_eq!(line, 2);
assert_eq!(keyword, "Ciphers");
assert!(msg.contains("totally-bogus"));
}
other => panic!("expected BadValue, got {other:?}"),
}
}
#[test]
fn proxy_command_parses() {
let src = "\
Host gw
ProxyCommand /usr/bin/nc -X connect -x proxy:3128 %h %p
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(
eff.proxy_command.as_deref(),
Some("/usr/bin/nc -X connect -x proxy:3128 %h %p")
);
}
#[test]
fn proxy_command_none_clears() {
let src = "\
Host gw
ProxyCommand none
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("gw").proxy_command, None);
}
#[test]
fn proxy_command_empty_errors() {
let src = "ProxyCommand\n";
let err = SshClientConfig::parse(src).unwrap_err();
match err {
ConfigError::BadValue { line, keyword, .. } => {
assert_eq!(line, 1);
assert_eq!(keyword, "proxycommand");
}
other => panic!("expected BadValue, got {other:?}"),
}
}
#[test]
fn proxy_jump_parses() {
let src = "\
Host target
ProxyJump user@bastion:2222,hop2
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(
cfg.lookup("target").proxy_jump.as_deref(),
Some("user@bastion:2222,hop2")
);
}
#[test]
fn proxy_jump_none_clears() {
let src = "\
Host target
ProxyJump none
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("target").proxy_jump, None);
}
#[test]
fn casignaturealgorithms_accepted() {
let src = "Host gw\n CASignatureAlgorithms ssh-ed25519,rsa-sha2-512\n";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(
eff.ca_signature_algorithms.as_deref(),
Some(["ssh-ed25519".to_string(), "rsa-sha2-512".to_string()].as_slice())
);
}
#[test]
fn casignaturealgorithms_rejects_plain_ssh_rsa() {
let src = "Host gw\n CASignatureAlgorithms ssh-rsa\n";
let err = SshClientConfig::parse(src).unwrap_err();
assert!(matches!(err, ConfigError::BadValue { line: 2, .. }));
}
#[test]
fn algorithms_first_match_wins() {
let src = "\
Host gw
Ciphers aes128-ctr
Host *
Ciphers aes256-ctr
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(
eff.ciphers.as_deref(),
Some(&["aes128-ctr".to_string()][..])
);
}
#[test]
fn global_block_applies() {
let src = "\
User globaluser
IdentitiesOnly yes
Host gw
Port 2222
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(eff.user.as_deref(), Some("globaluser"));
assert_eq!(eff.port, Some(2222));
assert_eq!(eff.identities_only, Some(true));
}
#[test]
fn first_match_wins_for_scalars() {
let src = "\
Host *.example.com
User firstuser
Host *
User otheruser
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("host.example.com");
assert_eq!(eff.user.as_deref(), Some("firstuser"));
}
#[test]
fn identity_files_cumulative() {
let src = "\
Host *
IdentityFile ~/.ssh/id_a
Host gw
IdentityFile ~/.ssh/id_b
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(eff.identity_files, vec!["~/.ssh/id_a", "~/.ssh/id_b"]);
}
#[test]
fn local_forward_parses() {
let src = "\
Host gw
LocalForward 8080 example.com:80
LocalForward 127.0.0.1:9090 backend:443
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(eff.local_forwards.len(), 2);
assert_eq!(eff.local_forwards[0].bind_addr, None);
assert_eq!(eff.local_forwards[0].listen_port, 8080);
assert_eq!(eff.local_forwards[0].remote_host, "example.com");
assert_eq!(eff.local_forwards[0].remote_port, 80);
assert_eq!(
eff.local_forwards[1].bind_addr.as_deref(),
Some("127.0.0.1")
);
assert_eq!(eff.local_forwards[1].listen_port, 9090);
}
#[test]
fn ipv6_bracketed_bind() {
let src = "\
Host gw
LocalForward [::1]:8080 example.com:80
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(eff.local_forwards[0].bind_addr.as_deref(), Some("::1"));
assert_eq!(eff.local_forwards[0].listen_port, 8080);
}
#[test]
fn negated_host_excludes() {
let src = "\
Host *.example.com !secret.example.com
User foo
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("ok.example.com").user.as_deref(), Some("foo"));
assert_eq!(cfg.lookup("secret.example.com").user, None);
}
#[test]
fn unknown_keyword_errors() {
let src = "Host gw\n CompressionLevel 9\n";
let err = SshClientConfig::parse(src).unwrap_err();
match err {
ConfigError::UnknownKeyword { keyword, line } => {
assert_eq!(keyword, "compressionlevel");
assert_eq!(line, 2);
}
_ => panic!("wrong error: {err:?}"),
}
}
#[test]
fn strict_host_key_values() {
for (s, want) in [
("yes", StrictMode::Yes),
("no", StrictMode::No),
("off", StrictMode::No),
("accept-new", StrictMode::AcceptNew),
("ask", StrictMode::Ask),
] {
let src = format!("StrictHostKeyChecking {s}\n");
let cfg = SshClientConfig::parse(&src).unwrap();
assert_eq!(cfg.lookup("anything").strict_host_key, Some(want));
}
}
#[test]
fn request_tty_values() {
for (s, want) in [
("no", RequestTty::No),
("yes", RequestTty::Yes),
("force", RequestTty::Force),
("auto", RequestTty::Auto),
] {
let src = format!("RequestTTY {s}\n");
let cfg = SshClientConfig::parse(&src).unwrap();
assert_eq!(cfg.lookup("anything").request_tty, Some(want));
}
}
#[test]
fn equals_separator_accepted() {
let src = "Host gw\n Port=2222\n";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("gw").port, Some(2222));
}
#[cfg(feature = "compress")]
#[test]
fn compression_parses_with_feature() {
let cfg = SshClientConfig::parse("Compression yes\n").unwrap();
assert_eq!(cfg.lookup("h").compression, Some(true));
let cfg = SshClientConfig::parse("Compression no\n").unwrap();
assert_eq!(cfg.lookup("h").compression, Some(false));
}
#[cfg(not(feature = "compress"))]
#[test]
fn compression_yes_unsupported_without_feature() {
let cfg = SshClientConfig::parse("Compression no\n").unwrap();
assert_eq!(cfg.lookup("h").compression, Some(false));
let err = SshClientConfig::parse("Compression yes\n").unwrap_err();
assert!(matches!(err, ConfigError::Unsupported { .. }));
}
#[test]
fn set_env_parses_multiple() {
let src = "Host gw\n SetEnv FOO=bar BAZ=qux\n SetEnv LANG=C\n";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(
eff.set_env,
vec![
("FOO".to_string(), "bar".to_string()),
("BAZ".to_string(), "qux".to_string()),
("LANG".to_string(), "C".to_string()),
]
);
}
#[test]
fn set_env_rejects_missing_equals() {
let err = SshClientConfig::parse("SetEnv NOTANENV\n").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn send_env_parses() {
let src = "Host gw\n SendEnv LANG LC_*\n SendEnv TERM\n";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("gw").send_env, vec!["LANG", "LC_*", "TERM"]);
}
#[test]
fn connect_timeout_parses_and_rejects_zero() {
let cfg = SshClientConfig::parse("ConnectTimeout 10\n").unwrap();
assert_eq!(cfg.lookup("h").connect_timeout, Some(10));
let err = SshClientConfig::parse("ConnectTimeout 0\n").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn server_alive_parses() {
let src = "ServerAliveInterval 15\nServerAliveCountMax 4\n";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("h");
assert_eq!(eff.server_alive_interval, Some(15));
assert_eq!(eff.server_alive_count_max, Some(4));
}
#[test]
fn tcp_keep_alive_parses() {
let cfg = SshClientConfig::parse("TCPKeepAlive no\n").unwrap();
assert_eq!(cfg.lookup("h").tcp_keep_alive, Some(false));
}
#[test]
fn add_keys_to_agent_yes_no() {
assert_eq!(
SshClientConfig::parse("AddKeysToAgent yes\n")
.unwrap()
.lookup("h")
.add_keys_to_agent,
Some(true)
);
assert_eq!(
SshClientConfig::parse("AddKeysToAgent no\n")
.unwrap()
.lookup("h")
.add_keys_to_agent,
Some(false)
);
}
#[test]
fn add_keys_to_agent_confirm_unsupported() {
for v in ["confirm", "ask"] {
let err = SshClientConfig::parse(&format!("AddKeysToAgent {v}\n")).unwrap_err();
assert!(
matches!(err, ConfigError::Unsupported { .. }),
"expected Unsupported for {v}, got {err:?}"
);
}
}
#[test]
fn preferred_authentications_parses_and_orders() {
let cfg = SshClientConfig::parse("PreferredAuthentications password,publickey\n").unwrap();
assert_eq!(
cfg.lookup("h").preferred_authentications.as_deref(),
Some(&["password".to_string(), "publickey".to_string()][..])
);
}
#[test]
fn preferred_authentications_only_unimplementable_unsupported() {
let err = SshClientConfig::parse("PreferredAuthentications gssapi-with-mic,hostbased\n")
.unwrap_err();
assert!(matches!(err, ConfigError::Unsupported { .. }));
}
#[test]
fn preferred_authentications_unknown_method_rejected() {
let err = SshClientConfig::parse("PreferredAuthentications quantum\n").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn pubkey_authentication_parses() {
let cfg = SshClientConfig::parse("PubkeyAuthentication no\n").unwrap();
assert_eq!(cfg.lookup("h").pubkey_authentication, Some(false));
}
#[test]
fn number_of_password_prompts_and_batchmode() {
let cfg = SshClientConfig::parse("NumberOfPasswordPrompts 1\nBatchMode yes\n").unwrap();
let eff = cfg.lookup("h");
assert_eq!(eff.number_of_password_prompts, Some(1));
assert_eq!(eff.batch_mode, Some(true));
}
#[test]
fn exit_on_forward_failure_and_clear_all() {
let cfg =
SshClientConfig::parse("ExitOnForwardFailure yes\nClearAllForwardings yes\n").unwrap();
let eff = cfg.lookup("h");
assert_eq!(eff.exit_on_forward_failure, Some(true));
assert_eq!(eff.clear_all_forwardings, Some(true));
}
#[test]
fn dynamic_forward_parses() {
let src = "Host gw\n DynamicForward 1080\n DynamicForward 127.0.0.1:1081\n";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(eff.dynamic_forwards.len(), 2);
assert_eq!(eff.dynamic_forwards[0].bind_addr, None);
assert_eq!(eff.dynamic_forwards[0].listen_port, 1080);
assert_eq!(
eff.dynamic_forwards[1].bind_addr.as_deref(),
Some("127.0.0.1")
);
assert_eq!(eff.dynamic_forwards[1].listen_port, 1081);
}
#[test]
fn gateway_ports_parses() {
for (s, want) in [
("no", GatewayPorts::No),
("yes", GatewayPorts::Yes),
("clientspecified", GatewayPorts::ClientSpecified),
] {
let cfg = SshClientConfig::parse(&format!("GatewayPorts {s}\n")).unwrap();
assert_eq!(cfg.lookup("h").gateway_ports, Some(want));
}
}
#[test]
fn address_family_parses() {
for (s, want) in [
("any", AddressFamily::Any),
("inet", AddressFamily::Inet),
("inet6", AddressFamily::Inet6),
] {
let cfg = SshClientConfig::parse(&format!("AddressFamily {s}\n")).unwrap();
assert_eq!(cfg.lookup("h").address_family, Some(want));
}
let err = SshClientConfig::parse("AddressFamily ipx\n").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn bind_address_parses() {
let cfg = SshClientConfig::parse("BindAddress 10.0.0.5\n").unwrap();
assert_eq!(cfg.lookup("h").bind_address.as_deref(), Some("10.0.0.5"));
}
#[test]
fn identity_agent_parses() {
let cfg = SshClientConfig::parse("IdentityAgent none\n").unwrap();
assert_eq!(cfg.lookup("h").identity_agent, Some(IdentityAgent::None));
let cfg = SshClientConfig::parse("IdentityAgent /run/agent.sock\n").unwrap();
assert_eq!(
cfg.lookup("h").identity_agent,
Some(IdentityAgent::Path("/run/agent.sock".to_string()))
);
}
#[test]
fn match_host_glob() {
let src = "\
Match host *.example.com
User alice
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("web.example.com").user.as_deref(), Some("alice"));
assert_eq!(cfg.lookup("web.other.com").user, None);
}
#[test]
fn control_master_values() {
let cfg = SshClientConfig::parse("Host h\n ControlMaster auto\n").unwrap();
assert_eq!(cfg.lookup("h").control_master, Some(ControlMaster::Auto));
let cfg = SshClientConfig::parse("Host h\n ControlMaster yes\n").unwrap();
assert_eq!(cfg.lookup("h").control_master, Some(ControlMaster::Yes));
let cfg = SshClientConfig::parse("Host h\n ControlMaster no\n").unwrap();
assert_eq!(cfg.lookup("h").control_master, Some(ControlMaster::No));
}
#[test]
fn control_master_ask_unsupported() {
let err = SshClientConfig::parse("Host h\n ControlMaster ask\n").unwrap_err();
assert!(matches!(err, ConfigError::Unsupported { .. }));
let err = SshClientConfig::parse("Host h\n ControlMaster autoask\n").unwrap_err();
assert!(matches!(err, ConfigError::Unsupported { .. }));
}
#[test]
fn control_master_bad_value() {
let err = SshClientConfig::parse("Host h\n ControlMaster maybe\n").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn control_path_none_disables() {
let cfg = SshClientConfig::parse("Host h\n ControlPath none\n").unwrap();
assert_eq!(cfg.lookup("h").control_path, None);
let cfg = SshClientConfig::parse("Host h\n ControlPath ~/.ssh/cm-%r@%h:%p\n").unwrap();
assert_eq!(
cfg.lookup("h").control_path.as_deref(),
Some("~/.ssh/cm-%r@%h:%p")
);
}
#[test]
fn control_persist_values() {
let p = |s: &str| {
SshClientConfig::parse(&format!("Host h\n ControlPersist {s}\n"))
.unwrap()
.lookup("h")
.control_persist
};
assert_eq!(p("no"), Some(ControlPersist::No));
assert_eq!(p("yes"), Some(ControlPersist::Yes));
assert_eq!(p("30"), Some(ControlPersist::Seconds(30)));
assert_eq!(p("30s"), Some(ControlPersist::Seconds(30)));
assert_eq!(p("5m"), Some(ControlPersist::Seconds(300)));
assert_eq!(p("2h"), Some(ControlPersist::Seconds(7200)));
assert_eq!(p("0"), Some(ControlPersist::No));
}
#[test]
fn control_persist_bad_value() {
let err = SshClientConfig::parse("Host h\n ControlPersist soon\n").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
let err = SshClientConfig::parse("Host h\n ControlPersist 10x\n").unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
#[test]
fn match_negated_host() {
let src = "\
Match host *.example.com,!internal.example.com
User alice
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("web.example.com").user.as_deref(), Some("alice"));
assert_eq!(cfg.lookup("internal.example.com").user, None);
}
#[test]
fn match_user_combined_with_host() {
let src = "\
Match host *.example.com user alice
Port 2222
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("web.example.com").port, None);
let ctx = MatchContext {
host: "web.example.com",
original_host: None,
user: Some("bob"),
local_user: None,
..MatchContext::default()
};
assert_eq!(cfg.lookup_with(ctx).port, None);
let ctx = MatchContext {
host: "web.example.com",
original_host: None,
user: Some("alice"),
local_user: None,
..MatchContext::default()
};
assert_eq!(cfg.lookup_with(ctx).port, Some(2222));
}
#[test]
fn match_all_matches_everything() {
let src = "\
Match all
Port 4242
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("anything").port, Some(4242));
assert_eq!(cfg.lookup("other").port, Some(4242));
}
#[test]
fn match_canonical_never_matches_in_first_pass() {
let src = "\
Match canonical
Port 4242
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("anything").port, None);
}
#[test]
fn match_final_never_matches_in_first_pass() {
let src = "\
Match final
Port 4242
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("anything").port, None);
}
#[test]
fn match_exec_disabled_by_default() {
let src = "\
Match exec true
Port 4242
";
let cfg = SshClientConfig::parse(src).unwrap();
assert!(!cfg.is_match_exec_enabled());
assert_eq!(cfg.lookup("anything").port, None);
}
#[cfg(unix)]
#[test]
fn match_exec_enabled_runs_command() {
let src = "\
Match exec true
Port 4242
";
let cfg = SshClientConfig::parse(src).unwrap().enable_match_exec(true);
assert!(cfg.is_match_exec_enabled());
assert_eq!(cfg.lookup("anything").port, Some(4242));
let src_false = "\
Match exec false
Port 4242
";
let cfg = SshClientConfig::parse(src_false)
.unwrap()
.enable_match_exec(true);
assert_eq!(cfg.lookup("anything").port, None);
}
#[test]
fn match_originalhost_uses_pre_substitution_name() {
let src = "\
Match originalhost prod
Port 2200
";
let cfg = SshClientConfig::parse(src).unwrap();
let ctx = MatchContext {
host: "10.0.0.1",
original_host: Some("prod"),
user: None,
local_user: None,
..MatchContext::default()
};
assert_eq!(cfg.lookup_with(ctx).port, Some(2200));
}
#[test]
fn match_localuser() {
let src = "\
Match localuser alice
Port 2200
";
let cfg = SshClientConfig::parse(src).unwrap();
let ctx = MatchContext {
host: "h",
original_host: None,
user: None,
local_user: Some("alice"),
..MatchContext::default()
};
assert_eq!(cfg.lookup_with(ctx).port, Some(2200));
let ctx = MatchContext {
host: "h",
original_host: None,
user: None,
local_user: Some("bob"),
..MatchContext::default()
};
assert_eq!(cfg.lookup_with(ctx).port, None);
}
#[test]
fn match_block_with_settings_parses() {
let src = "\
Match host gw
HostName 10.0.0.1
Port 2222
User admin
";
let cfg = SshClientConfig::parse(src).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(eff.host_name.as_deref(), Some("10.0.0.1"));
assert_eq!(eff.port, Some(2222));
assert_eq!(eff.user.as_deref(), Some("admin"));
}
#[test]
fn match_unknown_criterion_errors() {
let src = "Match address 1.2.3.4\n Port 22\n";
let err = SshClientConfig::parse(src).unwrap_err();
match err {
ConfigError::BadValue { line, .. } => assert_eq!(line, 1),
_ => panic!("wrong err: {err:?}"),
}
}
#[test]
fn match_empty_args_errors() {
let src = "Match\n Port 22\n";
let err = SshClientConfig::parse(src).unwrap_err();
match err {
ConfigError::BadValue { line, .. } => assert_eq!(line, 1),
_ => panic!("wrong err: {err:?}"),
}
}
#[test]
fn obscure_keystroke_timing_yes_defaults_to_20ms() {
let cfg = SshClientConfig::parse("ObscureKeystrokeTiming yes\n").unwrap();
assert_eq!(
cfg.lookup("h").obscure_keystroke_timing,
Some(ObscureKeystrokeTiming::On { interval_ms: 20 })
);
}
#[test]
fn obscure_keystroke_timing_no_is_off() {
let cfg = SshClientConfig::parse("ObscureKeystrokeTiming no\n").unwrap();
assert_eq!(
cfg.lookup("h").obscure_keystroke_timing,
Some(ObscureKeystrokeTiming::Off)
);
}
#[test]
fn obscure_keystroke_timing_interval_ms_integer() {
let cfg = SshClientConfig::parse("ObscureKeystrokeTiming interval:80\n").unwrap();
assert_eq!(
cfg.lookup("h").obscure_keystroke_timing,
Some(ObscureKeystrokeTiming::On { interval_ms: 80 })
);
}
#[test]
fn obscure_keystroke_timing_interval_time_units() {
let cases = [
("interval:1s", 1000),
("interval:500ms", 500),
("interval:2m", 120_000),
];
for (spec, want) in cases {
let cfg = SshClientConfig::parse(&format!("ObscureKeystrokeTiming {spec}\n")).unwrap();
assert_eq!(
cfg.lookup("h").obscure_keystroke_timing,
Some(ObscureKeystrokeTiming::On { interval_ms: want }),
"spec {spec}"
);
}
}
#[test]
fn obscure_keystroke_timing_unset_is_none() {
let cfg = SshClientConfig::parse("Host h\n Port 22\n").unwrap();
assert_eq!(cfg.lookup("h").obscure_keystroke_timing, None);
}
#[test]
fn obscure_keystroke_timing_malformed_is_bad_value() {
for bad in [
"ObscureKeystrokeTiming maybe\n",
"ObscureKeystrokeTiming interval:\n",
"ObscureKeystrokeTiming interval:abc\n",
"ObscureKeystrokeTiming interval:0\n",
"ObscureKeystrokeTiming interval:-5\n",
"ObscureKeystrokeTiming 80\n",
] {
let err = SshClientConfig::parse(bad).unwrap_err();
assert!(
matches!(err, ConfigError::BadValue { .. }),
"input {bad:?} gave {err:?}"
);
}
}
#[test]
fn obscure_keystroke_timing_default_helpers() {
assert!(ObscureKeystrokeTiming::default_on().is_on());
assert_eq!(ObscureKeystrokeTiming::default_on().interval_ms(), Some(20));
assert!(!ObscureKeystrokeTiming::Off.is_on());
assert_eq!(ObscureKeystrokeTiming::Off.interval_ms(), None);
}
#[test]
fn append_layers_user_over_system() {
let mut user = SshClientConfig::parse("Host gw\n Port 2200\n IdentityFile /u/key\n")
.expect("user parses");
let system = SshClientConfig::parse("Host gw\n Port 22\n IdentityFile /etc/key\n")
.expect("system parses");
user.append(system);
let eff = user.lookup("gw");
assert_eq!(eff.port, Some(2200));
assert_eq!(eff.identity_files, vec!["/u/key", "/etc/key"]);
}
#[cfg(feature = "std")]
#[test]
fn include_unsupported_in_string_parse() {
let src = "Include /etc/ssh/somefile\n";
let err = SshClientConfig::parse(src).unwrap_err();
match err {
ConfigError::Unsupported { line, msg } => {
assert_eq!(line, 1);
assert!(msg.contains("Include"), "msg = {msg}");
}
_ => panic!("wrong err: {err:?}"),
}
}
#[cfg(feature = "std")]
mod include_io {
use super::*;
use std::io::Write;
use std::path::PathBuf;
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(prefix: &str) -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let path =
std::env::temp_dir().join(format!("puressh-cfg-client-{prefix}-{pid}-{nanos}"));
std::fs::create_dir_all(&path).expect("create tempdir");
Self { path }
}
fn write(&self, name: &str, body: &str) -> PathBuf {
let p = self.path.join(name);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).expect("mkdir");
}
let mut f = std::fs::File::create(&p).expect("create file");
f.write_all(body.as_bytes()).expect("write file");
p
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn include_pulls_in_settings() {
let dir = TempDir::new("pull");
let leaf = dir.write("leaf.cfg", "Port 4242\n");
let root = dir.write(
"root.cfg",
&format!("Host gw\n HostName 10.0.0.1\nInclude {}\n", leaf.display()),
);
let cfg = SshClientConfig::load(&root).unwrap();
assert_eq!(cfg.lookup("gw").port, Some(4242));
assert_eq!(cfg.lookup("gw").host_name.as_deref(), Some("10.0.0.1"));
}
#[test]
fn include_glob_pulls_all_matches() {
let dir = TempDir::new("glob");
dir.write("conf.d/01.cfg", "Host gw\n Port 2001\n");
dir.write("conf.d/02.cfg", "Host gw\n User u2\n");
dir.write("conf.d/03.cfg", "Host gw\n IdentityFile /tmp/k3\n");
dir.write("conf.d/skip.txt", "Host gw\n Port 9999\n");
let root = dir.write(
"root.cfg",
&format!("Include {}/conf.d/*.cfg\n", dir.path.display()),
);
let cfg = SshClientConfig::load(&root).unwrap();
let eff = cfg.lookup("gw");
assert_eq!(eff.port, Some(2001));
assert_eq!(eff.user.as_deref(), Some("u2"));
assert_eq!(eff.identity_files, vec!["/tmp/k3"]);
}
#[test]
fn include_relative_to_containing_file() {
let dir = TempDir::new("relative");
dir.write("sibling.cfg", "Host gw\n Port 7777\n");
let root = dir.write("root.cfg", "Include sibling.cfg\n");
let cfg = SshClientConfig::load(&root).unwrap();
assert_eq!(cfg.lookup("gw").port, Some(7777));
}
#[test]
fn include_missing_file_warned_not_fatal() {
let dir = TempDir::new("missing");
let root = dir.write(
"root.cfg",
&format!(
"Host gw\n Port 22\nInclude {}/nope.cfg\n",
dir.path.display()
),
);
let cfg = SshClientConfig::load(&root).expect("missing include is non-fatal");
assert_eq!(cfg.lookup("gw").port, Some(22));
}
#[test]
fn include_circular_capped_at_16_depth() {
let dir = TempDir::new("circ");
let p = dir.path.join("loop.cfg");
let body = format!("Include {}\n", p.display());
std::fs::write(&p, body).expect("write loop.cfg");
let err = SshClientConfig::load(&p).unwrap_err();
match err {
ConfigError::Syntax { msg, .. } => {
assert!(msg.contains("max depth"), "msg = {msg}");
}
_ => panic!("wrong err: {err:?}"),
}
}
#[test]
fn include_load_with_base_resolves_relative() {
let dir = TempDir::new("loadbase");
dir.write("inner.cfg", "Host gw\n Port 9999\n");
let src = "Include inner.cfg\n";
let cfg = SshClientConfig::load_with_base(src, &dir.path).unwrap();
assert_eq!(cfg.lookup("gw").port, Some(9999));
}
#[test]
fn include_inside_match_block_only_applies_there() {
let dir = TempDir::new("inblock");
dir.write("only_gw.cfg", "Port 3300\n");
let root = dir.write(
"root.cfg",
"Host gw\n Include only_gw.cfg\nHost other\n Port 22\n",
);
let cfg = SshClientConfig::load(&root).unwrap();
assert_eq!(cfg.lookup("gw").port, Some(3300));
assert_eq!(cfg.lookup("other").port, Some(22));
}
}
}