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, evaluate, parse_match_line,
};
use super::parser::{ParsedLine, tokenize};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum StrictMode {
Yes,
No,
AcceptNew,
Ask,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum RequestTty {
No,
Yes,
Force,
Auto,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum CanonicalizeHostname {
No,
Yes,
Always,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SessionType {
None,
Subsystem,
Default,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum AddressFamily {
Any,
Inet,
Inet6,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum KnockSetting {
No,
Auto,
Always,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum GatewayPorts {
No,
Yes,
ClientSpecified,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum IdentityAgent {
None,
Path(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ControlMaster {
No,
Yes,
Auto,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ControlPersist {
No,
Yes,
Seconds(u64),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
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)]
#[non_exhaustive]
pub struct DynamicForwardSpec {
pub bind_addr: Option<String>,
pub listen_port: u16,
}
impl DynamicForwardSpec {
pub fn new(bind_addr: Option<String>, listen_port: u16) -> Self {
Self {
bind_addr,
listen_port,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct LocalForwardSpec {
pub bind_addr: Option<String>,
pub listen_port: u16,
pub remote_host: String,
pub remote_port: u16,
pub listen_path: Option<String>,
pub connect_path: Option<String>,
}
impl LocalForwardSpec {
pub fn new(
bind_addr: Option<String>,
listen_port: u16,
remote_host: impl Into<String>,
remote_port: u16,
) -> Self {
Self {
bind_addr,
listen_port,
remote_host: remote_host.into(),
remote_port,
listen_path: None,
connect_path: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct RemoteForwardSpec {
pub bind_addr: Option<String>,
pub remote_port: u16,
pub local_host: String,
pub local_port: u16,
pub dynamic: bool,
pub listen_path: Option<String>,
pub connect_path: Option<String>,
}
impl RemoteForwardSpec {
pub fn new(
bind_addr: Option<String>,
remote_port: u16,
local_host: impl Into<String>,
local_port: u16,
) -> Self {
Self {
bind_addr,
remote_port,
local_host: local_host.into(),
local_port,
dynamic: false,
listen_path: None,
connect_path: None,
}
}
pub fn dynamic(bind_addr: Option<String>, remote_port: u16) -> Self {
Self {
bind_addr,
remote_port,
local_host: String::new(),
local_port: 0,
dynamic: true,
listen_path: None,
connect_path: None,
}
}
}
#[derive(Default, Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
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 unimplemented: Vec<(String, String)>,
pub forward_agent_socket: Option<String>,
pub forward_x11: Option<bool>,
pub forward_x11_trusted: Option<bool>,
pub request_tty: Option<RequestTty>,
pub canonicalize_hostname: Option<CanonicalizeHostname>,
pub rekey_limit: Option<crate::config::RekeyLimit>,
pub permit_remote_open: Option<Vec<String>>,
pub canonical_domains: Option<Vec<String>>,
pub canonicalize_max_dots: Option<u32>,
pub canonicalize_fallback_local: Option<bool>,
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>,
pub knock_mode: Option<KnockSetting>,
pub knock_fallback_delay: Option<u32>,
pub knock_port: Option<u16>,
pub escape_char: Option<Option<u8>>,
pub remote_command: Option<String>,
pub known_hosts_command: Option<String>,
pub update_host_keys: Option<String>,
pub tag: Option<String>,
pub local_command: Option<String>,
pub permit_local_command: Option<bool>,
pub session_type: Option<SessionType>,
pub stdin_null: Option<bool>,
pub fork_after_authentication: Option<bool>,
pub password_authentication: Option<bool>,
pub kbd_interactive_authentication: Option<bool>,
pub host_key_alias: Option<String>,
pub revoked_host_keys: Option<String>,
pub global_known_hosts_files: Option<Vec<String>>,
pub connection_attempts: Option<u32>,
}
#[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_expanding<P: AsRef<std::path::Path>>(
path: P,
expand: &dyn Fn(&str) -> String,
) -> Result<Self, ConfigError> {
let lines =
super::include::tokenize_file_with_includes_expanding(path.as_ref(), 0, expand)?;
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 wants_final_pass(&self) -> bool {
self.blocks.iter().any(|b| {
matches!(b, Block::Match { conditions, .. }
if conditions.iter().any(|c| matches!(c, MatchCondition::Final)))
})
}
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 fn lookup_with_exec(
&self,
ctx: MatchContext<'_>,
exec: &mut dyn FnMut(&str, &ClientOptions) -> bool,
) -> ClientOptions {
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, .. } => {
!conditions.is_empty()
&& conditions
.iter()
.all(|c| eval_with_options(c, &ctx, &out, exec))
}
};
if matches {
merge_into(&mut out, block.opts());
}
}
out
}
}
fn eval_with_options(
c: &MatchCondition,
ctx: &MatchContext<'_>,
so_far: &ClientOptions,
exec: &mut dyn FnMut(&str, &ClientOptions) -> bool,
) -> bool {
match c {
MatchCondition::Exec(cmd) => exec(cmd, so_far),
MatchCondition::Not(inner) => !eval_with_options(inner, ctx, so_far, exec),
MatchCondition::Tagged(patterns) => {
let tag = ctx.tag.or(so_far.tag.as_deref()).unwrap_or("");
crate::config::host_matches(patterns, tag)
}
other => evaluate(other, ctx, ExecPolicy::Deny),
}
}
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(),
}];
let mut ignore_unknown: Option<String> = None;
let mut at_include: Vec<usize> = Vec::new();
let mut guards: Vec<Vec<MatchCondition>> = Vec::new();
for line in lines {
let mut apply = || -> Result<(), ConfigError> {
let guard: Vec<MatchCondition> = guards.iter().flatten().cloned().collect();
match line.keyword.as_str() {
super::parser::INCLUDE_BEGIN => {
at_include.push(blocks.len());
let enclosing = match blocks.last() {
Some(Block::Host { patterns, .. })
if !matches!(patterns.as_slice(), [HostPattern::Any]) =>
{
vec![MatchCondition::Host(patterns.clone())]
}
Some(Block::Match { conditions, .. }) => conditions.clone(),
_ => Vec::new(),
};
let own: Vec<MatchCondition> = enclosing
.into_iter()
.skip(if matches!(blocks.last(), Some(Block::Match { .. })) {
guard.len()
} else {
0
})
.collect();
guards.push(own);
}
super::parser::INCLUDE_END => {
guards.pop();
let saved = at_include.pop().unwrap_or(blocks.len());
if saved != blocks.len() {
let reopened = match &blocks[saved - 1] {
Block::Host { patterns, .. } => Block::Host {
patterns: patterns.clone(),
opts: ClientOptions::default(),
},
Block::Match { conditions, .. } => Block::Match {
conditions: conditions.clone(),
opts: ClientOptions::default(),
},
};
blocks.push(reopened);
}
}
"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(),
});
}
let patterns = HostPattern::parse_all(&line.args);
if guard.is_empty() {
blocks.push(Block::Host {
patterns,
opts: ClientOptions::default(),
});
} else {
let mut conditions = guard;
conditions.push(MatchCondition::Host(patterns));
blocks.push(Block::Match {
conditions,
opts: ClientOptions::default(),
});
}
}
"match" => {
let mut conditions = guard;
conditions.extend(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(),
});
}
"ignoreunknown" => {
if ignore_unknown.is_none() {
ignore_unknown = Some(line.args.join(","));
}
}
_ => {
let current = blocks.last_mut().expect("global block always present");
match apply_keyword(current.opts_mut(), &line) {
Err(ConfigError::UnknownKeyword { .. })
if ignore_unknown.as_deref().is_some_and(|pats| {
let pats: Vec<HostPattern> = pats
.split(',')
.map(|p| HostPattern::parse(&p.to_ascii_lowercase()))
.collect();
host_matches(&pats, &line.keyword)
}) => {}
r => r?,
}
}
}
Ok(())
};
apply().map_err(|e| e.in_file(line.file.as_deref()))?;
}
Ok(blocks)
}
fn apply_keyword(opts: &mut ClientOptions, line: &ParsedLine) -> Result<(), ConfigError> {
let kw = match line.keyword.as_str() {
"challengeresponseauthentication" => "kbdinteractiveauthentication",
"dsaauthentication" => "pubkeyauthentication",
kw => kw,
};
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" => {
if args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "expected one or more files".into(),
});
}
opts.user_known_hosts = Some(args.join(" "));
}
"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" => {
match parse_yes_no(line) {
Ok(v) => opts.forward_agent = Some(v),
Err(_) => {
opts.forward_agent = Some(true);
opts.forward_agent_socket = Some(one_arg(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)?);
}
"permitremoteopen" => {
let alone = |v: &str| v.eq_ignore_ascii_case("any") || v.eq_ignore_ascii_case("none");
for a in args.iter() {
let ok = if alone(a) {
args.len() == 1
} else {
match a.rsplit_once(':') {
Some((h, p)) => !h.is_empty() && (p == "*" || p.parse::<u16>().is_ok()),
None => false,
}
};
if !ok {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("invalid permission {a:?}"),
});
}
}
opts.permit_remote_open = Some(args.to_vec());
}
"rekeylimit" => {
opts.rekey_limit = Some(super::server::parse_rekey_limit(line)?);
}
"canonicalizehostname" => {
let v = one_arg(line)?.to_ascii_lowercase();
opts.canonicalize_hostname = Some(match v.as_str() {
"no" | "false" => CanonicalizeHostname::No,
"yes" | "true" => CanonicalizeHostname::Yes,
"always" => CanonicalizeHostname::Always,
_ => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected no/yes/always, got {v:?}"),
});
}
});
}
"canonicaldomains" => {
opts.canonical_domains = Some(
args.iter()
.filter(|d| !d.eq_ignore_ascii_case("none"))
.map(|d| d.trim_end_matches('.').to_ascii_lowercase())
.collect(),
);
}
"canonicalizemaxdots" => {
opts.canonicalize_max_dots = Some(parse_u32(line)?);
}
"canonicalizefallbacklocal" => {
opts.canonicalize_fallback_local = Some(parse_yes_no(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 opts.proxy_command.is_none() && opts.proxy_jump.is_none() {
opts.proxy_command =
Some(if args.len() == 1 && args[0].eq_ignore_ascii_case("none") {
"none".to_string()
} else {
line.rest.clone()
});
}
}
"proxyjump" => {
let v = one_arg(line)?;
if opts.proxy_jump.is_none() && opts.proxy_command.is_none() {
opts.proxy_jump = Some(if v.eq_ignore_ascii_case("none") {
"none".to_string()
} else {
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 v = one_arg(line)?;
opts.connect_timeout = Some(if v.eq_ignore_ascii_case("none") {
0
} else {
parse_time(line, &v)?
});
}
"serveraliveinterval" => {
let v = one_arg(line)?;
opts.server_alive_interval = Some(parse_time(line, &v)?);
}
"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)?);
}
"knockmode" => {
let s = one_arg(line)?.to_ascii_lowercase();
opts.knock_mode = Some(match s.as_str() {
"no" | "never" | "false" | "off" => KnockSetting::No,
"auto" | "yes" | "true" | "on" => KnockSetting::Auto,
"always" => KnockSetting::Always,
other => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected no/auto/always, got {other:?}"),
});
}
});
}
"knockfallbackdelay" => {
opts.knock_fallback_delay = Some(parse_u32(line)?);
}
"knockport" => {
let port = parse_u32(line)?;
if port == 0 || port > u16::MAX as u32 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "KnockPort must be 1..=65535".into(),
});
}
opts.knock_port = Some(port as u16);
}
"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,
)?);
}
"escapechar" => {
let v = one_arg(line)?;
opts.escape_char =
Some(parse_escape_char(&v).ok_or_else(|| ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected a character, ^X or none, got {v:?}"),
})?);
}
"knownhostscommand" => {
if opts.known_hosts_command.is_none() {
opts.known_hosts_command = Some(line.rest.clone());
}
}
"updatehostkeys" => {
let v = one_arg(line)?.to_ascii_lowercase();
let v = match v.as_str() {
"yes" | "true" => "yes",
"no" | "false" => "no",
"ask" => "ask",
_ => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected yes/no/ask, got {v:?}"),
});
}
};
if opts.update_host_keys.is_none() {
opts.update_host_keys = Some(v.to_string());
}
}
"tag" => {
let v = one_arg(line)?;
if opts.tag.is_none() {
opts.tag = Some(v);
}
}
"localcommand" => {
if opts.local_command.is_none() {
opts.local_command = Some(line.rest.clone());
}
}
"permitlocalcommand" => {
opts.permit_local_command = Some(parse_yes_no(line)?);
}
"remotecommand" => {
if opts.remote_command.is_none() {
opts.remote_command = Some(line.rest.clone());
}
}
"sessiontype" => {
let v = one_arg(line)?.to_ascii_lowercase();
opts.session_type = Some(match v.as_str() {
"none" => SessionType::None,
"subsystem" => SessionType::Subsystem,
"default" => SessionType::Default,
_ => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected none/subsystem/default, got {v:?}"),
});
}
});
}
"stdinnull" => opts.stdin_null = Some(parse_yes_no(line)?),
"forkafterauthentication" => opts.fork_after_authentication = Some(parse_yes_no(line)?),
"passwordauthentication" => opts.password_authentication = Some(parse_yes_no(line)?),
"kbdinteractiveauthentication" => {
opts.kbd_interactive_authentication = Some(parse_yes_no(line)?)
}
"hostkeyalias" => opts.host_key_alias = Some(one_arg(line)?),
"revokedhostkeys" => opts.revoked_host_keys = Some(one_arg(line)?),
"globalknownhostsfile" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "expected one or more files".into(),
});
}
opts.global_known_hosts_files = Some(line.args.clone());
}
"connectionattempts" => {
let v = one_arg(line)?;
opts.connection_attempts =
Some(v.parse::<u32>().ok().filter(|n| *n > 0).ok_or_else(|| {
ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected a positive number, got {v:?}"),
}
})?);
}
_ => {
if let Some((_, allowed, why)) = IGNORED_KEYWORDS
.iter()
.find(|(k, _, _)| k.eq_ignore_ascii_case(kw))
{
let value = line.args.join(" ");
if allowed.is_empty()
|| allowed.contains(&"*")
|| allowed.iter().any(|a| a.eq_ignore_ascii_case(&value))
{
if !opts.unimplemented.iter().any(|(k, _)| k == kw) {
opts.unimplemented.push((kw.to_string(), value));
}
return Ok(());
}
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: format!("{} {value}: {why}", line.keyword),
});
}
return Err(ConfigError::UnknownKeyword {
line: line.line_no,
keyword: kw.to_string(),
});
}
}
let _ = args;
Ok(())
}
pub fn parse_escape_char(v: &str) -> Option<Option<u8>> {
if v.eq_ignore_ascii_case("none") {
return Some(None);
}
match v.as_bytes() {
[c] => Some(Some(*c)),
[b'^', c] => Some(Some(c.to_ascii_uppercase() & 0x1f)),
_ => None,
}
}
const IGNORED_KEYWORDS: &[(&str, &[&str], &str)] = &[
("BindInterface", &["*"], ""),
("CanonicalizePermittedCNAMEs", &["*"], ""),
("ChannelTimeout", &["*"], ""),
("CheckHostIP", &["*"], ""),
("EnableEscapeCommandline", &["*"], ""),
(
"EnableSSHKeysign",
&["no"],
"host-based authentication is not supported",
),
("FingerprintHash", &["*"], ""),
("ForwardX11Timeout", &["*"], ""),
("GSSAPIAuthentication", &["*"], ""),
("GSSAPIDelegateCredentials", &["*"], ""),
("GSSAPIKeyExchange", &["*"], ""),
("GSSAPIClientIdentity", &["*"], ""),
("GSSAPIServerIdentity", &["*"], ""),
("GSSAPIRenewalForcesRekey", &["*"], ""),
("GSSAPITrustDns", &["*"], ""),
("GSSAPIKexAlgorithms", &["*"], ""),
("HostbasedAcceptedAlgorithms", &["*"], ""),
("HostbasedKeyTypes", &["*"], ""),
("HostbasedAuthentication", &["*"], ""),
("IPQoS", &["*"], ""),
("KbdInteractiveDevices", &["*"], ""),
("LogVerbose", &["*"], ""),
("NoHostAuthenticationForLocalhost", &["*"], ""),
(
"PKCS11Provider",
&["none"],
"PKCS#11 tokens are not supported",
),
(
"ProxyUseFdpass",
&["no"],
"a ProxyCommand that passes a file descriptor is not supported",
),
(
"RequiredRSASize",
&["1024", "2048"],
"RSA keys under 2048 bits are always refused; a higher floor is not supported",
),
("SecurityKeyProvider", &["*"], ""),
("StreamLocalBindMask", &["*"], ""),
("StreamLocalBindUnlink", &["*"], ""),
("SyslogFacility", &["*"], ""),
(
"Tunnel",
&["yes", "true", "no", "false", "point-to-point", "ethernet"],
"expected yes, no, point-to-point or ethernet",
),
("TunnelDevice", &["*"], ""),
("UseKeychain", &["*"], ""),
("VerifyHostKeyDNS", &["*"], ""),
("VisualHostKey", &["*"], ""),
("WarnWeakCrypto", &["*"], ""),
("XAuthLocation", &["*"], ""),
("Protocol", &["*"], ""),
("Cipher", &["*"], ""),
("CompressionLevel", &["*"], ""),
("UseRoaming", &["*"], ""),
("RSAAuthentication", &["*"], ""),
("RhostsRSAAuthentication", &["*"], ""),
("UsePrivilegedPort", &["*"], ""),
("FallBackToRsh", &["*"], ""),
("UseRsh", &["*"], ""),
];
impl ClientOptions {
pub fn merge_lower(&mut self, lower: &ClientOptions) {
merge_into(self, lower);
}
}
fn merge_into(dst: &mut ClientOptions, src: &ClientOptions) {
for (k, v) in &src.unimplemented {
if !dst.unimplemented.iter().any(|(d, _)| d == k) {
dst.unimplemented.push((k.clone(), v.clone()));
}
}
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_agent_socket);
take_scalar!(forward_x11);
take_scalar!(forward_x11_trusted);
take_scalar!(request_tty);
take_scalar!(canonicalize_hostname);
take_scalar!(rekey_limit);
take_scalar!(permit_remote_open);
take_scalar!(canonical_domains);
take_scalar!(canonicalize_max_dots);
take_scalar!(canonicalize_fallback_local);
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);
if dst.proxy_jump.is_none() {
take_scalar!(proxy_command);
}
if dst.proxy_command.is_none() {
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);
take_scalar!(knock_mode);
take_scalar!(knock_fallback_delay);
take_scalar!(knock_port);
take_scalar!(escape_char);
take_scalar!(remote_command);
take_scalar!(local_command);
take_scalar!(tag);
take_scalar!(update_host_keys);
take_scalar!(known_hosts_command);
take_scalar!(permit_local_command);
take_scalar!(session_type);
take_scalar!(stdin_null);
take_scalar!(fork_after_authentication);
take_scalar!(password_authentication);
take_scalar!(kbd_interactive_authentication);
take_scalar!(host_key_alias);
take_scalar!(revoked_host_keys);
take_scalar!(global_known_hosts_files);
take_scalar!(connection_attempts);
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(),
});
}
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_time(line: &ParsedLine, v: &str) -> Result<u32, ConfigError> {
let bad = || ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected a time (e.g. 30, 1m, 1h30m), got {v:?}"),
};
let mut total: u64 = 0;
let mut num: Option<u64> = None;
for c in v.chars() {
if let Some(d) = c.to_digit(10) {
num = Some(num.unwrap_or(0).checked_mul(10).ok_or_else(bad)? + u64::from(d));
continue;
}
let scale = match c.to_ascii_lowercase() {
's' => 1,
'm' => 60,
'h' => 3600,
'd' => 86400,
'w' => 604800,
_ => return Err(bad()),
};
total = total
.checked_add(
num.take()
.ok_or_else(bad)?
.checked_mul(scale)
.ok_or_else(bad)?,
)
.ok_or_else(bad)?;
}
if let Some(n) = num {
total = total.checked_add(n).ok_or_else(bad)?;
} else if v.is_empty() {
return Err(bad());
}
u32::try_from(total).map_err(|_| bad())
}
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" | "true" => Ok(StrictMode::Yes),
"no" | "off" | "false" => 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();
let s = if s == "DEBUG" {
"DEBUG1".to_string()
} else {
s
};
match super::server::LOG_LEVELS.iter().position(|l| *l == s) {
Some(n) => Ok(n as u8),
None => 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> {
let bad = || ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "Bad forwarding specification".into(),
};
if line.args.len() != 2 {
return Err(bad());
}
let f = parse_forward(&alloc::format!("{}:{}", line.args[0], line.args[1]), false)
.ok_or_else(bad)?;
f.into_local().ok_or_else(bad)
}
fn parse_remote_forward(line: &ParsedLine) -> Result<RemoteForwardSpec, ConfigError> {
let bad = || ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "Bad forwarding specification".into(),
};
let spec = match line.args.as_slice() {
[a] => a.clone(),
[a, b] => alloc::format!("{a}:{b}"),
_ => return Err(bad()),
};
Ok(parse_forward(&spec, true).ok_or_else(bad)?.into_remote())
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ForwardEndpoint {
Tcp {
host: Option<String>,
port: u16,
},
Path(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ForwardSpec {
pub listen: ForwardEndpoint,
pub connect: Option<ForwardEndpoint>,
}
impl ForwardSpec {
pub fn into_local(self) -> Option<LocalForwardSpec> {
let mut out = LocalForwardSpec::new(None, 0, String::new(), 0);
match self.listen {
ForwardEndpoint::Tcp { host, port } => {
out.bind_addr = host;
out.listen_port = port;
}
ForwardEndpoint::Path(p) => out.listen_path = Some(p),
}
match self.connect? {
ForwardEndpoint::Tcp { host, port } => {
out.remote_host = host?;
out.remote_port = port;
}
ForwardEndpoint::Path(p) => out.connect_path = Some(p),
}
Some(out)
}
pub fn into_remote(self) -> RemoteForwardSpec {
let mut out = RemoteForwardSpec::dynamic(None, 0);
match self.listen {
ForwardEndpoint::Tcp { host, port } => {
out.bind_addr = host;
out.remote_port = port;
}
ForwardEndpoint::Path(p) => out.listen_path = Some(p),
}
match self.connect {
None => {}
Some(ForwardEndpoint::Tcp { host, port }) => {
out.dynamic = false;
out.local_host = host.unwrap_or_default();
out.local_port = port;
}
Some(ForwardEndpoint::Path(p)) => {
out.dynamic = false;
out.connect_path = Some(p);
}
}
out
}
}
pub fn parse_forward(spec: &str, remote: bool) -> Option<ForwardSpec> {
struct Field {
arg: String,
is_path: bool,
}
let mut fields: Vec<Field> = Vec::new();
let mut rest = spec.trim_start();
while !rest.is_empty() {
if fields.len() == 4 {
return None;
}
if let Some(r) = rest.strip_prefix('[') {
let close = r.find(']')?;
let after = &r[close + 1..];
if !(after.is_empty() || after.starts_with(':')) {
return None;
}
fields.push(Field {
arg: r[..close].to_string(),
is_path: r[..close].contains('/'),
});
rest = after.strip_prefix(':').unwrap_or(after);
continue;
}
let mut arg = String::new();
let mut is_path = false;
let mut chars = rest.char_indices();
let mut next = "";
while let Some((i, c)) = chars.next() {
match c {
'\\' => arg.push(chars.next()?.1),
':' => {
next = &rest[i + 1..];
break;
}
c => {
if c == '/' {
is_path = true;
}
arg.push(c);
}
}
}
fields.push(Field { arg, is_path });
rest = next;
}
let port = |s: &str| s.parse::<u16>().ok();
let tcp = |host: Option<&Field>, p: &Field| {
Some(ForwardEndpoint::Tcp {
host: host.map(|h| h.arg.clone()),
port: port(&p.arg)?,
})
};
let path = |f: &Field| ForwardEndpoint::Path(f.arg.clone());
let (listen, connect) = match fields.as_slice() {
[a] if a.is_path => (path(a), None),
[a] => (tcp(None, a)?, None),
[a, b] if a.is_path && b.is_path => (path(a), Some(path(b))),
[a, b] if b.is_path => (tcp(None, a)?, Some(path(b))),
[a, b] => (tcp(Some(a), b)?, None),
[a, b, c] if a.is_path => (path(a), Some(tcp(Some(b), c)?)),
[a, b, c] if c.is_path => (tcp(Some(a), b)?, Some(path(c))),
[a, b, c] => (tcp(None, a)?, Some(tcp(Some(b), c)?)),
[a, b, c, d] => (tcp(Some(a), b)?, Some(tcp(Some(c), d)?)),
_ => return None,
};
if connect.is_none() && !remote {
return None;
}
if let Some(ForwardEndpoint::Tcp { host, port }) = &connect
&& (*port == 0 || host.as_deref().is_none_or(str::is_empty))
{
return None;
}
if !remote && matches!(listen, ForwardEndpoint::Tcp { port: 0, .. }) {
return None;
}
Some(ForwardSpec { listen, connect })
}
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))
}
}
}
#[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_is_an_opt_in() {
let src = "Host legacy\n PubkeyAcceptedAlgorithms +ssh-rsa\n";
let cfg = SshClientConfig::parse(src).unwrap();
let algs = cfg.lookup("legacy").pubkey_accepted_algorithms.unwrap();
assert_eq!(algs.last().map(String::as_str), Some("ssh-rsa"), "{algs:?}");
assert!(algs.iter().any(|a| a == "rsa-sha2-512"), "{algs:?}");
}
#[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.as_deref(), Some("none"));
}
#[test]
fn match_criteria_take_values_negation_and_tags() {
let src = "\
Match host=a !user nobody
Port 1001
Match host=b tagged t1
Port 1002
Match host=b !tagged t1
Port 1003
";
let cfg = SshClientConfig::parse(src).unwrap();
let ctx = |host| MatchContext {
host,
user: Some("me"),
..Default::default()
};
assert_eq!(cfg.lookup_with(ctx("a")).port, Some(1001));
assert_eq!(cfg.lookup_with(ctx("b")).port, Some(1003));
let mut tagged = ctx("b");
tagged.tag = Some("t1");
assert_eq!(cfg.lookup_with(tagged).port, Some(1002));
}
#[test]
fn proxy_jump_and_proxy_command_first_obtained_wins() {
let cfg = SshClientConfig::parse("ProxyJump j\nProxyCommand c\n").unwrap();
let eff = cfg.lookup("h");
assert_eq!(
(eff.proxy_jump.as_deref(), eff.proxy_command),
(Some("j"), None)
);
let cfg = SshClientConfig::parse("ProxyCommand none\nProxyJump j\n").unwrap();
let eff = cfg.lookup("h");
assert_eq!(
(eff.proxy_jump, eff.proxy_command.as_deref()),
(None, Some("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.as_deref(), Some("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 NoSuchOption 9\n";
let err = SshClientConfig::parse(src).unwrap_err();
match err {
ConfigError::UnknownKeyword { keyword, line } => {
assert_eq!(keyword, "nosuchoption");
assert_eq!(line, 2);
}
_ => panic!("wrong error: {err:?}"),
}
}
#[test]
fn openssh_keywords_accepted() {
let src = "Host *\n\
\x20 SendEnv LANG LC_*\n\
\x20 HashKnownHosts yes\n\
\x20 GSSAPIAuthentication yes\n\
\x20 CheckHostIP no\n\
\x20 VisualHostKey yes\n\
\x20 UpdateHostKeys yes\n\
\x20 Tunnel no\n\
\x20 ChallengeResponseAuthentication no\n\
\x20 RevokedHostKeys /etc/ssh/ssh_revoked_hosts\n\
\x20 HostKeyAlias gateway\n\
\x20 EscapeChar ^]\n\
\x20 PasswordAuthentication no\n\
\x20 SessionType none\n\
\x20 GlobalKnownHostsFile /etc/a /etc/b\n\
\x20 ConnectionAttempts 3\n";
let cfg = SshClientConfig::parse(src).unwrap();
let o = cfg.lookup("example.com");
assert_eq!(o.kbd_interactive_authentication, Some(false));
assert_eq!(o.password_authentication, Some(false));
assert_eq!(
o.revoked_host_keys.as_deref(),
Some("/etc/ssh/ssh_revoked_hosts")
);
assert_eq!(o.host_key_alias.as_deref(), Some("gateway"));
assert_eq!(o.escape_char, Some(Some(0x1d)));
assert_eq!(o.session_type, Some(SessionType::None));
assert_eq!(
o.global_known_hosts_files,
Some(vec!["/etc/a".to_string(), "/etc/b".to_string()])
);
assert_eq!(o.connection_attempts, Some(3));
}
#[test]
fn unsupported_values_refused() {
for src in [
"Tunnel maybe\n",
"ProxyUseFdpass yes\n",
"PKCS11Provider /usr/lib/opensc-pkcs11.so\n",
"RequiredRSASize 4096\n",
] {
assert!(
matches!(
SshClientConfig::parse(src),
Err(ConfigError::Unsupported { .. })
),
"{src}"
);
}
}
#[test]
fn ignore_unknown_applies_to_later_lines() {
assert!(SshClientConfig::parse("UseFoo yes\nIgnoreUnknown UseFoo\n").is_err());
let cfg = SshClientConfig::parse("IgnoreUnknown Use*,Bar\nUseFoo yes\nHost x\n Bar 1\n");
assert!(cfg.is_ok(), "{cfg:?}");
assert!(SshClientConfig::parse("IgnoreUnknown Bar\nBaz 1\n").is_err());
}
#[test]
fn escape_char_forms() {
assert_eq!(parse_escape_char("none"), Some(None));
assert_eq!(parse_escape_char("~"), Some(Some(b'~')));
assert_eq!(parse_escape_char("^A"), Some(Some(1)));
assert_eq!(parse_escape_char("ab"), None);
}
#[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_takes_times_and_none() {
let t = |s: &str| {
SshClientConfig::parse(&format!("ConnectTimeout {s}\n"))
.map(|c| c.lookup("h").connect_timeout)
};
assert_eq!(t("10").unwrap(), Some(10));
assert_eq!(t("1m30s").unwrap(), Some(90));
assert_eq!(t("2h").unwrap(), Some(7200));
assert_eq!(t("none").unwrap(), Some(0));
assert!(matches!(t("10x"), 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 knock_keywords_parse() {
let cfg = SshClientConfig::parse(
"Host a\n KnockMode always\n KnockFallbackDelay 250\n KnockPort 2222\n\
Host *\n KnockMode no\n",
)
.unwrap();
let a = cfg.lookup("a");
assert_eq!(a.knock_mode, Some(KnockSetting::Always));
assert_eq!(a.knock_fallback_delay, Some(250));
assert_eq!(a.knock_port, Some(2222));
assert_eq!(cfg.lookup("b").knock_mode, Some(KnockSetting::No));
assert!(SshClientConfig::parse("KnockMode maybe\n").is_err());
assert!(SshClientConfig::parse("KnockPort 0\n").is_err());
}
#[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_accepts_any_method_like_openssh() {
let cfg = SshClientConfig::parse(
"PreferredAuthentications gssapi-with-mic,gssapi-keyex,hostbased,publickey\n",
)
.unwrap();
assert_eq!(
cfg.lookup("h").preferred_authentications.unwrap(),
["gssapi-with-mic", "gssapi-keyex", "hostbased", "publickey"]
);
assert!(SshClientConfig::parse("PreferredAuthentications hostbased\n").is_ok());
}
#[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_matches_only_in_the_final_pass() {
let src = "\
Match final
Port 4242
";
let cfg = SshClientConfig::parse(src).unwrap();
assert_eq!(cfg.lookup("anything").port, None);
assert!(cfg.wants_final_pass());
let ctx = MatchContext {
host: "anything",
final_pass: true,
..MatchContext::default()
};
assert_eq!(cfg.lookup_with(ctx).port, Some(4242));
assert!(
!SshClientConfig::parse("Host x\n Port 1\n")
.unwrap()
.wants_final_pass()
);
let canonical_only = SshClientConfig::parse("Match canonical\n Port 1\n").unwrap();
assert!(!canonical_only.wants_final_pass());
}
#[test]
fn canonicalize_options_parse() {
let cfg = SshClientConfig::parse(
"CanonicalizeHostname always\nCanonicalDomains Example.COM. b.org\n\
CanonicalizeMaxDots 0\nCanonicalizeFallbackLocal no\n",
)
.unwrap();
let o = cfg.lookup("h");
assert_eq!(o.canonicalize_hostname, Some(CanonicalizeHostname::Always));
assert_eq!(
o.canonical_domains.as_deref(),
Some(&["example.com".to_string(), "b.org".to_string()][..])
);
assert_eq!(o.canonicalize_max_dots, Some(0));
assert_eq!(o.canonicalize_fallback_local, Some(false));
assert!(SshClientConfig::parse("CanonicalizeHostname maybe\n").is_err());
}
#[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_inside_a_block_applies_only_where_the_block_does() {
let dir = TempDir::new("cond");
dir.write("inc.cfg", "Host *\n Port 2222\nMatch all\n User inc\n");
let root = dir.write(
"root.cfg",
&format!(
"Host gw\n Include {}/inc.cfg\nHost *\n Port 22\n",
dir.path.display()
),
);
let cfg = SshClientConfig::load(&root).unwrap();
assert_eq!(cfg.lookup("gw").port, Some(2222));
assert_eq!(cfg.lookup("gw").user.as_deref(), Some("inc"));
assert_eq!(cfg.lookup("other").port, Some(22));
assert_eq!(cfg.lookup("other").user, None);
}
#[test]
fn include_paths_take_tokens_and_variables() {
let dir = TempDir::new("tokens");
dir.write("inc.gw", "Port 2022\n");
let root = dir.write(
"root.cfg",
&format!("Include {}/inc.%h\n", dir.path.display()),
);
let cfg = SshClientConfig::load_expanding(&root, &|p| p.replace("%h", "gw")).unwrap();
assert_eq!(cfg.lookup("gw").port, Some(2022));
}
#[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 = match SshClientConfig::load(&p).unwrap_err() {
ConfigError::InFile { error, .. } => *error,
e => e,
};
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 host_block_in_included_file_ends_with_it() {
let dir = TempDir::new("blockend");
dir.write("hosts.cfg", "Host db\n Port 5432\n");
let root = dir.write(
"root.cfg",
"Include hosts.cfg\nUser everyone\nHost gw\n Include hosts.cfg\n Port 2222\n",
);
let cfg = SshClientConfig::load(&root).unwrap();
assert_eq!(cfg.lookup("gw").port, Some(2222));
assert_eq!(cfg.lookup("other").user.as_deref(), Some("everyone"));
assert_eq!(cfg.lookup("db").user.as_deref(), Some("everyone"));
assert_eq!(cfg.lookup("db").port, Some(5432));
}
#[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));
}
}
}