use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use super::ConfigError;
use super::algos::{AlgoCategory, resolve_algo_list};
use super::match_block::{
ExecPolicy, MatchCondition, MatchContext, all_match, parse_match_line_server,
};
use super::parser::{ParsedLine, tokenize};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PermitRootLogin {
Yes,
No,
ProhibitPassword,
}
impl PermitRootLogin {
pub fn permits_publickey(self) -> bool {
matches!(
self,
PermitRootLogin::Yes | PermitRootLogin::ProhibitPassword
)
}
pub fn permits_password(self) -> bool {
matches!(self, PermitRootLogin::Yes)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ServerOptions {
pub port: Option<u16>,
pub listen_addresses: Vec<String>,
pub host_key_files: Vec<String>,
pub host_certificate_files: Vec<String>,
pub trusted_user_ca_keys: Option<String>,
pub revoked_keys: Option<String>,
pub authorized_principals_file: Option<String>,
pub authorized_keys_file: Option<String>,
pub allow_users: Vec<String>,
pub login_grace_time: Option<u32>,
pub max_startups: Option<u32>,
pub allow_agent_forwarding: Option<bool>,
pub x11_forwarding: Option<bool>,
pub accept_env: Vec<String>,
pub strict_modes: Option<bool>,
pub log_level: Option<u8>,
pub sftp_enabled: Option<bool>,
pub sftp_read_only: Option<bool>,
pub sftp_root: Option<String>,
pub scp_enabled: Option<bool>,
pub permit_root_login: Option<PermitRootLogin>,
pub ciphers: Option<Vec<String>>,
pub macs: Option<Vec<String>>,
pub kex_algorithms: Option<Vec<String>>,
pub host_key_algorithms: Option<Vec<String>>,
pub ca_signature_algorithms: Option<Vec<String>>,
pub pubkey_authentication: Option<bool>,
pub password_authentication: Option<bool>,
pub kbd_interactive_authentication: Option<bool>,
pub permit_empty_passwords: Option<bool>,
pub authentication_methods: Option<Vec<String>>,
pub max_auth_tries: Option<u32>,
pub deny_users: Vec<String>,
pub allow_groups: Vec<String>,
pub deny_groups: Vec<String>,
pub banner: Option<String>,
pub max_sessions: Option<u32>,
pub allow_tcp_forwarding: Option<TcpForwarding>,
pub permit_open: Option<Vec<HostPort>>,
pub permit_listen: Option<Vec<HostPort>>,
pub gateway_ports: Option<GatewayPorts>,
pub force_command: Option<String>,
pub chroot_directory: Option<String>,
pub client_alive_interval: Option<u32>,
pub client_alive_count_max: Option<u32>,
pub print_motd: Option<bool>,
pub compression: Option<Compression>,
pub rekey_limit: Option<RekeyLimit>,
pub address_family: Option<AddressFamily>,
pub pid_file: Option<String>,
pub pid_file_set: bool,
pub subsystem_sftp: Option<bool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TcpForwarding {
No,
All,
Local,
Remote,
}
impl TcpForwarding {
pub fn local_allowed(self) -> bool {
matches!(self, TcpForwarding::All | TcpForwarding::Local)
}
pub fn remote_allowed(self) -> bool {
matches!(self, TcpForwarding::All | TcpForwarding::Remote)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GatewayPorts {
No,
Yes,
ClientSpecified,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Compression {
No,
Yes,
Delayed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AddressFamily {
Any,
Inet,
Inet6,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostPort {
pub host: String,
pub port: Option<u16>,
}
impl HostPort {
pub fn matches(&self, host: &str, port: u16) -> bool {
let host_ok = self.host == "*" || self.host == host;
let port_ok = self.port.is_none_or(|p| p == port);
host_ok && port_ok
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RekeyLimit {
pub max_bytes: Option<u64>,
pub max_seconds: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServerMatchBlock {
pub conditions: Vec<MatchCondition>,
pub opts: ServerOptions,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SshServerConfig {
pub global: ServerOptions,
pub match_blocks: Vec<ServerMatchBlock>,
}
impl SshServerConfig {
pub fn parse(src: &str) -> Result<Self, ConfigError> {
Self::from_lines(tokenize(src)?)
}
#[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)?;
Self::from_lines(lines)
}
pub fn from_lines(lines: Vec<ParsedLine>) -> Result<Self, ConfigError> {
let mut global = ServerOptions::default();
let mut match_blocks: Vec<ServerMatchBlock> = Vec::new();
for line in lines {
if line.keyword == "include" {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: "Include requires file-based loading; use SshServerConfig::load() instead"
.into(),
});
}
if line.keyword == "match" {
let conditions = parse_match_line_server(&line.args, line.line_no)?;
match_blocks.push(ServerMatchBlock {
conditions,
opts: ServerOptions::default(),
});
continue;
}
let in_match = !match_blocks.is_empty();
let target = match match_blocks.last_mut() {
Some(b) => &mut b.opts,
None => &mut global,
};
if in_match {
reject_invalid_in_match(&line)?;
}
apply_keyword(target, &line)?;
}
Ok(SshServerConfig {
global,
match_blocks,
})
}
pub fn resolve(&self, ctx: &MatchContext<'_>, policy: ExecPolicy) -> ServerOptions {
let mut out = self.global.clone();
for block in &self.match_blocks {
if all_match(&block.conditions, ctx, policy) {
merge_server_options(&mut out, &block.opts);
}
}
out
}
}
fn merge_server_options(dst: &mut ServerOptions, src: &ServerOptions) {
macro_rules! take_scalar {
($field:ident) => {
if dst.$field.is_none() {
dst.$field = src.$field.clone();
}
};
}
take_scalar!(port);
take_scalar!(authorized_keys_file);
take_scalar!(login_grace_time);
take_scalar!(max_startups);
take_scalar!(allow_agent_forwarding);
take_scalar!(x11_forwarding);
take_scalar!(strict_modes);
take_scalar!(log_level);
take_scalar!(sftp_enabled);
take_scalar!(sftp_read_only);
take_scalar!(sftp_root);
take_scalar!(scp_enabled);
take_scalar!(permit_root_login);
take_scalar!(ciphers);
take_scalar!(macs);
take_scalar!(kex_algorithms);
take_scalar!(host_key_algorithms);
take_scalar!(ca_signature_algorithms);
take_scalar!(pubkey_authentication);
take_scalar!(password_authentication);
take_scalar!(kbd_interactive_authentication);
take_scalar!(permit_empty_passwords);
take_scalar!(authentication_methods);
take_scalar!(max_auth_tries);
take_scalar!(banner);
take_scalar!(max_sessions);
take_scalar!(allow_tcp_forwarding);
take_scalar!(permit_open);
take_scalar!(permit_listen);
take_scalar!(gateway_ports);
take_scalar!(force_command);
take_scalar!(chroot_directory);
take_scalar!(client_alive_interval);
take_scalar!(client_alive_count_max);
take_scalar!(print_motd);
take_scalar!(compression);
take_scalar!(rekey_limit);
take_scalar!(address_family);
take_scalar!(subsystem_sftp);
if !dst.pid_file_set && src.pid_file_set {
dst.pid_file = src.pid_file.clone();
dst.pid_file_set = true;
}
dst.listen_addresses
.extend(src.listen_addresses.iter().cloned());
dst.host_key_files
.extend(src.host_key_files.iter().cloned());
dst.host_certificate_files
.extend(src.host_certificate_files.iter().cloned());
take_scalar!(trusted_user_ca_keys);
take_scalar!(revoked_keys);
take_scalar!(authorized_principals_file);
dst.allow_users.extend(src.allow_users.iter().cloned());
dst.accept_env.extend(src.accept_env.iter().cloned());
dst.deny_users.extend(src.deny_users.iter().cloned());
dst.allow_groups.extend(src.allow_groups.iter().cloned());
dst.deny_groups.extend(src.deny_groups.iter().cloned());
}
fn reject_invalid_in_match(line: &ParsedLine) -> Result<(), ConfigError> {
const INVALID_IN_MATCH: &[&str] = &[
"port",
"listenaddress",
"hostkey",
"hostcertificate",
"addressfamily",
"pidfile",
"loglevel",
"compression",
"rekeylimit",
"logingracetime",
"maxstartups",
"strictmodes",
"include",
"subsystem",
"permittunnel",
];
if INVALID_IN_MATCH.contains(&line.keyword.as_str()) {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: format!("{} is not valid inside a Match block", line.keyword),
});
}
Ok(())
}
fn apply_keyword(opts: &mut ServerOptions, line: &ParsedLine) -> Result<(), ConfigError> {
let kw = line.keyword.as_str();
match kw {
"port" => {
opts.port = Some(parse_u16(line)?);
}
"listenaddress" => {
opts.listen_addresses.push(one_arg(line)?);
}
"hostkey" => {
opts.host_key_files.push(one_arg(line)?);
}
"hostcertificate" => {
opts.host_certificate_files.push(one_arg(line)?);
}
"trustedusercakeys" => {
opts.trusted_user_ca_keys = Some(one_arg(line)?);
}
"revokedkeys" => {
opts.revoked_keys = Some(one_arg(line)?);
}
"authorizedprincipalsfile" => {
opts.authorized_principals_file = Some(one_arg(line)?);
}
"authorizedkeysfile" => {
opts.authorized_keys_file = Some(one_arg(line)?);
}
"allowusers" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: "expected at least one user name".into(),
});
}
for u in &line.args {
validate_user_at_host(u, kw, line.line_no)?;
opts.allow_users.push(u.clone());
}
}
"logingracetime" => {
opts.login_grace_time = Some(parse_duration_seconds(line)?);
}
"maxstartups" => {
let s = one_arg(line)?;
if s.contains(':') {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: "MaxStartups start:rate:full triple not yet supported".into(),
});
}
opts.max_startups = Some(s.parse::<u32>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: format!("expected an integer, got {s:?}"),
})?);
}
"allowagentforwarding" => {
opts.allow_agent_forwarding = Some(parse_yes_no(line)?);
}
"x11forwarding" => {
opts.x11_forwarding = Some(parse_yes_no(line)?);
}
"acceptenv" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: "expected at least one env pattern".into(),
});
}
for p in &line.args {
opts.accept_env.push(p.clone());
}
}
"strictmodes" => {
opts.strict_modes = Some(parse_yes_no(line)?);
}
"loglevel" => {
opts.log_level = Some(parse_log_level(line)?);
}
"sftpenabled" => {
opts.sftp_enabled = Some(parse_yes_no(line)?);
}
"sftpreadonly" => {
opts.sftp_read_only = Some(parse_yes_no(line)?);
}
"sftproot" => {
opts.sftp_root = Some(one_arg(line)?);
}
"scpenabled" => {
opts.scp_enabled = Some(parse_yes_no(line)?);
}
"permitrootlogin" => {
opts.permit_root_login = Some(parse_permit_root_login(line)?);
}
"ciphers" => {
opts.ciphers = Some(resolve_algo_list(
AlgoCategory::Cipher,
&line.args,
line.line_no,
"Ciphers",
)?);
}
"macs" => {
opts.macs = Some(resolve_algo_list(
AlgoCategory::Mac,
&line.args,
line.line_no,
"MACs",
)?);
}
"kexalgorithms" => {
opts.kex_algorithms = Some(resolve_algo_list(
AlgoCategory::Kex,
&line.args,
line.line_no,
"KexAlgorithms",
)?);
}
"hostkeyalgorithms" => {
opts.host_key_algorithms = Some(resolve_algo_list(
AlgoCategory::HostKey,
&line.args,
line.line_no,
"HostKeyAlgorithms",
)?);
}
"casignaturealgorithms" => {
opts.ca_signature_algorithms = Some(resolve_algo_list(
AlgoCategory::CaSignature,
&line.args,
line.line_no,
"CASignatureAlgorithms",
)?);
}
"pubkeyauthentication" => {
opts.pubkey_authentication = Some(parse_yes_no(line)?);
}
"passwordauthentication" => {
opts.password_authentication = Some(parse_yes_no(line)?);
}
"kbdinteractiveauthentication" | "challengeresponseauthentication" => {
opts.kbd_interactive_authentication = Some(parse_yes_no(line)?);
}
"authenticationmethods" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: "expected at least one method list".into(),
});
}
for alt in &line.args {
if alt == "any" {
continue;
}
let factors: Vec<&str> = alt.split(',').filter(|s| !s.is_empty()).collect();
if factors.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: format!("empty method list in {alt:?}"),
});
}
for factor in factors {
if !matches!(
factor,
"publickey" | "password" | "keyboard-interactive" | "any" | "none"
) {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: format!("unknown authentication method {factor:?}"),
});
}
}
}
opts.authentication_methods = Some(line.args.clone());
}
"maxauthtries" => {
let s = one_arg(line)?;
opts.max_auth_tries = Some(s.parse::<u32>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: format!("expected an integer, got {s:?}"),
})?);
}
"denyusers" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: "expected at least one user pattern".into(),
});
}
for u in &line.args {
validate_user_at_host(u, kw, line.line_no)?;
opts.deny_users.push(u.clone());
}
}
"allowgroups" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: "expected at least one group pattern".into(),
});
}
for g in &line.args {
opts.allow_groups.push(g.clone());
}
}
"denygroups" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: "expected at least one group pattern".into(),
});
}
for g in &line.args {
opts.deny_groups.push(g.clone());
}
}
"permitemptypasswords" => {
opts.permit_empty_passwords = Some(parse_yes_no(line)?);
}
"banner" => {
let s = one_arg(line)?;
if s.eq_ignore_ascii_case("none") {
opts.banner = None;
} else {
opts.banner = Some(s);
}
}
"maxsessions" => {
let s = one_arg(line)?;
opts.max_sessions = Some(s.parse::<u32>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: format!("expected an integer, got {s:?}"),
})?);
}
"allowtcpforwarding" => {
opts.allow_tcp_forwarding = Some(parse_tcp_forwarding(line)?);
}
"permitopen" => {
opts.permit_open = Some(parse_host_port_list(line)?);
}
"permitlisten" => {
opts.permit_listen = Some(parse_host_port_list(line)?);
}
"gatewayports" => {
opts.gateway_ports = Some(parse_gateway_ports(line)?);
}
"forcecommand" => {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: "expected a command".into(),
});
}
opts.force_command = Some(line.args.join(" "));
}
"chrootdirectory" => {
let s = one_arg(line)?;
if s.eq_ignore_ascii_case("none") {
opts.chroot_directory = None;
} else {
opts.chroot_directory = Some(s);
}
}
"clientaliveinterval" => {
opts.client_alive_interval = Some(parse_duration_seconds(line)?);
}
"clientalivecountmax" => {
let s = one_arg(line)?;
opts.client_alive_count_max =
Some(s.parse::<u32>().map_err(|_| ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: format!("expected an integer, got {s:?}"),
})?);
}
"printmotd" => {
opts.print_motd = Some(parse_yes_no(line)?);
}
"compression" => {
opts.compression = Some(parse_compression(line)?);
}
"rekeylimit" => {
opts.rekey_limit = Some(parse_rekey_limit(line)?);
}
"addressfamily" => {
opts.address_family = Some(parse_address_family(line)?);
}
"pidfile" => {
let s = one_arg(line)?;
opts.pid_file_set = true;
if s.eq_ignore_ascii_case("none") {
opts.pid_file = None;
} else {
opts.pid_file = Some(s);
}
}
"subsystem" => {
if line.args.len() < 2 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: kw.to_string(),
msg: "expected `Subsystem <name> <command>`".into(),
});
}
let name = line.args[0].to_ascii_lowercase();
let command = &line.args[1];
if name == "sftp" && command.eq_ignore_ascii_case("internal-sftp") {
opts.subsystem_sftp = Some(true);
} else {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: format!(
"Subsystem {name:?}: only `sftp internal-sftp` is supported \
(external-command subsystems are not)"
),
});
}
}
"permittunnel" => {
return Err(ConfigError::Unsupported {
line: line.line_no,
msg: "PermitTunnel: tun/tap device forwarding is not supported".into(),
});
}
_ => {
return Err(ConfigError::UnknownKeyword {
line: line.line_no,
keyword: kw.to_string(),
});
}
}
Ok(())
}
fn parse_tcp_forwarding(line: &ParsedLine) -> Result<TcpForwarding, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"yes" | "all" => Ok(TcpForwarding::All),
"no" => Ok(TcpForwarding::No),
"local" => Ok(TcpForwarding::Local),
"remote" => Ok(TcpForwarding::Remote),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected yes/no/all/local/remote, got {s:?}"),
}),
}
}
fn parse_gateway_ports(line: &ParsedLine) -> Result<GatewayPorts, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"no" => Ok(GatewayPorts::No),
"yes" => Ok(GatewayPorts::Yes),
"clientspecified" => Ok(GatewayPorts::ClientSpecified),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected no/yes/clientspecified, got {s:?}"),
}),
}
}
fn parse_compression(line: &ParsedLine) -> Result<Compression, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"no" | "false" | "off" => Ok(Compression::No),
"yes" | "true" | "on" => Ok(Compression::Yes),
"delayed" => Ok(Compression::Delayed),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected no/yes/delayed, got {s:?}"),
}),
}
}
fn parse_address_family(line: &ParsedLine) -> Result<AddressFamily, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"any" => Ok(AddressFamily::Any),
"inet" => Ok(AddressFamily::Inet),
"inet6" => Ok(AddressFamily::Inet6),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected any/inet/inet6, got {s:?}"),
}),
}
}
fn parse_host_port_list(line: &ParsedLine) -> Result<Vec<HostPort>, ConfigError> {
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "expected at least one host:port (or any/none)".into(),
});
}
if line.args.len() == 1 {
let only = line.args[0].to_ascii_lowercase();
if only == "any" {
return Ok(alloc::vec![HostPort {
host: "*".into(),
port: None,
}]);
}
if only == "none" {
return Ok(Vec::new());
}
}
let mut out = Vec::with_capacity(line.args.len());
for spec in &line.args {
out.push(parse_host_port(spec, line)?);
}
Ok(out)
}
fn parse_host_port(spec: &str, line: &ParsedLine) -> Result<HostPort, ConfigError> {
let bad = || ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("malformed host:port entry {spec:?}"),
};
let (host, port_str) = if let Some(rest) = spec.strip_prefix('[') {
let close = rest.find(']').ok_or_else(bad)?;
let host = &rest[..close];
let after = &rest[close + 1..];
let port = after.strip_prefix(':').ok_or_else(bad)?;
(host.to_string(), port)
} else {
let colon = spec.rfind(':').ok_or_else(bad)?;
let host = &spec[..colon];
let port = &spec[colon + 1..];
if host.contains(':') {
return Err(bad());
}
(host.to_string(), port)
};
if host.is_empty() {
return Err(bad());
}
let port = if port_str == "*" {
None
} else {
Some(port_str.parse::<u16>().map_err(|_| bad())?)
};
Ok(HostPort { host, port })
}
fn parse_rekey_limit(line: &ParsedLine) -> Result<RekeyLimit, ConfigError> {
if line.args.is_empty() || line.args.len() > 2 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: "expected `<bytes>[ <time>]`".into(),
});
}
let bytes_tok = line.args[0].to_ascii_lowercase();
let max_bytes = match bytes_tok.as_str() {
"default" | "none" => None,
other => Some(parse_size_bytes(other, line)?),
};
let max_seconds = match line.args.get(1) {
None => None,
Some(t) => {
if t.eq_ignore_ascii_case("none") {
None
} else {
Some(parse_duration_str(t, line)?)
}
}
};
Ok(RekeyLimit {
max_bytes,
max_seconds,
})
}
fn parse_size_bytes(s: &str, line: &ParsedLine) -> Result<u64, ConfigError> {
let bad = || ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("bad size value {s:?}"),
};
let bytes = s.as_bytes();
if bytes.is_empty() {
return Err(bad());
}
let last = bytes[bytes.len() - 1];
let (digits, mult): (&str, u64) = match last.to_ascii_lowercase() {
b'k' => (&s[..s.len() - 1], 1024),
b'm' => (&s[..s.len() - 1], 1024 * 1024),
b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
_ if last.is_ascii_digit() => (s, 1),
_ => return Err(bad()),
};
let n: u64 = digits.parse().map_err(|_| bad())?;
n.checked_mul(mult).ok_or_else(bad)
}
fn validate_user_at_host(token: &str, keyword: &str, line_no: usize) -> Result<(), ConfigError> {
if let Some((user, host)) = token.split_once('@')
&& (user.is_empty() || host.is_empty() || host.contains('@'))
{
return Err(ConfigError::BadValue {
line: line_no,
keyword: keyword.to_string(),
msg: format!("malformed user@host pattern {token:?}"),
});
}
Ok(())
}
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_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_permit_root_login(line: &ParsedLine) -> Result<PermitRootLogin, ConfigError> {
let s = one_arg(line)?.to_ascii_lowercase();
match s.as_str() {
"yes" | "true" | "on" => Ok(PermitRootLogin::Yes),
"no" | "false" | "off" => Ok(PermitRootLogin::No),
"prohibit-password" | "without-password" => Ok(PermitRootLogin::ProhibitPassword),
"forced-commands-only" => Err(ConfigError::Unsupported {
line: line.line_no,
msg: "PermitRootLogin forced-commands-only is not supported (puressh authorized_keys \
has no command= restriction); use yes, no, or prohibit-password"
.into(),
}),
_ => Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("expected yes/no/prohibit-password, 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_duration_seconds(line: &ParsedLine) -> Result<u32, ConfigError> {
let s = one_arg(line)?;
parse_duration_str(&s, line)
}
fn parse_duration_str(s: &str, line: &ParsedLine) -> Result<u32, ConfigError> {
let bytes = s.as_bytes();
let mut total: u64 = 0;
let mut acc: u64 = 0;
let mut has_digit = false;
for &b in bytes {
if b.is_ascii_digit() {
has_digit = true;
acc = acc * 10 + (b - b'0') as u64;
} else {
let mult: u64 = match b.to_ascii_lowercase() {
b's' => 1,
b'm' => 60,
b'h' => 3600,
b'd' => 86400,
b'w' => 604800,
_ => {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("bad duration unit in {s:?}"),
});
}
};
if !has_digit {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("missing number before unit in {s:?}"),
});
}
total = total.saturating_add(acc.saturating_mul(mult));
acc = 0;
has_digit = false;
}
}
if has_digit {
total = total.saturating_add(acc);
}
if total > u32::MAX as u64 {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: line.keyword.clone(),
msg: format!("duration overflows u32 seconds: {s:?}"),
});
}
Ok(total as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal() {
let src = "\
Port 2222
ListenAddress 127.0.0.1
HostKey /etc/ssh/ssh_host_ed25519_key
AuthorizedKeysFile /etc/authkeys
AllowUsers alice bob
StrictModes yes
";
let cfg = SshServerConfig::parse(src).unwrap().global;
assert_eq!(cfg.port, Some(2222));
assert_eq!(cfg.listen_addresses, vec!["127.0.0.1".to_string()]);
assert_eq!(
cfg.host_key_files,
vec!["/etc/ssh/ssh_host_ed25519_key".to_string()]
);
assert_eq!(cfg.authorized_keys_file.as_deref(), Some("/etc/authkeys"));
assert_eq!(
cfg.allow_users,
vec!["alice".to_string(), "bob".to_string()]
);
assert_eq!(cfg.strict_modes, Some(true));
}
#[test]
fn cumulative_fields() {
let src = "\
HostKey /a
HostKey /b
ListenAddress 127.0.0.1
ListenAddress ::1
AllowUsers alice
AllowUsers bob carol
";
let cfg = SshServerConfig::parse(src).unwrap().global;
assert_eq!(cfg.host_key_files, vec!["/a".to_string(), "/b".to_string()]);
assert_eq!(
cfg.listen_addresses,
vec!["127.0.0.1".to_string(), "::1".to_string()]
);
assert_eq!(
cfg.allow_users,
vec!["alice".to_string(), "bob".to_string(), "carol".to_string()]
);
}
#[test]
fn match_block_parses_and_resolves() {
let src = "Port 22\nMatch User alice\n AllowAgentForwarding no\n";
let cfg = SshServerConfig::parse(src).unwrap();
assert_eq!(cfg.global.port, Some(22));
assert_eq!(cfg.match_blocks.len(), 1);
let base = cfg.resolve(&MatchContext::default(), ExecPolicy::Deny);
assert_eq!(base.allow_agent_forwarding, None);
let ctx = MatchContext {
host: "h",
user: Some("alice"),
..MatchContext::default()
};
let eff = cfg.resolve(&ctx, ExecPolicy::Deny);
assert_eq!(eff.allow_agent_forwarding, Some(false));
assert_eq!(eff.port, Some(22)); }
#[test]
fn match_first_match_wins_and_cumulative() {
let src = "\
Match User alice
MaxAuthTries 1
AcceptEnv FOO
Match Group dev
MaxAuthTries 2
AcceptEnv BAR
";
let cfg = SshServerConfig::parse(src).unwrap();
let groups = vec!["dev".to_string()];
let ctx = MatchContext {
host: "h",
user: Some("alice"),
groups: Some(&groups),
..MatchContext::default()
};
let eff = cfg.resolve(&ctx, ExecPolicy::Deny);
assert_eq!(eff.max_auth_tries, Some(1));
assert_eq!(eff.accept_env, vec!["FOO".to_string(), "BAR".to_string()]);
}
#[test]
fn match_address_localport_gate() {
let src = "\
Match Address 192.0.2.0/24
X11Forwarding no
Match LocalPort 2222
AllowAgentForwarding no
";
let cfg = SshServerConfig::parse(src).unwrap();
let ctx = MatchContext {
host: "h",
address: Some("192.0.2.50"),
local_port: Some(2222),
..MatchContext::default()
};
let eff = cfg.resolve(&ctx, ExecPolicy::Deny);
assert_eq!(eff.x11_forwarding, Some(false));
assert_eq!(eff.allow_agent_forwarding, Some(false));
let ctx2 = MatchContext {
host: "h",
address: Some("198.51.100.1"),
local_port: Some(22),
..MatchContext::default()
};
let eff2 = cfg.resolve(&ctx2, ExecPolicy::Deny);
assert_eq!(eff2.x11_forwarding, None);
assert_eq!(eff2.allow_agent_forwarding, None);
}
#[test]
fn match_password_auth_yes_accepted() {
let src = "Match User alice\n PasswordAuthentication yes\n";
let cfg = SshServerConfig::parse(src).expect("PasswordAuthentication yes parses");
let ctx = MatchContext {
user: Some("alice"),
..MatchContext::default()
};
let eff = cfg.resolve(&ctx, ExecPolicy::Deny);
assert_eq!(eff.password_authentication, Some(true));
}
#[test]
fn match_host_on_server_unsupported() {
let src = "Match Host example.com\n X11Forwarding no\n";
let err = SshServerConfig::parse(src).unwrap_err();
assert!(
matches!(err, ConfigError::Unsupported { line: 1, .. }),
"{err:?}"
);
}
#[test]
fn invalid_in_match_rejected() {
let src = "Match User alice\n Port 2222\n";
let err = SshServerConfig::parse(src).unwrap_err();
assert!(
matches!(err, ConfigError::Unsupported { line: 2, .. }),
"{err:?}"
);
}
#[test]
fn auth_access_keywords_parse() {
let src = "\
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
MaxAuthTries 3
DenyUsers baduser eve*
AllowGroups wheel admins
DenyGroups nologin
Banner /etc/ssh/banner
";
let cfg = SshServerConfig::parse(src).unwrap().global;
assert_eq!(cfg.pubkey_authentication, Some(true));
assert_eq!(cfg.password_authentication, Some(false));
assert_eq!(cfg.kbd_interactive_authentication, Some(false));
assert_eq!(
cfg.authentication_methods.as_deref(),
Some(&["publickey".to_string()][..])
);
assert_eq!(cfg.max_auth_tries, Some(3));
assert_eq!(
cfg.deny_users,
vec!["baduser".to_string(), "eve*".to_string()]
);
assert_eq!(
cfg.allow_groups,
vec!["wheel".to_string(), "admins".to_string()]
);
assert_eq!(cfg.deny_groups, vec!["nologin".to_string()]);
assert_eq!(cfg.banner.as_deref(), Some("/etc/ssh/banner"));
}
#[test]
fn authentication_methods_multifactor_accepted() {
let cfg = SshServerConfig::parse("AuthenticationMethods publickey,password\n")
.unwrap()
.global;
assert_eq!(
cfg.authentication_methods.as_deref(),
Some(&["publickey,password".to_string()][..])
);
let cfg2 = SshServerConfig::parse("AuthenticationMethods password\n")
.unwrap()
.global;
assert_eq!(
cfg2.authentication_methods.as_deref(),
Some(&["password".to_string()][..])
);
assert!(SshServerConfig::parse("AuthenticationMethods any\n").is_ok());
}
#[test]
fn authentication_methods_multiple_alternatives_parse() {
let cfg = SshServerConfig::parse(
"AuthenticationMethods publickey,password keyboard-interactive\n",
)
.unwrap()
.global;
assert_eq!(
cfg.authentication_methods.as_deref(),
Some(
&[
"publickey,password".to_string(),
"keyboard-interactive".to_string()
][..]
)
);
}
#[test]
fn authentication_methods_unknown_factor_rejected() {
let err = SshServerConfig::parse("AuthenticationMethods publickey,bogus\n").unwrap_err();
assert!(
matches!(err, ConfigError::BadValue { line: 1, .. }),
"{err:?}"
);
}
#[test]
fn permit_empty_passwords_yes_accepted() {
let cfg = SshServerConfig::parse("PermitEmptyPasswords yes\n")
.unwrap()
.global;
assert_eq!(cfg.permit_empty_passwords, Some(true));
let cfg2 = SshServerConfig::parse("PermitEmptyPasswords no\n")
.unwrap()
.global;
assert_eq!(cfg2.permit_empty_passwords, Some(false));
}
#[test]
fn allow_users_at_host_accepted() {
let cfg = SshServerConfig::parse("AllowUsers alice@1.2.3.4 bob\n").unwrap();
assert_eq!(
cfg.global.allow_users,
vec!["alice@1.2.3.4".to_string(), "bob".to_string()]
);
assert!(matches!(
SshServerConfig::parse("AllowUsers @1.2.3.4\n").unwrap_err(),
ConfigError::BadValue { line: 1, .. }
));
assert!(matches!(
SshServerConfig::parse("AllowUsers alice@\n").unwrap_err(),
ConfigError::BadValue { line: 1, .. }
));
let cfg = SshServerConfig::parse("DenyUsers eve@*.evil.example\n").unwrap();
assert_eq!(
cfg.global.deny_users,
vec!["eve@*.evil.example".to_string()]
);
}
#[test]
fn unknown_keyword_errors() {
let src = "Port 22\nTunnel yes\n";
let err = SshServerConfig::parse(src).unwrap_err();
match err {
ConfigError::UnknownKeyword { keyword, line } => {
assert_eq!(keyword, "tunnel");
assert_eq!(line, 2);
}
_ => panic!("wrong error: {err:?}"),
}
}
#[test]
fn algorithm_keywords_parse() {
let src = "\
Port 22
Ciphers aes256-ctr,aes128-ctr
MACs hmac-sha2-512
KexAlgorithms curve25519-sha256
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512
";
let cfg = SshServerConfig::parse(src).unwrap().global;
assert_eq!(
cfg.ciphers.as_deref(),
Some(&["aes256-ctr".to_string(), "aes128-ctr".to_string()][..])
);
assert_eq!(
cfg.macs.as_deref(),
Some(&["hmac-sha2-512".to_string()][..])
);
assert_eq!(
cfg.kex_algorithms.as_deref(),
Some(&["curve25519-sha256".to_string()][..])
);
assert_eq!(
cfg.host_key_algorithms.as_deref(),
Some(&["ssh-ed25519".to_string(), "rsa-sha2-512".to_string()][..])
);
}
#[test]
fn unknown_mac_rejected_with_line() {
let src = "Port 22\nMACs hmac-bogus\n";
let err = SshServerConfig::parse(src).unwrap_err();
match err {
ConfigError::BadValue { line, keyword, msg } => {
assert_eq!(line, 2);
assert_eq!(keyword, "MACs");
assert!(msg.contains("hmac-bogus"));
}
other => panic!("expected BadValue, got {other:?}"),
}
}
#[test]
fn server_revoked_keys_accepted() {
let src = "Port 22\nRevokedKeys /etc/ssh/revoked\n";
let cfg = SshServerConfig::parse(src).unwrap().global;
assert_eq!(cfg.revoked_keys.as_deref(), Some("/etc/ssh/revoked"));
}
#[test]
fn server_casignaturealgorithms_accepted() {
let src = "Port 22\nCASignatureAlgorithms ssh-ed25519,rsa-sha2-512\n";
let cfg = SshServerConfig::parse(src).unwrap().global;
assert_eq!(
cfg.ca_signature_algorithms.as_deref(),
Some(&["ssh-ed25519".to_string(), "rsa-sha2-512".to_string()][..])
);
}
#[test]
fn server_casignaturealgorithms_rejects_plain_ssh_rsa() {
let src = "Port 22\nCASignatureAlgorithms ssh-rsa\n";
let err = SshServerConfig::parse(src).unwrap_err();
assert!(matches!(err, ConfigError::BadValue { line: 2, .. }));
}
#[test]
fn kex_remove_modifier_keeps_nonempty() {
let src = "Port 22\nKexAlgorithms -diffie-hellman-group*\n";
let cfg = SshServerConfig::parse(src).unwrap().global;
let kex = cfg.kex_algorithms.unwrap();
assert!(kex.iter().all(|k| !k.starts_with("diffie-hellman-group")));
assert!(kex.iter().any(|k| k == "curve25519-sha256"));
}
#[test]
fn login_grace_time_units() {
for (s, want) in [
("30", 30u32),
("30s", 30),
("2m", 120),
("1h", 3600),
("1m30s", 90),
] {
let src = format!("LoginGraceTime {s}\n");
let cfg = SshServerConfig::parse(&src).unwrap().global;
assert_eq!(cfg.login_grace_time, Some(want), "case {s:?}");
}
}
#[test]
fn permit_root_login_values() {
for (s, want) in [
("yes", PermitRootLogin::Yes),
("no", PermitRootLogin::No),
("prohibit-password", PermitRootLogin::ProhibitPassword),
("without-password", PermitRootLogin::ProhibitPassword),
] {
let src = format!("PermitRootLogin {s}\n");
let cfg = SshServerConfig::parse(&src).unwrap().global;
assert_eq!(cfg.permit_root_login, Some(want), "case {s:?}");
}
assert!(PermitRootLogin::Yes.permits_publickey());
assert!(PermitRootLogin::ProhibitPassword.permits_publickey());
assert!(!PermitRootLogin::No.permits_publickey());
}
#[test]
fn permit_root_login_forced_commands_unsupported() {
let err = SshServerConfig::parse("PermitRootLogin forced-commands-only\n").unwrap_err();
assert!(
matches!(err, ConfigError::Unsupported { line: 1, .. }),
"got {err:?}"
);
}
#[test]
fn permit_root_login_bad_value() {
let err = SshServerConfig::parse("PermitRootLogin maybe\n").unwrap_err();
assert!(
matches!(err, ConfigError::BadValue { line: 1, .. }),
"got {err:?}"
);
}
#[test]
fn sftp_knobs() {
let src = "\
SftpEnabled yes
SftpReadOnly no
SftpRoot /var/sftp
ScpEnabled yes
";
let cfg = SshServerConfig::parse(src).unwrap().global;
assert_eq!(cfg.sftp_enabled, Some(true));
assert_eq!(cfg.sftp_read_only, Some(false));
assert_eq!(cfg.sftp_root.as_deref(), Some("/var/sftp"));
assert_eq!(cfg.scp_enabled, Some(true));
}
#[test]
fn session_forwarding_keywords_parse() {
let src = "\
MaxSessions 4
AllowTcpForwarding local
PermitOpen 127.0.0.1:80 example.com:443
PermitListen 127.0.0.1:8080
GatewayPorts clientspecified
ForceCommand /usr/bin/uptime --pretty
ChrootDirectory /var/jail
ClientAliveInterval 30
ClientAliveCountMax 2
PrintMotd yes
Compression delayed
RekeyLimit 512M 1h
AddressFamily inet
PidFile /run/sshd.pid
Subsystem sftp internal-sftp
";
let cfg = SshServerConfig::parse(src).unwrap().global;
assert_eq!(cfg.max_sessions, Some(4));
assert_eq!(cfg.allow_tcp_forwarding, Some(TcpForwarding::Local));
let open = cfg.permit_open.unwrap();
assert_eq!(open.len(), 2);
assert!(open[0].matches("127.0.0.1", 80));
assert!(open[1].matches("example.com", 443));
assert!(!open[0].matches("127.0.0.1", 81));
assert_eq!(cfg.permit_listen.unwrap()[0].port, Some(8080));
assert_eq!(cfg.gateway_ports, Some(GatewayPorts::ClientSpecified));
assert_eq!(
cfg.force_command.as_deref(),
Some("/usr/bin/uptime --pretty")
);
assert_eq!(cfg.chroot_directory.as_deref(), Some("/var/jail"));
assert_eq!(cfg.client_alive_interval, Some(30));
assert_eq!(cfg.client_alive_count_max, Some(2));
assert_eq!(cfg.print_motd, Some(true));
assert_eq!(cfg.compression, Some(Compression::Delayed));
assert_eq!(
cfg.rekey_limit,
Some(RekeyLimit {
max_bytes: Some(512 * 1024 * 1024),
max_seconds: Some(3600),
})
);
assert_eq!(cfg.address_family, Some(AddressFamily::Inet));
assert!(cfg.pid_file_set);
assert_eq!(cfg.pid_file.as_deref(), Some("/run/sshd.pid"));
assert_eq!(cfg.subsystem_sftp, Some(true));
}
#[test]
fn permit_open_any_and_none() {
let any = SshServerConfig::parse("PermitOpen any\n")
.unwrap()
.global
.permit_open
.unwrap();
assert!(any[0].matches("anything", 9999));
let none = SshServerConfig::parse("PermitOpen none\n")
.unwrap()
.global
.permit_open
.unwrap();
assert!(none.is_empty());
}
#[test]
fn permit_open_malformed_bad_value() {
let err = SshServerConfig::parse("PermitOpen justhost\n").unwrap_err();
assert!(
matches!(err, ConfigError::BadValue { line: 1, .. }),
"{err:?}"
);
let err2 = SshServerConfig::parse("PermitOpen host:abc\n").unwrap_err();
assert!(
matches!(err2, ConfigError::BadValue { line: 1, .. }),
"{err2:?}"
);
let err3 = SshServerConfig::parse("PermitListen :\n").unwrap_err();
assert!(
matches!(err3, ConfigError::BadValue { line: 1, .. }),
"{err3:?}"
);
}
#[test]
fn permit_open_ipv6_bracketed() {
let cfg = SshServerConfig::parse("PermitOpen [::1]:22\n")
.unwrap()
.global
.permit_open
.unwrap();
assert_eq!(cfg[0].host, "::1");
assert_eq!(cfg[0].port, Some(22));
}
#[test]
fn force_command_empty_bad_value() {
let err = SshServerConfig::parse("ForceCommand\n").unwrap_err();
assert!(
matches!(err, ConfigError::BadValue { line: 1, .. }),
"{err:?}"
);
}
#[test]
fn permit_tunnel_unsupported() {
let err = SshServerConfig::parse("PermitTunnel yes\n").unwrap_err();
assert!(
matches!(err, ConfigError::Unsupported { line: 1, .. }),
"{err:?}"
);
}
#[test]
fn external_subsystem_unsupported() {
let err =
SshServerConfig::parse("Subsystem sftp /usr/lib/openssh/sftp-server\n").unwrap_err();
assert!(
matches!(err, ConfigError::Unsupported { line: 1, .. }),
"{err:?}"
);
}
#[test]
fn subsystem_missing_command_bad_value() {
let err = SshServerConfig::parse("Subsystem sftp\n").unwrap_err();
assert!(
matches!(err, ConfigError::BadValue { line: 1, .. }),
"{err:?}"
);
}
#[test]
fn chroot_none_clears() {
let cfg = SshServerConfig::parse("ChrootDirectory none\n")
.unwrap()
.global;
assert_eq!(cfg.chroot_directory, None);
}
#[test]
fn rekey_limit_default_and_none() {
let d = SshServerConfig::parse("RekeyLimit default\n")
.unwrap()
.global
.rekey_limit
.unwrap();
assert_eq!(d.max_bytes, None);
assert_eq!(d.max_seconds, None);
let n = SshServerConfig::parse("RekeyLimit 1G none\n")
.unwrap()
.global
.rekey_limit
.unwrap();
assert_eq!(n.max_bytes, Some(1024 * 1024 * 1024));
assert_eq!(n.max_seconds, None);
}
#[test]
fn rekey_limit_bad_value() {
let err = SshServerConfig::parse("RekeyLimit 5X\n").unwrap_err();
assert!(
matches!(err, ConfigError::BadValue { line: 1, .. }),
"{err:?}"
);
}
#[test]
fn startup_keywords_rejected_in_match() {
for kw in [
"AddressFamily inet",
"PidFile /x",
"Compression no",
"RekeyLimit 1G",
"Subsystem sftp internal-sftp",
"PermitTunnel yes",
] {
let src = format!("Match User alice\n {kw}\n");
let err = SshServerConfig::parse(&src).unwrap_err();
assert!(
matches!(err, ConfigError::Unsupported { line: 2, .. }),
"expected reject for {kw:?}, got {err:?}"
);
}
}
#[test]
fn session_keywords_match_overridable() {
let src = "\
MaxSessions 10
Match User alice
MaxSessions 1
AllowTcpForwarding no
ForceCommand internal-sftp
";
let cfg = SshServerConfig::parse(src).unwrap();
let base = cfg.resolve(&MatchContext::default(), ExecPolicy::Deny);
assert_eq!(base.max_sessions, Some(10));
assert_eq!(base.allow_tcp_forwarding, None);
let ctx = MatchContext {
host: "h",
user: Some("alice"),
..MatchContext::default()
};
let eff = cfg.resolve(&ctx, ExecPolicy::Deny);
assert_eq!(eff.max_sessions, Some(10));
assert_eq!(eff.allow_tcp_forwarding, Some(TcpForwarding::No));
assert_eq!(eff.force_command.as_deref(), Some("internal-sftp"));
}
#[cfg(feature = "std")]
#[test]
fn include_unsupported_in_string_parse() {
let err = SshServerConfig::parse("Include /etc/ssh/sshd_config.d/*.conf\n").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")]
#[test]
fn load_resolves_include() {
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir =
std::env::temp_dir().join(format!("puressh-sshd-inc-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(dir.join("sshd_config.d")).expect("mkdir");
let write = |rel: &str, body: &str| {
let p: PathBuf = dir.join(rel);
let mut f = std::fs::File::create(&p).expect("create");
f.write_all(body.as_bytes()).expect("write");
p
};
write("sshd_config.d/10-port.conf", "Port 2022\n");
let root = write(
"sshd_config",
"Include sshd_config.d/*.conf\nMaxAuthTries 3\n",
);
let cfg = SshServerConfig::load(&root).expect("load resolves Include");
let base = cfg.resolve(&MatchContext::default(), ExecPolicy::Deny);
assert_eq!(base.port, Some(2022));
assert_eq!(base.max_auth_tries, Some(3));
let _ = std::fs::remove_dir_all(&dir);
}
}