diff --git a/src/app/config_dump.rs b/src/app/config_dump.rs new file mode 100644 index 00000000..217b595e --- /dev/null +++ b/src/app/config_dump.rs @@ -0,0 +1,59 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +//! Side-effect-free resolved SSH configuration output. + +use std::io::Write as _; + +use anyhow::{Context, Result}; +use bssh::cli::SshDumpInvocation; +use bssh::ssh::ssh_config::{SshConfig, render_resolved_config}; + +/// Resolve and print ssh_config without initializing any connection services. +pub async fn handle_config_dump(invocation: &SshDumpInvocation) -> Result<()> { + let mut config = match invocation.config_file.as_deref() { + Some(path) if path.as_os_str() == "none" => SshConfig::new(), + Some(path) => SshConfig::load_explicit_for_config_dump_with_options( + path, + &invocation.destination, + &invocation.overrides, + ) + .await + .with_context(|| format!("Failed to load SSH config from {path:?}"))?, + None => { + SshConfig::load_default_for_config_dump_with_options( + &invocation.destination, + &invocation.overrides, + ) + .await? + } + }; + if invocation + .config_file + .as_deref() + .is_some_and(|path| path.as_os_str() == "none") + { + config + .apply_cli_options(&invocation.overrides) + .context("Failed to apply command-line SSH options")?; + } + if let Some(keyword) = config + .hosts + .iter() + .flat_map(|host| host.unknown_options.keys()) + .next() + { + anyhow::bail!("Unknown SSH config option '{keyword}'"); + } + let mut resolved = config.find_host_config(&invocation.destination); + if invocation.stdio_forward { + resolved.clear_all_forwardings.get_or_insert(true); + resolved.exit_on_forward_failure.get_or_insert(true); + } + let rendered = render_resolved_config(&invocation.destination, &resolved)?; + std::io::stdout() + .write_all(rendered.as_bytes()) + .context("Failed to write resolved SSH configuration") +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 32fd8ea2..c71bfd33 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,6 +18,7 @@ //! initialization, and utility functions for the bssh CLI. pub mod cache; +pub mod config_dump; pub mod dispatcher; pub mod initialization; pub mod nodes; diff --git a/src/app/query.rs b/src/app/query.rs index 7eefba3c..e677d857 100644 --- a/src/app/query.rs +++ b/src/app/query.rs @@ -16,6 +16,22 @@ use bssh::diagnosticln as eprintln; +pub fn is_supported_query(query: &str) -> bool { + matches!( + query, + "cipher" + | "cipher-auth" + | "mac" + | "kex" + | "key" + | "key-plain" + | "key-cert" + | "key-sig" + | "protocol-version" + | "help" + ) +} + /// Handle SSH query options (-Q) pub fn handle_query(query: &str) { match query { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a4981807..0445f69c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -35,12 +35,14 @@ mod bssh; pub mod pdsh; +mod ssh_args; #[cfg(test)] mod mode_detection_tests; // Re-export main CLI types from bssh module pub use bssh::{Cli, Commands}; +pub use ssh_args::SshDumpInvocation; // Re-export pdsh compatibility utilities pub use pdsh::{ diff --git a/src/cli/ssh_args.rs b/src/cli/ssh_args.rs new file mode 100644 index 00000000..fe0565b4 --- /dev/null +++ b/src/cli/ssh_args.rs @@ -0,0 +1,895 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +//! Order-preserving extraction of ssh_config command-line options. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +/// Inputs needed by `ssh -G`, in the order OpenSSH obtains them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SshDumpInvocation { + pub destination: String, + pub config_file: Option, + pub log_file: Option, + pub overrides: Vec, + /// `-W` requests OpenSSH's implicit forwarding policy after config merge. + pub stdio_forward: bool, + /// Terminal SSH options take priority over `-G` after raw argv parsing. + pub version: bool, + pub query: Option, +} + +impl SshDumpInvocation { + /// Preserve OpenSSH's two-pass argv behavior: after capturing the first + /// destination, another option group may follow. The next non-option is + /// the remote command and ends option processing. + pub fn requests_config_dump(args: &[String]) -> bool { + scan_for_dump_flag(args) + } + + /// Extract `-E` before fallible validation so every `-G` diagnostic uses + /// the requested sink, including errors later in the second option pass. + pub fn diagnostic_file(args: &[String]) -> Option { + scan_diagnostic_file(args) + } + + pub fn from_argv(args: &[String]) -> Result { + let dump_requested = scan_for_dump_flag(args); + let mut overrides = Vec::new(); + let mut priority_overrides = Vec::new(); + let mut config_file = None; + let mut log_file = None; + let mut index = 1usize; + let mut destination = None; + let mut options_terminated = false; + let mut stdio_forward = false; + let mut saw_dump = false; + let mut version = false; + let mut query = None; + + 'arguments: while index < args.len() { + let argument = &args[index]; + if argument == "--" { + if destination.is_some() { + break; + } + options_terminated = true; + index += 1; + continue; + } + if destination.is_none() + && (options_terminated || !argument.starts_with('-') || argument == "-") + { + add_destination_overrides(argument, &mut overrides)?; + destination = Some(argument.clone()); + index += 1; + if options_terminated { + break; + } + continue; + } + if destination.is_some() && !argument.starts_with('-') { + break; + } + + if let Some(long) = argument.strip_prefix("--") { + let (name, attached) = long + .split_once('=') + .map_or((long, None), |(name, value)| (name, Some(value))); + if let Some(value_name) = long_value_name(name) { + let (value, consumed) = value_for(args, index, attached, value_name)?; + apply_value( + value_name, + value, + &mut config_file, + &mut log_file, + &mut overrides, + &mut priority_overrides, + &mut query, + )?; + stdio_forward |= value_name == "stdio-forward"; + index += consumed; + } else { + match name { + "print-config" => saw_dump = true, + "ipv4" => set_priority( + &mut priority_overrides, + "addressfamily", + "AddressFamily=inet", + ), + "ipv6" => set_priority( + &mut priority_overrides, + "addressfamily", + "AddressFamily=inet6", + ), + "tty" => { + set_priority(&mut priority_overrides, "requesttty", "RequestTTY=yes") + } + "no-tty" => { + set_priority(&mut priority_overrides, "requesttty", "RequestTTY=no") + } + "no-x11" => { + set_priority(&mut priority_overrides, "forwardx11", "ForwardX11=no") + } + _ => anyhow::bail!("Unknown option '--{name}'"), + } + } + index += 1; + continue; + } + + if let Some(shorts) = argument.strip_prefix('-') { + for (position, short) in shorts.char_indices() { + let value_name = match short { + 'o' => Some("option"), + 'l' => Some("login"), + 'p' => Some("port"), + 'i' => Some("identity"), + 'J' => Some("jump-host"), + 'c' => Some("cipher"), + 'm' => Some("macs"), + 'F' => Some("ssh-config"), + 'L' => Some("local-forward"), + 'R' => Some("remote-forward"), + 'D' => Some("dynamic-forward"), + 'W' => Some("stdio-forward"), + 'E' => Some("diagnostic-file"), + 'Q' => Some("query"), + 'B' => Some("bind-interface"), + 'b' => Some("bind-address"), + 'e' => Some("escape-char"), + 'I' => Some("pkcs11-provider"), + 'O' => Some("control-command"), + 'P' => Some("tag"), + 'S' => Some("control-path"), + 'w' => Some("tunnel-device"), + _ => None, + }; + if let Some(name) = value_name { + let value_start = position + short.len_utf8(); + let attached = shorts + .get(value_start..) + .filter(|remaining| !remaining.is_empty()); + let (value, consumed) = value_for(args, index, attached, name)?; + apply_value( + name, + value, + &mut config_file, + &mut log_file, + &mut overrides, + &mut priority_overrides, + &mut query, + )?; + stdio_forward |= name == "stdio-forward"; + if name == "query" { + break 'arguments; + } + index += consumed; + break; + } + match short { + 'G' => saw_dump = true, + '4' => set_priority( + &mut priority_overrides, + "addressfamily", + "AddressFamily=inet", + ), + '6' => set_priority( + &mut priority_overrides, + "addressfamily", + "AddressFamily=inet6", + ), + 'A' => set_priority( + &mut priority_overrides, + "forwardagent", + "ForwardAgent=yes", + ), + 'a' => { + set_priority(&mut priority_overrides, "forwardagent", "ForwardAgent=no") + } + 'X' => { + set_priority(&mut priority_overrides, "forwardx11", "ForwardX11=yes") + } + 'x' => set_priority(&mut priority_overrides, "forwardx11", "ForwardX11=no"), + 't' => { + set_priority(&mut priority_overrides, "requesttty", "RequestTTY=yes") + } + 'T' => set_priority(&mut priority_overrides, "requesttty", "RequestTTY=no"), + 'C' => { + set_priority(&mut priority_overrides, "compression", "Compression=yes") + } + 'N' => { + set_priority(&mut priority_overrides, "sessiontype", "SessionType=none") + } + 'n' => set_priority(&mut priority_overrides, "stdinnull", "StdinNull=yes"), + 'f' => set_priority( + &mut priority_overrides, + "forkafterauthentication", + "ForkAfterAuthentication=yes", + ), + 'g' => set_priority( + &mut priority_overrides, + "gatewayports", + "GatewayPorts=yes", + ), + 'M' => set_priority( + &mut priority_overrides, + "controlmaster", + "ControlMaster=yes", + ), + 's' => set_priority( + &mut priority_overrides, + "sessiontype", + "SessionType=subsystem", + ), + 'Y' => { + set_priority(&mut priority_overrides, "forwardx11", "ForwardX11=yes"); + set_priority( + &mut priority_overrides, + "forwardx11trusted", + "ForwardX11Trusted=yes", + ); + } + 'V' => { + version = true; + break 'arguments; + } + 'q' | 'v' | 'y' => {} + _ => anyhow::bail!("Unknown option '-{short}'"), + } + } + } + index += 1; + } + + if !saw_dump && !dump_requested { + anyhow::bail!("Resolved configuration invocation is missing -G"); + } + let terminal = version || query.is_some(); + let destination = if terminal { + destination.unwrap_or_default() + } else { + destination.context("-G requires a destination")? + }; + let destination = destination.strip_prefix("ssh://").unwrap_or(&destination); + let parsed = (!destination.is_empty()) + .then(|| parse_dump_destination(destination)) + .transpose() + .context("Invalid destination for resolved configuration")?; + let mut all_overrides = priority_overrides + .into_iter() + .map(|(_, value)| value) + .collect::>(); + all_overrides.extend(overrides); + + Ok(Self { + destination: parsed + .map_or_else(String::new, |destination| destination.host.to_string()), + config_file, + log_file, + overrides: all_overrides, + stdio_forward, + version, + query, + }) + } +} + +fn add_destination_overrides(destination: &str, overrides: &mut Vec) -> Result<()> { + let destination = destination.strip_prefix("ssh://").unwrap_or(destination); + let parsed = parse_dump_destination(destination) + .context("Invalid destination for resolved configuration")?; + if let Some(user) = parsed.user { + overrides.push(config_option("User", &literal_user(user)?)?); + } + if let Some(port) = parsed.port { + overrides.push(format!("Port={port}")); + } + Ok(()) +} + +fn parse_dump_destination(destination: &str) -> Result> { + let (user, host) = destination + .split_once('@') + .map_or((None, destination), |(user, host)| (Some(user), host)); + if host.parse::().is_ok() { + return Ok(crate::node::NodeSpec { + user, + host, + port: None, + }); + } + crate::node::parse_node_spec(destination) +} + +fn value_for<'a>( + args: &'a [String], + index: usize, + attached: Option<&'a str>, + name: &str, +) -> Result<(&'a str, usize)> { + if let Some(value) = attached { + return Ok((value, 0)); + } + args.get(index + 1) + .map(|value| (value.as_str(), 1)) + .with_context(|| format!("-{name} requires an argument")) +} + +fn apply_value( + name: &str, + value: &str, + config_file: &mut Option, + log_file: &mut Option, + overrides: &mut Vec, + priority_overrides: &mut Vec<(&'static str, String)>, + query: &mut Option, +) -> Result<()> { + let option = match name { + "option" => value.to_string(), + "login" => config_option("User", &literal_user(value)?)?, + "port" => { + value + .parse::() + .with_context(|| format!("Invalid port '{value}'"))?; + format!("Port={value}") + } + "identity" => config_option("IdentityFile", value)?, + "jump-host" => config_option("ProxyJump", value)?, + "cipher" => { + set_priority( + priority_overrides, + "ciphers", + config_option("Ciphers", value)?, + ); + return Ok(()); + } + "macs" => { + set_priority(priority_overrides, "macs", config_option("MACs", value)?); + return Ok(()); + } + "local-forward" => config_option("LocalForward", value)?, + "remote-forward" => config_option("RemoteForward", value)?, + "dynamic-forward" => config_option("DynamicForward", value)?, + "stdio-forward" => { + validate_stdio_forward(value)?; + return Ok(()); + } + "diagnostic-file" => { + *log_file = Some(PathBuf::from(value)); + return Ok(()); + } + "query" => { + *query = Some(value.to_string()); + return Ok(()); + } + "bind-interface" => config_option("BindInterface", value)?, + "bind-address" => config_option("BindAddress", value)?, + "escape-char" => config_option("EscapeChar", value)?, + "control-path" => config_option("ControlPath", value)?, + "tunnel-device" => config_option("TunnelDevice", value)?, + "pkcs11-provider" | "control-command" | "tag" => { + anyhow::bail!("Option '-{name}' is not supported with -G") + } + "ssh-config" => { + *config_file = Some(PathBuf::from(value)); + return Ok(()); + } + _ => return Ok(()), + }; + overrides.push(option); + Ok(()) +} + +fn config_option(keyword: &str, value: &str) -> Result { + Ok(format!( + "{keyword}={}", + crate::ssh::ssh_config::encode_config_value(value)? + )) +} + +fn set_priority( + overrides: &mut Vec<(&'static str, String)>, + keyword: &'static str, + value: impl Into, +) { + if let Some(existing) = overrides.iter_mut().find(|(key, _)| *key == keyword) { + existing.1 = value.into(); + } else { + overrides.push((keyword, value.into())); + } +} + +fn validate_stdio_forward(value: &str) -> Result<()> { + let (host, port) = value.rsplit_once(':').context("-W requires host:port")?; + let valid_host = !host.is_empty() + && if host.starts_with('[') { + host.ends_with(']') && host.len() > 2 + } else { + !host.contains(':') && !host.ends_with(']') + }; + let valid_port = match port.parse::() { + Ok(port) => port > 0, + Err(_) => service_exists(port), + }; + if !valid_host || !valid_port { + anyhow::bail!("Invalid -W target '{value}'; expected host:port"); + } + Ok(()) +} + +#[cfg(unix)] +fn service_exists(name: &str) -> bool { + if name.is_empty() + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return false; + } + std::fs::read_to_string("/etc/services") + .ok() + .is_some_and(|services| { + services.lines().any(|line| { + let fields = line + .split('#') + .next() + .unwrap_or_default() + .split_whitespace() + .collect::>(); + fields.get(1).is_some_and(|port| port.ends_with("/tcp")) + && fields + .iter() + .enumerate() + .any(|(index, field)| index != 1 && *field == name) + }) + }) +} + +#[cfg(not(unix))] +fn service_exists(_name: &str) -> bool { + false +} + +fn scan_for_dump_flag(args: &[String]) -> bool { + let mut index = 1usize; + let mut destination_seen = false; + let mut saw_dump = false; + while index < args.len() { + let argument = &args[index]; + if argument == "--" { + break; + } + if !argument.starts_with('-') || argument == "-" { + if destination_seen { + break; + } + destination_seen = true; + index += 1; + continue; + } + if let Some(long) = argument.strip_prefix("--") { + let (name, attached) = long + .split_once('=') + .map_or((long, false), |(name, _)| (name, true)); + if name == "print-config" { + saw_dump = true; + } + if long_takes_value(name) && !attached { + index += 1; + } + } else if let Some(shorts) = argument.strip_prefix('-') { + for (position, short) in shorts.char_indices() { + if short == 'G' { + saw_dump = true; + } + if short_takes_value(short) { + if position + short.len_utf8() == shorts.len() { + index += 1; + } + break; + } + } + } + index += 1; + } + saw_dump +} + +fn scan_diagnostic_file(args: &[String]) -> Option { + let mut result = None; + let mut index = 1usize; + let mut destination_seen = false; + while index < args.len() { + let argument = &args[index]; + if argument == "--" { + break; + } + if !argument.starts_with('-') || argument == "-" { + if destination_seen { + break; + } + destination_seen = true; + index += 1; + continue; + } + if let Some(long) = argument.strip_prefix("--") { + let (name, attached) = long + .split_once('=') + .map_or((long, None), |(name, value)| (name, Some(value))); + if long_takes_value(name) { + let value = attached.or_else(|| args.get(index + 1).map(String::as_str)); + if name == "diagnostic-file" { + result = value.map(PathBuf::from); + } + if attached.is_none() { + index += 1; + } + } + } else if let Some(shorts) = argument.strip_prefix('-') { + for (position, short) in shorts.char_indices() { + if matches!(short, 'V' | 'Q') { + return result; + } + if !short_takes_value(short) { + continue; + } + let value_start = position + short.len_utf8(); + let attached = shorts + .get(value_start..) + .filter(|remaining| !remaining.is_empty()); + let value = attached.or_else(|| args.get(index + 1).map(String::as_str)); + if short == 'E' { + result = value.map(PathBuf::from); + } + if attached.is_none() { + index += 1; + } + break; + } + } + index += 1; + } + result +} + +fn short_takes_value(short: char) -> bool { + matches!( + short, + 'B' | 'b' + | 'c' + | 'D' + | 'E' + | 'e' + | 'F' + | 'I' + | 'i' + | 'J' + | 'L' + | 'l' + | 'm' + | 'O' + | 'o' + | 'P' + | 'p' + | 'Q' + | 'R' + | 'S' + | 'W' + | 'w' + ) +} + +fn long_takes_value(name: &str) -> bool { + long_value_name(name).is_some() +} + +fn long_value_name(name: &str) -> Option<&str> { + matches!( + name, + "option" + | "login" + | "port" + | "identity" + | "jump-host" + | "cipher" + | "macs" + | "ssh-config" + | "local-forward" + | "remote-forward" + | "dynamic-forward" + | "stdio-forward" + | "diagnostic-file" + | "bind-interface" + | "bind-address" + | "escape-char" + | "pkcs11-provider" + | "control-command" + | "tag" + | "control-path" + | "tunnel-device" + ) + .then_some(name) +} + +fn literal_user(value: &str) -> Result { + let chars = value.chars().collect::>(); + let forbidden = "'`\";&<>|(){}"; + if value.starts_with('-') + || chars + .iter() + .any(|ch| ch.is_control() || forbidden.contains(*ch)) + || chars + .windows(2) + .any(|pair| pair[0].is_whitespace() && pair[1] == '-') + || value.ends_with('\\') + { + anyhow::bail!("Remote username contains invalid characters"); + } + Ok(value.replace('$', "$$").replace('%', "%%")) +} + +#[cfg(test)] +mod tests { + use super::SshDumpInvocation; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn preserves_order_across_second_option_pass() { + let argv = args(&[ + "bssh", + "-G6", + "-oUser=first", + "-lsecond", + "host", + "-o", + "Port=9", + ]); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); + assert_eq!( + parsed.overrides, + ["AddressFamily=inet6", "User=first", "User=second", "Port=9"] + ); + } + + #[test] + fn destination_values_are_last_and_ipv6_is_unwrapped() { + let argv = args(&["bssh", "-Gp2200", "user@[::1]:2300"]); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); + assert_eq!(parsed.destination, "::1"); + assert_eq!(parsed.overrides, ["Port=2200", "User=user", "Port=2300"]); + } + + #[test] + fn stdio_forward_implicit_clear_is_overridden_by_explicit_option() { + let implicit = args(&["bssh", "-GF", "none", "-W", "a:1", "host"]); + let parsed = SshDumpInvocation::from_argv(&implicit).unwrap(); + assert!(parsed.stdio_forward); + assert!(!parsed.overrides.iter().any(|option| { + option + .to_ascii_lowercase() + .starts_with("clearallforwardings=") + })); + assert!(!parsed.overrides.iter().any(|option| { + option + .to_ascii_lowercase() + .starts_with("exitonforwardfailure=") + })); + + let explicit = args(&[ + "bssh", + "-GF", + "none", + "-W", + "a:1", + "-o", + "ClearAllForwardings=no", + "host", + ]); + let parsed = SshDumpInvocation::from_argv(&explicit).unwrap(); + assert!(parsed.stdio_forward); + assert!( + parsed + .overrides + .contains(&"ClearAllForwardings=no".to_string()) + ); + } + + #[test] + fn matches_sshcfgparse_user_first_obtained_cases() { + let cases = [ + ( + vec!["bssh", "-G", "-o", "user=foo", "-l", "bar", "baz@host"], + "foo", + ), + ( + vec!["bssh", "-G", "-lbar", "baz@host", "user=foo", "baz@host"], + "bar", + ), + ( + vec![ + "bssh", "-G", "baz@host", "-o", "user=foo", "-l", "bar", "baz@host", + ], + "baz", + ), + ]; + for (values, expected) in cases { + let argv = args(&values); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); + let first_user = parsed.overrides.iter().find_map(|option| { + option + .to_ascii_lowercase() + .strip_prefix("user=") + .map(str::to_string) + }); + assert_eq!(first_user.as_deref(), Some(expected)); + } + } + + #[test] + fn double_dash_prevents_a_second_option_pass() { + let argv = args(&["bssh", "-GF", "none", "--", "host", "-l", "late"]); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); + assert!(!parsed.overrides.iter().any(|option| option == "User=late")); + } + + #[test] + fn ignored_value_options_consume_attached_and_separate_values_once() { + let attached = args(&["bssh", "-G", "-E/tmp/path/containing/options.log", "host"]); + let separate = args(&[ + "bssh", + "-G", + "-E", + "/tmp/path/containing/options.log", + "host", + ]); + for argv in [attached, separate] { + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); + assert!(parsed.overrides.is_empty()); + } + } + + #[test] + fn validates_remote_user_without_expanding_percent_tokens() { + let literal = args(&["bssh", "-G", "-l%u", "host"]); + let parsed = SshDumpInvocation::from_argv(&literal).unwrap(); + assert_eq!(parsed.overrides, ["User=%%u"]); + + for invalid in ["${FOO}", "bad\u{7}user", "-flag", "bad\\"] { + let argv = args(&["bssh", "-G", "-l", invalid, "host"]); + assert!(SshDumpInvocation::from_argv(&argv).is_err()); + } + } + + #[test] + fn direct_values_are_serialized_before_overlay_tokenization() { + let parsed = SshDumpInvocation::from_argv(&args(&[ + "bssh", + "-GF", + "none", + "-i", + "/tmp/a b#c", + "-S/tmp/control path#socket", + "host", + ])) + .unwrap(); + assert!( + parsed + .overrides + .contains(&r#"IdentityFile="/tmp/a b#c""#.to_string()) + ); + assert!( + parsed + .overrides + .contains(&r#"ControlPath="/tmp/control path#socket""#.to_string()) + ); + } + + #[test] + fn config_dump_accepts_unbracketed_ipv6_destinations() { + let parsed = SshDumpInvocation::from_argv(&args(&["bssh", "-GF", "none", "::1"])).unwrap(); + assert_eq!(parsed.destination, "::1"); + + let parsed = + SshDumpInvocation::from_argv(&args(&["bssh", "-GF", "none", "deploy@::1"])).unwrap(); + assert_eq!(parsed.destination, "::1"); + assert!(parsed.overrides.contains(&"User=deploy".to_string())); + } + + #[test] + fn direct_algorithms_override_o_and_inverse_flags_use_last_value() { + for argv in [ + args(&["bssh", "-G", "-o", "Ciphers=first", "-c", "last", "host"]), + args(&["bssh", "-G", "-c", "last", "-o", "Ciphers=first", "host"]), + ] { + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); + assert_eq!( + parsed.overrides.first().map(String::as_str), + Some("Ciphers=last") + ); + } + + let disabled = SshDumpInvocation::from_argv(&args(&["bssh", "-GtT", "host"])).unwrap(); + assert!(disabled.overrides.contains(&"RequestTTY=no".to_string())); + let enabled = SshDumpInvocation::from_argv(&args(&["bssh", "-GTt", "host"])).unwrap(); + assert!(enabled.overrides.contains(&"RequestTTY=yes".to_string())); + } + + #[test] + fn rejects_unknown_options_and_invalid_stdio_forward_targets() { + for argv in [ + args(&["bssh", "-G", "-Z", "host"]), + args(&["bssh", "-G", "host", "-Z", "value"]), + args(&["bssh", "-G", "-W", "missing-port", "host"]), + args(&["bssh", "-GW[::1]", "host"]), + args(&["bssh", "-GW::1:22", "host"]), + args(&["bssh", "-GWhost:0", "host"]), + args(&["bssh", "-GWhost:definitely-not-a-service", "host"]), + ] { + assert!(SshDumpInvocation::from_argv(&argv).is_err()); + } + for argv in [ + args(&["bssh", "-G", "-W", "localhost:22", "host"]), + args(&["bssh", "-GW[::1]:22", "host"]), + args(&["bssh", "-GWhost:ssh", "host"]), + ] { + assert!(SshDumpInvocation::from_argv(&argv).is_ok()); + } + } + + #[test] + fn detects_dump_without_rescanning_option_values() { + assert!(SshDumpInvocation::requests_config_dump(&args(&[ + "bssh", + "-E/tmp/log", + "-GF", + "none", + "list" + ]))); + assert!(!SshDumpInvocation::requests_config_dump(&args(&[ + "bssh", + "-E/tmp/contains/G", + "host" + ]))); + assert!(!SshDumpInvocation::requests_config_dump(&args(&[ + "bssh", "--", "host", "-G" + ]))); + assert_eq!( + SshDumpInvocation::diagnostic_file(&args(&["bssh", "--", "host", "-E/tmp/remote-log"])), + None + ); + let terminated = + SshDumpInvocation::from_argv(&args(&["bssh", "-G", "--", "-alias"])).unwrap(); + assert_eq!(terminated.destination, "-alias"); + assert!(SshDumpInvocation::from_argv(&args(&["bssh", "-G", "-"])).is_ok()); + } + + #[test] + fn terminal_version_and_query_options_preempt_config_dump() { + for argv in [ + args(&["bssh", "-VG", "host"]), + args(&["bssh", "-GV", "host"]), + args(&["bssh", "-GQ", "cipher", "host"]), + args(&["bssh", "-Qcipher", "-G", "host"]), + ] { + assert!(SshDumpInvocation::requests_config_dump(&argv), "{argv:?}"); + } + let version = SshDumpInvocation::from_argv(&args(&["bssh", "-VG"])).unwrap(); + assert!(version.version); + let query = SshDumpInvocation::from_argv(&args(&["bssh", "-GQ", "cipher"])).unwrap(); + assert_eq!(query.query.as_deref(), Some("cipher")); + assert!(SshDumpInvocation::from_argv(&args(&["bssh", "-VG", "-Z"])).is_ok()); + assert!(SshDumpInvocation::from_argv(&args(&["bssh", "-GQ", "cipher", "-Z"])).is_ok()); + assert!(SshDumpInvocation::from_argv(&args(&["bssh", "-Z", "-VG"])).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 543aab60..0705765f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,9 +27,10 @@ mod app; use app::{ cache::handle_cache_stats, + config_dump::handle_config_dump, dispatcher::dispatch_command, initialization::{AppContext, initialize_app}, - query::handle_query, + query::{handle_query, is_supported_query}, utils::show_usage, }; @@ -61,6 +62,42 @@ async fn run() -> Result<()> { return run_pdsh_mode(&args).await; } + // Raw dispatch is required before Clap so SSH destinations named like + // bssh subcommands remain destinations. It also guarantees SSH-style + // error status and diagnostic routing for all `-G` parse failures. + if bssh::cli::SshDumpInvocation::requests_config_dump(&args) { + if let Some(path) = bssh::cli::SshDumpInvocation::diagnostic_file(&args) + && let Err(error) = bssh::utils::diagnostics::set_log_file(&path) + { + bssh::diagnosticln!("Error: {error:?}"); + std::process::exit(255); + } + let invocation = match bssh::cli::SshDumpInvocation::from_argv(&args) { + Ok(invocation) => invocation, + Err(error) => { + bssh::diagnosticln!("Error: {error:?}"); + std::process::exit(255); + } + }; + if invocation.version { + eprintln!("bssh_{}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + if let Some(query) = invocation.query.as_deref() { + if !is_supported_query(query) { + bssh::diagnosticln!("Unsupported query \"{query}\""); + std::process::exit(255); + } + handle_query(query); + return Ok(()); + } + if let Err(error) = handle_config_dump(&invocation).await { + bssh::diagnosticln!("Error: {error:?}"); + std::process::exit(255); + } + return Ok(()); + } + // Standard bssh mode run_bssh_mode(&args).await } diff --git a/src/ssh/ssh_config/dump.rs b/src/ssh/ssh_config/dump.rs new file mode 100644 index 00000000..f4c918a3 --- /dev/null +++ b/src/ssh/ssh_config/dump.rs @@ -0,0 +1,828 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +//! OpenSSH-shaped resolved configuration rendering. + +use anyhow::{Context, Result}; +use std::fmt::Write as _; + +use super::{IpQosPolicy, IpQosValue, RekeyDataLimit, RekeyLimit, RekeyTimeLimit, SshHostConfig}; + +mod tokens; +use tokens::TokenContext; + +pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Result { + let mut output = DumpWriter::default(); + let mut tokens = TokenContext::new(original_host, config); + tokens.effective_host = + super::resolver::expand_hostname_value(&tokens.effective_host, original_host); + tokens.remote_user = tokens.expand(&tokens.remote_user)?; + let jump = tokens.expand(config.proxy_jump.as_deref().unwrap_or(""))?; + tokens.refresh_hash(&jump); + + output.line("host", original_host)?; + output.line("user", TokenContext::escape_for_dump(&tokens.remote_user))?; + output.line("hostname", &tokens.effective_host)?; + output.line("port", &tokens.port)?; + output.line( + "addressfamily", + config.address_family.as_deref().unwrap_or("any"), + )?; + output.bool("batchmode", config.batch_mode.unwrap_or(false))?; + output.bool("checkhostip", config.check_host_ip.unwrap_or(false))?; + output.bool("compression", config.compression.unwrap_or(false))?; + output.line( + "controlmaster", + config.control_master.as_deref().unwrap_or("false"), + )?; + output.bool( + "clearallforwardings", + config.clear_all_forwardings.unwrap_or(false), + )?; + output.bool( + "exitonforwardfailure", + config.exit_on_forward_failure.unwrap_or(false), + )?; + output.bool( + "enablesshkeysign", + config.enable_ssh_keysign.unwrap_or(false), + )?; + output.bool("forwardx11", config.forward_x11.unwrap_or(false))?; + output.bool( + "forwardx11trusted", + config.forward_x11_trusted.unwrap_or(false), + )?; + let forward_agent = raw_option(config, "forwardagent") + .map(|value| tokens.expand_path_for_dump(&value)) + .transpose()? + .unwrap_or_else(|| yes_no(config.forward_agent.unwrap_or(false)).to_string()); + output.line("forwardagent", forward_agent)?; + output.line( + "gatewayports", + config.gateway_ports.as_deref().unwrap_or("no"), + )?; + output.bool( + "gssapiauthentication", + config.gssapi_authentication.unwrap_or(false), + )?; + output.bool("hashknownhosts", config.hash_known_hosts.unwrap_or(false))?; + output.bool( + "hostbasedauthentication", + config.hostbased_authentication.unwrap_or(false), + )?; + output.bool("identitiesonly", config.identities_only.unwrap_or(false))?; + output.bool( + "kbdinteractiveauthentication", + config.keyboard_interactive_authentication.unwrap_or(true), + )?; + output.bool( + "nohostauthenticationforlocalhost", + config.no_host_authentication_for_localhost.unwrap_or(false), + )?; + output.bool( + "passwordauthentication", + config.password_authentication.unwrap_or(true), + )?; + output.bool( + "permitlocalcommand", + config.permit_local_command.unwrap_or(false), + )?; + output.bool( + "pubkeyauthentication", + config.pubkey_authentication.unwrap_or(true), + )?; + output.line( + "requesttty", + config.request_tty.as_deref().unwrap_or("auto"), + )?; + output.line("protocol", list_or(&config.protocol, "2"))?; + output.line( + "sessiontype", + config.session_type.as_deref().unwrap_or("default"), + )?; + output.bool("stdinnull", config.stdin_null.unwrap_or(false))?; + output.bool( + "forkafterauthentication", + config.fork_after_authentication.unwrap_or(false), + )?; + output.line( + "stricthostkeychecking", + config.strict_host_key_checking.as_deref().unwrap_or("ask"), + )?; + output.bool("tcpkeepalive", config.tcp_keep_alive.unwrap_or(true))?; + output.bool("visualhostkey", config.visual_host_key.unwrap_or(false))?; + output.line( + "verifyhostkeydns", + config.verify_host_key_dns.as_deref().unwrap_or("no"), + )?; + output.line( + "updatehostkeys", + config.update_host_keys.as_deref().unwrap_or("yes"), + )?; + output.line( + "canonicalizefallbacklocal", + raw_option(config, "canonicalizefallbacklocal") + .as_deref() + .unwrap_or("yes"), + )?; + output.line( + "canonicalizehostname", + raw_option(config, "canonicalizehostname") + .as_deref() + .unwrap_or("false"), + )?; + output.line( + "streamlocalbindunlink", + raw_option(config, "streamlocalbindunlink") + .as_deref() + .unwrap_or("no"), + )?; + output.line( + "tunnel", + raw_option(config, "tunnel").as_deref().unwrap_or("false"), + )?; + output.line( + "enableescapecommandline", + raw_option(config, "enableescapecommandline") + .as_deref() + .unwrap_or("no"), + )?; + output.line( + "warnweakcrypto", + raw_option(config, "warnweakcrypto") + .as_deref() + .unwrap_or("yes"), + )?; + + output.line( + "connectionattempts", + config.connection_attempts.unwrap_or(1), + )?; + output.line("connecttimeout", config.connect_timeout.unwrap_or(0))?; + output.line( + "forwardx11timeout", + config.forward_x11_timeout.as_deref().unwrap_or("1200"), + )?; + output.line( + "numberofpasswordprompts", + config.number_of_password_prompts.unwrap_or(3), + )?; + output.line( + "serveralivecountmax", + config.server_alive_count_max.unwrap_or(3), + )?; + output.line( + "serveraliveinterval", + config.server_alive_interval.unwrap_or(0), + )?; + output.line("requiredrsasize", config.required_rsa_size.unwrap_or(1024))?; + output.line( + "canonicalizemaxdots", + raw_option(config, "canonicalizemaxdots") + .as_deref() + .unwrap_or("1"), + )?; + output.line( + "obscurekeystroketiming", + raw_option(config, "obscurekeystroketiming") + .as_deref() + .unwrap_or("yes"), + )?; + + output.optional("bindaddress", config.bind_address.as_deref())?; + output.optional("bindinterface", config.bind_interface.as_deref())?; + output.line("ciphers", cipher_names(config))?; + if let Some(value) = config.control_path.as_deref() { + output.line("controlpath", tokens.expand_path_for_dump(value)?)?; + } + output.line("hostkeyalgorithms", host_key_names(config))?; + output.optional("hostkeyalias", config.host_key_alias.as_deref())?; + output.line("kexalgorithms", kex_names(config))?; + output.line("macs", mac_names(config))?; + if let Some(value) = config.identity_agent.as_deref() { + output.line("identityagent", tokens.expand_path_for_dump(value)?)?; + } + output.line( + "securitykeyprovider", + raw_option(config, "securitykeyprovider") + .as_deref() + .unwrap_or("$SSH_SK_PROVIDER"), + )?; + output.line( + "xauthlocation", + raw_option(config, "xauthlocation") + .as_deref() + .unwrap_or("/usr/bin/xauth"), + )?; + output.optional_command("localcommand", config.local_command.as_deref())?; + output.percent_expanded_command("remotecommand", config.remote_command.as_deref(), &tokens)?; + output.optional_command("knownhostscommand", config.known_hosts_command.as_deref())?; + if let Some(proxy_jump) = config.proxy_jump.as_deref() { + output.line("proxyjump", proxy_jump)?; + } else { + let proxy_command = config + .proxy_command + .clone() + .unwrap_or_else(|| "none".to_string()); + output.command("proxycommand", &proxy_command)?; + } + output.bool("proxyusefdpass", config.proxy_use_fdpass.unwrap_or(false))?; + output.line("loglevel", config.log_level.as_deref().unwrap_or("INFO"))?; + output.line( + "syslogfacility", + config.syslog_facility.as_deref().unwrap_or("USER"), + )?; + output.line("escapechar", config.escape_char.as_deref().unwrap_or("~"))?; + output.line( + "fingerprinthash", + config + .fingerprint_hash + .as_deref() + .filter(|value| !value.eq_ignore_ascii_case("sha256")) + .unwrap_or("SHA256"), + )?; + output.line( + "preferredauthentications", + list_or( + &config.preferred_authentications, + "gssapi-with-mic,hostbased,publickey,keyboard-interactive,password", + ), + )?; + output.line("pubkeyacceptedalgorithms", pubkey_names(config))?; + output.line( + "hostbasedacceptedalgorithms", + resolve_signature_policy( + &config.hostbased_accepted_algorithms, + DEFAULT_HOSTBASED_ACCEPTED_ALGORITHMS, + )?, + )?; + output.line( + "casignaturealgorithms", + resolve_signature_policy( + &config.ca_signature_algorithms, + DEFAULT_CA_SIGNATURE_ALGORITHMS, + )?, + )?; + output.args( + "canonicaldomains", + &raw_option_args(config, "canonicaldomains").unwrap_or_else(|| vec!["none".to_string()]), + )?; + let revoked_host_keys = raw_option(config, "revokedhostkeys") + .map(|value| tokens.expand_path_for_dump(&value)) + .transpose()? + .unwrap_or_else(|| "none".to_string()); + output.line("revokedhostkeys", revoked_host_keys)?; + output.args( + "logverbose", + &raw_option_args(config, "logverbose").unwrap_or_else(|| vec!["none".to_string()]), + )?; + output.args( + "channeltimeout", + &raw_option_args(config, "channeltimeout").unwrap_or_else(|| vec!["none".to_string()]), + )?; + output.line( + "tunneldevice", + raw_option(config, "tunneldevice") + .as_deref() + .unwrap_or("any:any"), + )?; + output.args( + "canonicalizepermittedcnames", + &raw_option_args(config, "canonicalizepermittedcnames") + .unwrap_or_else(|| vec!["none".to_string()]), + )?; + output.line( + "streamlocalbindmask", + raw_option(config, "streamlocalbindmask") + .as_deref() + .unwrap_or("0177"), + )?; + + for identity in identity_files(config) { + output.line("identityfile", identity)?; + } + for certificate in certificate_files(config) { + output.line("certificatefile", certificate)?; + } + let user_hosts = config.user_known_hosts_file.clone().unwrap_or_else(|| { + vec![ + "~/.ssh/known_hosts".to_string(), + "~/.ssh/known_hosts2".to_string(), + ] + }); + let user_hosts = user_hosts + .iter() + .map(|value| tokens.expand_path_for_dump(value)) + .collect::>>()?; + output.args("userknownhostsfile", &user_hosts)?; + let global_hosts = config.global_known_hosts_file.clone().unwrap_or_else(|| { + vec![ + "/etc/ssh/ssh_known_hosts".to_string(), + "/etc/ssh/ssh_known_hosts2".to_string(), + ] + }); + output.args("globalknownhostsfile", &global_hosts)?; + for value in &config.send_env { + output.line("sendenv", value)?; + } + let mut set_env = config.set_env.iter().collect::>(); + set_env.sort_by(|left, right| left.0.cmp(right.0)); + for (name, value) in set_env { + output.line( + "setenv", + format!("{name}={}", tokens.expand_for_dump(value)?), + )?; + } + if !config.clear_all_forwardings.unwrap_or(false) { + output_forwardings( + &mut output, + "localforward", + &config.local_forward, + &config.local_forward_args, + &tokens, + )?; + output_forwardings( + &mut output, + "remoteforward", + &config.remote_forward, + &config.remote_forward_args, + &tokens, + )?; + output_forwardings( + &mut output, + "dynamicforward", + &config.dynamic_forward, + &config.dynamic_forward_args, + &tokens, + )?; + } + let permit_remote_open = if config.permit_remote_open.is_empty() { + vec!["any".to_string()] + } else { + config.permit_remote_open.clone() + }; + output.args("permitremoteopen", &permit_remote_open)?; + output.line( + "addkeystoagent", + config.add_keys_to_agent.as_deref().unwrap_or("no"), + )?; + output.line( + "controlpersist", + config.control_persist.as_deref().unwrap_or("no"), + )?; + output.args("ipqos", &format_ipqos(config.ipqos.unwrap_or_default()))?; + output.args( + "rekeylimit", + &format_rekey(config.rekey_limit.unwrap_or_default()), + )?; + + for (keyword, args) in &config.unimplemented_options { + if !EMITTED_UNIMPLEMENTED.contains(&keyword.as_str()) { + output.args(keyword, args)?; + } + } + Ok(output.value) +} + +const EMITTED_UNIMPLEMENTED: &[&str] = &[ + "addkeystoagent", + "casignaturealgorithms", + "connecttimeout", + "controlmaster", + "controlpath", + "controlpersist", + "enablesshkeysign", + "escapechar", + "fingerprinthash", + "forkafterauthentication", + "forwardagent", + "forwardx11", + "forwardx11timeout", + "forwardx11trusted", + "gatewayports", + "gssapiauthentication", + "hostbasedacceptedalgorithms", + "hostbasedauthentication", + "identityagent", + "kbdinteractiveauthentication", + "loglevel", + "nohostauthenticationforlocalhost", + "permitremoteopen", + "protocol", + "requiredrsasize", + "stdinnull", + "syslogfacility", + "visualhostkey", + "canonicalizefallbacklocal", + "canonicalizehostname", + "canonicalizemaxdots", + "canonicaldomains", + "canonicalizepermittedcnames", + "channeltimeout", + "enableescapecommandline", + "logverbose", + "obscurekeystroketiming", + "revokedhostkeys", + "securitykeyprovider", + "streamlocalbindunlink", + "streamlocalbindmask", + "tunnel", + "tunneldevice", + "warnweakcrypto", + "xauthlocation", +]; + +#[derive(Default)] +struct DumpWriter { + value: String, +} + +impl DumpWriter { + fn line(&mut self, keyword: &str, value: impl std::fmt::Display) -> Result<()> { + let value = value.to_string(); + if keyword.is_empty() + || keyword.chars().any(|ch| !ch.is_ascii_alphanumeric()) + || value.chars().any(char::is_control) + { + anyhow::bail!("Resolved SSH configuration contains an unsafe value"); + } + let value = super::value::encode(&value)?; + writeln!(self.value, "{} {}", keyword.to_ascii_lowercase(), value) + .context("Failed to format resolved SSH configuration") + } + + fn args(&mut self, keyword: &str, values: &[String]) -> Result<()> { + if values.is_empty() { + anyhow::bail!("Resolved SSH configuration contains an empty argument list"); + } + if keyword.is_empty() + || keyword.chars().any(|ch| !ch.is_ascii_alphanumeric()) + || values + .iter() + .flat_map(|value| value.chars()) + .any(char::is_control) + { + anyhow::bail!("Resolved SSH configuration contains an unsafe value"); + } + let encoded = values + .iter() + .map(|value| super::value::encode(value)) + .collect::>>()?; + writeln!( + self.value, + "{} {}", + keyword.to_ascii_lowercase(), + encoded.join(" ") + ) + .context("Failed to format resolved SSH configuration") + } + + fn command(&mut self, keyword: &str, value: &str) -> Result<()> { + if keyword.is_empty() + || keyword.chars().any(|ch| !ch.is_ascii_alphanumeric()) + || value.is_empty() + || value.chars().any(char::is_control) + { + anyhow::bail!("Resolved SSH configuration contains an unsafe command"); + } + writeln!(self.value, "{} {}", keyword.to_ascii_lowercase(), value) + .context("Failed to format resolved SSH configuration") + } + + fn bool(&mut self, keyword: &str, value: bool) -> Result<()> { + self.line(keyword, yes_no(value)) + } + + fn optional(&mut self, keyword: &str, value: Option<&str>) -> Result<()> { + if let Some(value) = value { + self.line(keyword, value)?; + } + Ok(()) + } + + fn optional_command(&mut self, keyword: &str, value: Option<&str>) -> Result<()> { + if let Some(value) = value { + self.command(keyword, value)?; + } + Ok(()) + } + + fn percent_expanded_command( + &mut self, + keyword: &str, + value: Option<&str>, + tokens: &TokenContext, + ) -> Result<()> { + if let Some(value) = value { + self.command(keyword, &tokens.expand_percent(value)?)?; + } + Ok(()) + } +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +fn raw_option(config: &SshHostConfig, keyword: &str) -> Option { + config + .unimplemented_options + .get(keyword) + .map(|args| args.join(" ")) +} + +fn raw_option_args(config: &SshHostConfig, keyword: &str) -> Option> { + config.unimplemented_options.get(keyword).cloned() +} + +fn expanded_args(value: &str, tokens: &TokenContext) -> Result> { + super::value::tokenize(&tokens.expand_for_dump(value)?, 0) +} + +fn output_forwardings( + output: &mut DumpWriter, + keyword: &str, + values: &[String], + argument_lists: &[Vec], + tokens: &TokenContext, +) -> Result<()> { + if argument_lists.len() == values.len() { + for arguments in argument_lists { + let expanded = arguments + .iter() + .map(|argument| tokens.expand_for_dump(argument)) + .collect::>>()?; + output.args(keyword, &expanded)?; + } + } else { + // Preserve the public programmatic API for configurations constructed + // without the parser's structured argument metadata. + for value in values { + output.args(keyword, &expanded_args(value, tokens)?)?; + } + } + Ok(()) +} + +fn list_or(values: &[String], default: &str) -> String { + if values.is_empty() { + default.to_string() + } else { + values.join(",") + } +} + +fn cipher_names(config: &SshHostConfig) -> String { + config + .resolved_ciphers + .as_deref() + .unwrap_or(russh::Preferred::DEFAULT.cipher.as_ref()) + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(",") +} + +fn mac_names(config: &SshHostConfig) -> String { + config + .resolved_macs + .as_deref() + .unwrap_or(russh::Preferred::DEFAULT.mac.as_ref()) + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(",") +} + +fn kex_names(config: &SshHostConfig) -> String { + config + .resolved_kex_algorithms + .as_deref() + .unwrap_or(russh::Preferred::DEFAULT.kex.as_ref()) + .iter() + .map(AsRef::as_ref) + .filter(|name| { + !matches!( + *name, + "ext-info-c" + | "ext-info-s" + | "kex-strict-c-v00@openssh.com" + | "kex-strict-s-v00@openssh.com" + ) + }) + .collect::>() + .join(",") +} + +fn host_key_names(config: &SshHostConfig) -> String { + config + .resolved_host_key_algorithms + .as_deref() + .unwrap_or(russh::Preferred::DEFAULT.key.as_ref()) + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") +} + +fn pubkey_names(config: &SshHostConfig) -> String { + config + .resolved_pubkey_accepted_algorithms + .clone() + .unwrap_or_else(crate::ssh::tokio_client::algorithms::default_pubkey_algorithms) + .join(",") +} + +fn identity_files(config: &SshHostConfig) -> Vec { + let default_names = [ + "id_rsa", + "id_ecdsa", + "id_ecdsa_sk", + "id_ed25519", + "id_ed25519_sk", + ]; + let is_expanded_default = dirs::home_dir().is_some_and(|home| { + config.identity_files.len() == default_names.len() + && config + .identity_files + .iter() + .zip(default_names) + .all(|(path, name)| path == &home.join(".ssh").join(name)) + }); + if !config.identity_file_args.is_empty() + && config.identity_file_args.len() == config.identity_files.len() + { + return config.identity_file_args.clone(); + } + if !config.identity_files.is_empty() && !is_expanded_default { + return config + .identity_files + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + } + default_names + .into_iter() + .map(|name| format!("~/.ssh/{name}")) + .collect() +} + +fn certificate_files(config: &SshHostConfig) -> Vec { + if !config.certificate_file_args.is_empty() + && config.certificate_file_args.len() == config.certificate_files.len() + { + return config.certificate_file_args.clone(); + } + config + .certificate_files + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect() +} + +fn format_ipqos(policy: IpQosPolicy) -> Vec { + vec![qos_value(policy.interactive), qos_value(policy.bulk)] +} + +fn qos_value(value: IpQosValue) -> String { + match value { + IpQosValue::None => "none".to_string(), + IpQosValue::Class(0x28) => "af11".to_string(), + IpQosValue::Class(0x30) => "af12".to_string(), + IpQosValue::Class(0x38) => "af13".to_string(), + IpQosValue::Class(0x48) => "af21".to_string(), + IpQosValue::Class(0x50) => "af22".to_string(), + IpQosValue::Class(0x58) => "af23".to_string(), + IpQosValue::Class(0x68) => "af31".to_string(), + IpQosValue::Class(0x70) => "af32".to_string(), + IpQosValue::Class(0x78) => "af33".to_string(), + IpQosValue::Class(0x88) => "af41".to_string(), + IpQosValue::Class(0x90) => "af42".to_string(), + IpQosValue::Class(0x98) => "af43".to_string(), + IpQosValue::Class(0x20) => "cs1".to_string(), + IpQosValue::Class(0x40) => "cs2".to_string(), + IpQosValue::Class(0x60) => "cs3".to_string(), + IpQosValue::Class(0x80) => "cs4".to_string(), + IpQosValue::Class(0xa0) => "cs5".to_string(), + IpQosValue::Class(0xc0) => "cs6".to_string(), + IpQosValue::Class(0xe0) => "cs7".to_string(), + IpQosValue::Class(0xb8) => "ef".to_string(), + IpQosValue::Class(0x04) => "le".to_string(), + IpQosValue::Class(0xb0) => "va".to_string(), + IpQosValue::Class(0) => "cs0".to_string(), + IpQosValue::Class(value) => value.to_string(), + } +} + +fn format_rekey(limit: RekeyLimit) -> Vec { + let data = match limit.data { + RekeyDataLimit::Default => 0, + RekeyDataLimit::Bytes(value) => value, + }; + let time = match limit.time { + RekeyTimeLimit::Default | RekeyTimeLimit::None => 0, + RekeyTimeLimit::Seconds(value) => value, + }; + vec![data.to_string(), time.to_string()] +} + +const DEFAULT_HOSTBASED_ACCEPTED_ALGORITHMS: &str = "ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,webauthn-sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,webauthn-sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256"; + +const DEFAULT_CA_SIGNATURE_ALGORITHMS: &str = "ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,webauthn-sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256"; + +const SUPPORTED_SIGNATURE_ALGORITHMS: &[&str] = &[ + "ssh-ed25519", + "ssh-ed25519-cert-v01@openssh.com", + "sk-ssh-ed25519@openssh.com", + "sk-ssh-ed25519-cert-v01@openssh.com", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521", + "ecdsa-sha2-nistp521-cert-v01@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "webauthn-sk-ecdsa-sha2-nistp256@openssh.com", + "webauthn-sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ssh-rsa", + "ssh-rsa-cert-v01@openssh.com", + "rsa-sha2-256", + "rsa-sha2-256-cert-v01@openssh.com", + "rsa-sha2-512", + "rsa-sha2-512-cert-v01@openssh.com", +]; + +fn resolve_signature_policy(configured: &[String], defaults: &str) -> Result { + if configured.is_empty() { + return Ok(defaults.to_string()); + } + + let mut values = configured + .iter() + .flat_map(|value| value.split(',')) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>(); + let modifier = values + .first_mut() + .and_then(|value| { + value + .chars() + .next() + .filter(|ch| matches!(ch, '+' | '-' | '^')) + }) + .inspect(|_| { + values[0].remove(0); + }); + if values.iter().any(String::is_empty) { + anyhow::bail!("algorithm policy contains an empty name or pattern"); + } + + let mut resolved = defaults.split(',').map(str::to_string).collect::>(); + match modifier { + None => { + validate_signature_names(&values)?; + resolved = values; + } + Some('+') => { + validate_signature_names(&values)?; + for value in values { + if !resolved.contains(&value) { + resolved.push(value); + } + } + } + Some('^') => { + validate_signature_names(&values)?; + values.retain(|value| !resolved.contains(value)); + values.extend(resolved); + resolved = values; + } + Some('-') => { + let patterns = values + .iter() + .map(|value| { + glob::Pattern::new(value) + .with_context(|| format!("invalid algorithm pattern '{value}'")) + }) + .collect::>>()?; + resolved.retain(|value| !patterns.iter().any(|pattern| pattern.matches(value))); + } + Some(_) => unreachable!("modifier was filtered above"), + } + if resolved.is_empty() { + anyhow::bail!("algorithm policy removed all supported algorithms"); + } + Ok(resolved.join(",")) +} + +fn validate_signature_names(values: &[String]) -> Result<()> { + for value in values { + if !SUPPORTED_SIGNATURE_ALGORITHMS.contains(&value.as_str()) { + anyhow::bail!("unsupported signature algorithm '{value}'"); + } + } + Ok(()) +} diff --git a/src/ssh/ssh_config/dump/tokens.rs b/src/ssh/ssh_config/dump/tokens.rs new file mode 100644 index 00000000..37d27d3e --- /dev/null +++ b/src/ssh/ssh_config/dump/tokens.rs @@ -0,0 +1,192 @@ +use std::fmt::Write as _; + +use anyhow::{Context, Result}; +use sha1::{Digest, Sha1}; + +use super::super::SshHostConfig; + +pub(super) struct TokenContext { + pub(super) effective_host: String, + original_host: String, + pub(super) remote_user: String, + local_user: String, + local_home: String, + local_host: String, + local_host_short: String, + local_uid: String, + pub(super) port: String, + host_key_alias: String, + jump_host: String, + connection_hash: String, +} + +impl TokenContext { + pub(super) fn new(original_host: &str, config: &SshHostConfig) -> Self { + let local_user = whoami::username().unwrap_or_else(|_| "user".to_string()); + let local_home = dirs::home_dir() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let local_host = whoami::hostname().unwrap_or_else(|_| "localhost".to_string()); + let local_host_short = local_host + .split('.') + .next() + .unwrap_or(&local_host) + .to_string(); + Self { + effective_host: config + .hostname + .clone() + .unwrap_or_else(|| original_host.to_string()), + original_host: original_host.to_string(), + remote_user: config.user.clone().unwrap_or_else(|| local_user.clone()), + local_user, + local_home, + local_host, + local_host_short, + local_uid: local_uid(), + port: config.port.unwrap_or(22).to_string(), + host_key_alias: config + .host_key_alias + .clone() + .unwrap_or_else(|| original_host.to_string()), + jump_host: config.proxy_jump.clone().unwrap_or_default(), + connection_hash: String::new(), + } + } + + pub(super) fn refresh_hash(&mut self, jump: &str) { + self.jump_host.clear(); + self.jump_host.push_str(jump); + let mut digest = Sha1::new(); + digest.update(self.local_host.as_bytes()); + digest.update(self.effective_host.as_bytes()); + digest.update(self.port.as_bytes()); + digest.update(self.remote_user.as_bytes()); + digest.update(jump.as_bytes()); + self.connection_hash.clear(); + for byte in digest.finalize() { + let _ = write!(self.connection_hash, "{byte:02x}"); + } + } + + pub(super) fn expand(&self, value: &str) -> Result { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '$' && chars.peek() == Some(&'$') { + chars.next(); + output.push('$'); + continue; + } + if ch == '$' && chars.peek() == Some(&'{') { + chars.next(); + let mut name = String::new(); + loop { + match chars.next() { + Some('}') => break, + Some(ch) => name.push(ch), + None => anyhow::bail!("Environment expansion is missing closing '}}'"), + } + } + let value = std::env::var(&name) + .with_context(|| format!("Environment variable ${{{name}}} is not set"))?; + output.push_str(&value); + continue; + } + if ch != '%' { + output.push(ch); + continue; + } + let token = chars + .next() + .context("Incomplete '%' token in SSH configuration")?; + let replacement = match token { + '%' => "%", + 'C' => &self.connection_hash, + 'd' => &self.local_home, + 'h' => &self.effective_host, + 'i' => &self.local_uid, + 'j' => &self.jump_host, + 'k' => &self.host_key_alias, + 'L' => &self.local_host_short, + 'l' => &self.local_host, + 'n' => &self.original_host, + 'p' => &self.port, + 'r' => &self.remote_user, + 'u' => &self.local_user, + _ => anyhow::bail!("Unsupported SSH percent token: %{token}"), + }; + output.push_str(replacement); + } + Ok(output) + } + + pub(super) fn expand_percent(&self, value: &str) -> Result { + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + output.push(ch); + continue; + } + let token = chars + .next() + .context("Incomplete '%' token in SSH configuration")?; + let replacement = match token { + '%' => "%", + 'C' => &self.connection_hash, + 'd' => &self.local_home, + 'h' => &self.effective_host, + 'i' => &self.local_uid, + 'j' => &self.jump_host, + 'k' => &self.host_key_alias, + 'L' => &self.local_host_short, + 'l' => &self.local_host, + 'n' => &self.original_host, + 'p' => &self.port, + 'r' => &self.remote_user, + 'u' => &self.local_user, + _ => anyhow::bail!("Unsupported SSH percent token: %{token}"), + }; + output.push_str(replacement); + } + Ok(output) + } + + pub(super) fn expand_path(&self, value: &str) -> Result { + let value = if value == "~" { + format!("{}/", self.local_home) + } else if let Some(suffix) = value.strip_prefix("~/") { + format!("{}/{suffix}", self.local_home) + } else { + value.to_string() + }; + self.expand(&value) + } + + pub(super) fn expand_for_dump(&self, value: &str) -> Result { + self.expand(value) + .map(|value| Self::escape_for_dump(&value)) + } + + pub(super) fn expand_path_for_dump(&self, value: &str) -> Result { + self.expand_path(value) + .map(|value| Self::escape_for_dump(&value)) + } + + pub(super) fn escape_for_dump(value: &str) -> String { + value.replace('$', "$$") + } +} + +#[cfg(unix)] +fn local_uid() -> String { + // SAFETY: getuid has no arguments, dereferences no pointers, and cannot fail. + unsafe { libc::getuid() }.to_string() +} + +#[cfg(not(unix))] +fn local_uid() -> String { + "0".to_string() +} diff --git a/src/ssh/ssh_config/dump_tests.rs b/src/ssh/ssh_config/dump_tests.rs new file mode 100644 index 00000000..c995320d --- /dev/null +++ b/src/ssh/ssh_config/dump_tests.rs @@ -0,0 +1,325 @@ +use super::{SshConfig, SshHostConfig, render_resolved_config}; +use std::collections::HashSet; + +#[test] +fn resolved_dump_round_trips_with_typed_and_retained_values() { + let source = r#" +Host target + HostName final.example + User deploy + Port 2222 + Ciphers +aes128-cbc + MACs ^hmac-sha1 + KexAlgorithms -*sha1 + IPQoS af21 cs1 + RekeyLimit 16M 2h + SetEnv ZETA=%h ALPHA=value JUMP=%j ENV_HOME=${HOME} LITERAL=$${NOT_EXPANDED} + ForwardAgent /tmp/%h-agent + IdentityAgent /tmp/$${LITERAL}-agent + BindAddress %h + BindInterface %h + ProxyJump %h + UserKnownHostsFile /tmp/%h + GlobalKnownHostsFile /tmp/%h + TunnelDevice 1:2 +"#; + let config = SshConfig::parse(source).expect("source config should parse"); + let resolved = config.find_host_config("target"); + let first = render_resolved_config("target", &resolved).expect("dump should render"); + let reparsed = SshConfig::parse(&first).expect("dump should be valid ssh_config"); + let second = render_resolved_config("target", &reparsed.find_host_config("target")) + .expect("reparsed dump should render"); + + assert_eq!(first, second); + assert!(first.contains("ipqos af21 cs1\n")); + assert!(first.contains("rekeylimit 16777216 7200\n")); + assert!(first.contains("tunneldevice 1:2\n")); + assert!(first.contains("setenv LITERAL=$${NOT_EXPANDED}\n")); + assert!(first.contains("identityagent /tmp/$${LITERAL}-agent\n")); + assert!(first.contains("bindaddress %h\n")); + assert!(first.contains("bindinterface %h\n")); + assert!(first.contains("proxyjump %h\n")); + assert!(first.contains("userknownhostsfile /tmp/final.example\n")); + assert!(first.contains("globalknownhostsfile /tmp/%h\n")); + assert!(first.contains("setenv ZETA=final.example\n")); + assert!(first.contains("setenv JUMP=final.example\n")); + assert!(first.contains(&format!( + "setenv ENV_HOME={}\n", + dirs::home_dir().unwrap().display() + ))); + assert!(!first.contains("ciphers +")); + assert!(!first.contains("kexalgorithms -")); +} + +#[test] +fn path_keywords_follow_openssh_dump_expansion_categories() { + let source = r#" +Host target + HostName final.example + IdentityFile ~/.ssh/%h-key + CertificateFile ~/.ssh/%h-cert.pub + UserKnownHostsFile ~/.ssh/%h-known-hosts +"#; + let config = SshConfig::parse(source).unwrap(); + let first = render_resolved_config("target", &config.find_host_config("target")).unwrap(); + let home = dirs::home_dir().unwrap(); + + assert!(first.contains("identityfile ~/.ssh/%h-key\n")); + assert!(first.contains("certificatefile ~/.ssh/%h-cert.pub\n")); + assert!(first.contains(&format!( + "userknownhostsfile {}/.ssh/final.example-known-hosts\n", + home.display() + ))); + + let reparsed = SshConfig::parse(&first).unwrap(); + let second = render_resolved_config("target", &reparsed.find_host_config("target")).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn renderer_rejects_line_injection() { + let mut config = SshHostConfig { + hostname: Some("safe.example\nport 1".to_string()), + ..Default::default() + }; + let error = render_resolved_config("target", &config).expect_err("newline must be rejected"); + assert!(error.to_string().contains("unsafe value")); + + config.hostname = Some("safe.example\u{7}".to_string()); + let error = render_resolved_config("target", &config).expect_err("control must be rejected"); + assert!(error.to_string().contains("unsafe value")); +} + +#[test] +fn command_tokens_expand_only_at_the_openssh_dump_stages() { + let config = SshConfig::parse( + "LocalCommand printf '%h local'\nRemoteCommand printf '%h remote'\nKnownHostsCommand printf '%h known'\nProxyCommand printf '%h|${HOME}'\n", + ) + .unwrap(); + let first = render_resolved_config("target", &config.find_host_config("target")).unwrap(); + assert!(first.contains("localcommand printf '%h local'")); + assert!(first.contains("remotecommand printf 'target remote'")); + assert!(first.contains("knownhostscommand printf '%h known'")); + assert!(first.contains("proxycommand printf '%h|${HOME}'")); + + let reparsed = SshConfig::parse(&first).unwrap(); + let second = render_resolved_config("target", &reparsed.find_host_config("target")).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn quoted_scalar_and_list_elements_round_trip_without_collapsing() { + let source = r#" +Host target + IdentityFile "/tmp/identity a#b" + ControlPath "/tmp/control \"quoted\" \\path # literal" + IdentityAgent "/tmp/agent a#b" + UserKnownHostsFile "/tmp/known one" "/tmp/known#two" +"#; + let config = SshConfig::parse(source).unwrap(); + let first = render_resolved_config("target", &config.find_host_config("target")).unwrap(); + let reparsed = SshConfig::parse(&first).unwrap(); + let second = render_resolved_config("target", &reparsed.find_host_config("target")).unwrap(); + assert_eq!(first, second); + + let arguments = |keyword: &str| { + let line = first + .lines() + .find(|line| line.starts_with(&format!("{keyword} "))) + .unwrap(); + super::value::tokenize(&line[keyword.len() + 1..], 1).unwrap() + }; + assert_eq!(arguments("identityfile"), ["/tmp/identity a#b"]); + assert_eq!( + arguments("controlpath"), + [r#"/tmp/control "quoted" \path # literal"#] + ); + assert_eq!(arguments("identityagent"), ["/tmp/agent a#b"]); + assert_eq!( + arguments("userknownhostsfile"), + ["/tmp/known one", "/tmp/known#two"] + ); +} + +#[test] +fn forwarding_arguments_are_serialized_individually() { + let source = r#" +Host target + LocalForward "/tmp/local socket#one" "/tmp/destination 'quoted'" + RemoteForward 2200 localhost:22 + DynamicForward localhost:1080 +"#; + let config = SshConfig::parse(source).unwrap(); + let first = render_resolved_config("target", &config.find_host_config("target")).unwrap(); + assert!(first.lines().any( + |line| line == r#"localforward "/tmp/local socket#one" "/tmp/destination \'quoted\'""# + )); + assert!( + first + .lines() + .any(|line| line == "remoteforward 2200 localhost:22") + ); + assert!( + first + .lines() + .any(|line| line == "dynamicforward localhost:1080") + ); + + let reparsed = SshConfig::parse(&first).unwrap(); + let second = render_resolved_config("target", &reparsed.find_host_config("target")).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn final_pass_forward_dedup_keeps_the_first_argument_boundaries() { + let config = SshConfig::parse( + "Host target\n LocalForward \"a b\" c\nMatch final\n LocalForward a \"b c\"\n", + ) + .unwrap(); + let output = render_resolved_config("target", &config.find_host_config("target")).unwrap(); + assert_eq!( + output + .lines() + .filter(|line| line.starts_with("localforward ")) + .collect::>(), + [r#"localforward "a b" c"#] + ); +} + +#[test] +fn configured_canonicalization_values_render_and_round_trip() { + let config = SshConfig::parse( + "CanonicalizeHostname yes\nCanonicalizeFallbackLocal no\nCanonicalizeMaxDots 4\nCanonicalDomains one.example two.example\nCanonicalizePermittedCNAMEs a:b c:d\nMatch canonical\n Port 2201\n", + ) + .unwrap(); + let first = render_resolved_config("target", &config.find_host_config("target")).unwrap(); + assert!(first.contains("canonicalizehostname yes\n")); + assert!(first.contains("canonicalizefallbacklocal no\n")); + assert!(first.contains("canonicalizemaxdots 4\n")); + assert!(first.contains("canonicaldomains one.example two.example\n")); + assert!(first.contains("canonicalizepermittedcnames a:b c:d\n")); + assert!(first.contains("port 2201\n")); + let reparsed = SshConfig::parse(&first).unwrap(); + let second = render_resolved_config("target", &reparsed.find_host_config("target")).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn default_dump_has_the_audited_full_keyword_shape() { + let output = render_resolved_config("host", &SshHostConfig::default()).unwrap(); + let keywords = output + .lines() + .filter_map(|line| line.split_whitespace().next()) + .collect::>(); + assert!( + keywords.len() >= 77, + "only {} keywords: {output}", + keywords.len() + ); + for line in [ + "canonicalizefallbacklocal yes", + "canonicalizehostname false", + "canonicalizemaxdots 1", + "canonicaldomains none", + "canonicalizepermittedcnames none", + "channeltimeout none", + "enableescapecommandline no", + "logverbose none", + "obscurekeystroketiming yes", + "revokedhostkeys none", + "securitykeyprovider $SSH_SK_PROVIDER", + "streamlocalbindmask 0177", + "streamlocalbindunlink no", + "tunnel false", + "tunneldevice any:any", + "warnweakcrypto yes", + "xauthlocation /usr/bin/xauth", + ] { + assert!( + output.lines().any(|actual| actual == line), + "missing {line}" + ); + } + let ca = output + .lines() + .find(|line| line.starts_with("casignaturealgorithms ")) + .unwrap(); + assert!(!ca.contains("-cert-")); + + let hostbased = output + .lines() + .find(|line| line.starts_with("hostbasedacceptedalgorithms ")) + .unwrap(); + assert!(hostbased.contains("ssh-ed25519-cert-v01@openssh.com")); + assert!(hostbased.contains("rsa-sha2-256")); + assert!(!hostbased.contains("ssh-rsa,")); +} + +#[test] +fn signature_algorithm_modifiers_resolve_against_independent_defaults() { + let cases = [ + ( + "HostbasedAcceptedAlgorithms +ssh-rsa\n", + "hostbasedacceptedalgorithms", + ",rsa-sha2-256,ssh-rsa", + ), + ( + "HostbasedAcceptedAlgorithms -*cert*\n", + "hostbasedacceptedalgorithms", + "ssh-ed25519,ecdsa-sha2-nistp256", + ), + ( + "HostbasedAcceptedAlgorithms ^ssh-rsa\n", + "hostbasedacceptedalgorithms", + "ssh-rsa,ssh-ed25519-cert-v01@openssh.com", + ), + ( + "CASignatureAlgorithms +ssh-rsa\n", + "casignaturealgorithms", + ",rsa-sha2-256,ssh-rsa", + ), + ( + "CASignatureAlgorithms -ecdsa-*\n", + "casignaturealgorithms", + "ssh-ed25519,sk-ssh-ed25519@openssh.com", + ), + ( + "CASignatureAlgorithms ^ssh-rsa\n", + "casignaturealgorithms", + "ssh-rsa,ssh-ed25519", + ), + ]; + + for (source, keyword, expected_fragment) in cases { + let parsed = SshConfig::parse(source).unwrap(); + let output = render_resolved_config("host", &parsed.find_host_config("host")).unwrap(); + let line = output + .lines() + .find(|line| line.starts_with(keyword)) + .unwrap(); + assert!( + line.contains(expected_fragment), + "expected {expected_fragment:?} in {line:?}" + ); + assert!(!line.contains(" +") && !line.contains(" -") && !line.contains(" ^")); + } +} + +#[test] +fn signature_algorithm_policy_rejects_unknown_names_and_empty_results() { + let unknown = SshConfig::parse("CASignatureAlgorithms +not-a-real-key\n").unwrap(); + let error = render_resolved_config("host", &unknown.find_host_config("host")).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported signature algorithm") + ); + + let empty = SshConfig::parse("HostbasedAcceptedAlgorithms -*\n").unwrap(); + let error = render_resolved_config("host", &empty.find_host_config("host")).unwrap_err(); + assert!( + error + .to_string() + .contains("removed all supported algorithms") + ); +} diff --git a/src/ssh/ssh_config/include/mod.rs b/src/ssh/ssh_config/include/mod.rs index e62b9610..25218873 100644 --- a/src/ssh/ssh_config/include/mod.rs +++ b/src/ssh/ssh_config/include/mod.rs @@ -18,7 +18,6 @@ //! from external files, supporting glob patterns and recursive includes. use anyhow::{Context, Result}; -use std::collections::HashSet; use std::path::{Path, PathBuf}; use super::diagnostic::{escape_field, escape_path}; @@ -28,11 +27,12 @@ mod validation; // Re-export submodule items pub use resolver::{parse_include_line, resolve_include_pattern}; +pub(crate) use validation::read_config_file; #[allow(unused_imports)] -pub use validation::{validate_glob_pattern, validate_include_path}; +pub use validation::validate_glob_pattern; /// Maximum include depth to prevent infinite recursion -const MAX_INCLUDE_DEPTH: usize = 10; +const MAX_INCLUDE_DEPTH: usize = 16; /// Maximum number of files that can be included (DoS prevention) const MAX_INCLUDED_FILES: usize = 100; @@ -42,30 +42,37 @@ const MAX_INCLUDED_FILES: usize = 100; pub struct IncludeContext { /// Current recursion depth depth: usize, - /// Set of canonical paths already processed (cycle detection) - using string for efficiency - visited: HashSet, /// Total number of files included so far file_count: usize, - /// Base directory for relative includes - pub base_dir: PathBuf, - /// LRU cache for canonicalized paths to avoid repeated filesystem operations - canonical_cache: std::collections::HashMap, + /// Immutable OpenSSH origin for all nested relative Includes. + pub anchor: PathBuf, + /// Whether this source has OpenSSH's USERCONF tilde-expansion flag. + allow_tilde: bool, } impl IncludeContext { /// Create a new include context for the given config file + #[cfg_attr(not(test), allow(dead_code))] pub fn new(config_path: &Path) -> Self { - let base_dir = config_path + let anchor = config_path .parent() .unwrap_or_else(|| Path::new("/")) .to_path_buf(); Self { depth: 0, - visited: HashSet::with_capacity(16), // Pre-allocate reasonable capacity file_count: 0, - base_dir, - canonical_cache: std::collections::HashMap::with_capacity(16), + anchor, + allow_tilde: true, + } + } + + pub fn with_anchor(anchor: PathBuf, allow_tilde: bool) -> Self { + Self { + depth: 0, + file_count: 0, + anchor, + allow_tilde, } } @@ -87,54 +94,10 @@ impl IncludeContext { } /// Enter a new include level - fn enter_include(&mut self, path: &Path) -> Result<()> { + fn enter_include(&mut self) -> Result<()> { self.can_include()?; - - // Check cache first to avoid repeated canonicalization - let canonical = if let Some(cached) = self.canonical_cache.get(path) { - cached.clone() - } else if path.exists() { - // Canonicalize and cache the result - let canonical = path - .canonicalize() - .with_context(|| format!("Failed to canonicalize path: {}", escape_path(path)))?; - self.canonical_cache - .insert(path.to_path_buf(), canonical.clone()); - canonical - } else { - // For non-existent files, try to at least make it absolute - if path.is_absolute() { - path.to_path_buf() - } else { - self.base_dir.join(path) - } - }; - - // Use string representation for more efficient cycle detection - let canonical_str = canonical.to_string_lossy().into_owned(); - - // Check for cycles - if self.visited.contains(&canonical_str) { - anyhow::bail!( - "Include cycle detected: {} has already been processed", - escape_path(path) - ); - } - - self.visited.insert(canonical_str); self.depth += 1; self.file_count += 1; - - // Update base directory for nested includes - if let Some(parent) = canonical.parent() { - self.base_dir = parent.to_path_buf(); - } - - // Clear cache if it gets too large to prevent unbounded memory growth - if self.canonical_cache.len() > 100 { - self.canonical_cache.clear(); - } - Ok(()) } @@ -154,69 +117,186 @@ pub struct IncludedFile { pub path: PathBuf, /// File content pub content: String, - /// One-based line number of the first content line in the source file. - pub source_line_start: usize, + /// One-based source line for every line in `content`. + /// + /// Include expansion may inject a synthetic scope directive between physical + /// source lines. Keeping the mapping explicitly prevents those directives + /// from shifting diagnostics for the lines that follow them. + pub source_lines: Vec, + /// Per-line Match results evaluated during this configuration pass. + pub precomputed_matches: Vec>, + /// Per-line record of whether Match parsing requested a final pass. + pub precomputed_final_requests: Vec>, + /// Cumulative state of the parent scope at the Include site. + pub precomputed_scope_active: Option, + /// Host/Match scopes that guarded entry into this included file. + pub scope_guards: Vec, } /// Resolve Include directives and collect all configuration files /// Processes files in the order they appear, inserting included files at Include directive locations pub async fn resolve_includes(config_path: &Path, content: &str) -> Result> { - let mut context = IncludeContext::new(config_path); - - // Mark the main file as visited to prevent cycles - let canonical = if config_path.exists() { - config_path.canonicalize().with_context(|| { - format!( - "Failed to canonicalize main config path: {}", - escape_path(config_path) - ) - })? - } else { - config_path.to_path_buf() - }; - context - .visited - .insert(canonical.to_string_lossy().into_owned()); + resolve_includes_for_host(config_path, content, None).await +} + +/// Resolve Includes with `%h` bound to the destination being inspected. +pub async fn resolve_includes_for_host( + config_path: &Path, + content: &str, + hostname: Option<&str>, +) -> Result> { + let anchor = config_path + .parent() + .unwrap_or_else(|| Path::new("/")) + .to_path_buf(); + resolve_includes_for_host_at(config_path, content, hostname, anchor).await +} + +/// Resolve Includes relative to an immutable OpenSSH origin directory. +pub(crate) async fn resolve_includes_for_host_at( + config_path: &Path, + content: &str, + hostname: Option<&str>, + anchor: PathBuf, +) -> Result> { + let initial_config = super::types::SshHostConfig::default(); + resolve_includes_for_host_at_pass( + config_path, + content, + hostname, + anchor, + &initial_config, + false, + true, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn resolve_includes_for_host_at_pass( + config_path: &Path, + content: &str, + hostname: Option<&str>, + anchor: PathBuf, + initial_config: &super::types::SshHostConfig, + final_pass: bool, + allow_tilde: bool, +) -> Result> { + let mut context = IncludeContext::with_anchor(anchor, allow_tilde); + let mut expansion = IncludeExpansionState::new(hostname, initial_config, final_pass); // Process the main file with includes - process_file_with_includes(config_path, content, &mut context).await + process_file_with_includes( + config_path, + content, + &mut context, + &mut expansion, + "Host *", + &[], + true, + None, + None, + ) + .await +} + +#[derive(Debug)] +struct IncludeExpansionState { + original_hostname: Option, + effective_hostname: Option, + hostname_obtained: bool, + remote_user: Option, + config: super::types::SshHostConfig, + final_pass: bool, +} + +impl IncludeExpansionState { + fn new( + hostname: Option<&str>, + initial_config: &super::types::SshHostConfig, + final_pass: bool, + ) -> Self { + let effective_hostname = initial_config.hostname.as_deref().map_or_else( + || hostname.map(str::to_string), + |value| { + Some(hostname.map_or_else( + || value.to_string(), + |original| super::resolver::expand_hostname_value(value, original), + )) + }, + ); + Self { + original_hostname: hostname.map(str::to_string), + effective_hostname, + hostname_obtained: initial_config.hostname.is_some(), + remote_user: initial_config.user.clone(), + config: initial_config.clone(), + final_pass, + } + } } /// Process a file with Include directives, inserting included files at the correct positions +#[allow(clippy::too_many_arguments)] async fn process_file_with_includes( file_path: &Path, content: &str, context: &mut IncludeContext, + expansion: &mut IncludeExpansionState, + inherited_scope: &str, + scope_guards: &[String], + inherited_active: bool, + inherited_match_result: Option, + inherited_final_request: Option, ) -> Result> { let mut result = Vec::new(); let mut current_content = String::new(); - let mut current_source_line = 1; + let mut current_source_lines = Vec::new(); + let mut current_precomputed_matches = Vec::new(); + let mut current_precomputed_final_requests = Vec::new(); + let mut active_scope = inherited_scope.to_string(); + let mut scope_active = inherited_active; + let mut active_match_result = inherited_match_result; + let mut active_final_request = inherited_final_request; + let mut pending_scope_restore = false; + let mut pending_initial_scope = context.depth > 0; for (line_number, line) in content.lines().enumerate() { let line_number = line_number + 1; // 1-indexed for error messages let trimmed = line.trim(); // Check for Include directive - if let Some(patterns) = parse_include_line(trimmed) { + if let Some(patterns) = parse_include_line(trimmed)? { // Save current accumulated content as an IncludedFile (if not empty) if !current_content.is_empty() { result.push(IncludedFile { path: file_path.to_path_buf(), content: current_content.clone(), - source_line_start: current_source_line, + source_lines: current_source_lines.clone(), + precomputed_matches: current_precomputed_matches.clone(), + precomputed_final_requests: current_precomputed_final_requests.clone(), + precomputed_scope_active: expansion + .original_hostname + .as_ref() + .map(|_| inherited_active), + scope_guards: scope_guards.to_vec(), }); current_content.clear(); + current_source_lines.clear(); + current_precomputed_matches.clear(); + current_precomputed_final_requests.clear(); } - current_source_line = line_number + 1; // Process each Include pattern for pattern in patterns { - let resolved_files = resolve_include_pattern(pattern, context) + let expanded_environment = expand_include_environment(&pattern)?; + let expanded_pattern = expand_include_percent(&expanded_environment, expansion)?; + let resolved_files = resolve_include_pattern(&expanded_pattern, context) .await .with_context(|| { format!( "Failed to resolve Include pattern '{}' at line {} in {}", - escape_field(pattern), + escape_field(&pattern), line_number, escape_path(file_path) ) @@ -224,14 +304,14 @@ async fn process_file_with_includes( // Process each resolved file recursively for include_path in resolved_files { - context.enter_include(&include_path).with_context(|| { + context.enter_include().with_context(|| { format!("Failed to include file: {}", escape_path(&include_path)) })?; // Read with timeout to prevent hanging on network filesystems let include_content = tokio::time::timeout( std::time::Duration::from_secs(5), - tokio::fs::read_to_string(&include_path), + validation::read_config_file(&include_path, true, true), ) .await .map_err(|_| { @@ -246,12 +326,24 @@ async fn process_file_with_includes( escape_path(&include_path) ) })?; + let Some(include_content) = include_content else { + context.exit_include(); + continue; + }; + let mut child_guards = scope_guards.to_vec(); + child_guards.push(active_scope.clone()); // Recursively process the included file (use Box::pin to avoid stack overflow) let mut included_files = Box::pin(process_file_with_includes( &include_path, &include_content, context, + expansion, + &active_scope, + &child_guards, + scope_active, + active_match_result, + active_final_request, )) .await?; @@ -261,10 +353,70 @@ async fn process_file_with_includes( context.exit_include(); } } + pending_scope_restore = true; } else { + if pending_initial_scope && !trimmed.is_empty() && !trimmed.starts_with('#') { + let lower = trimmed.to_ascii_lowercase(); + let starts_new_scope = lower.starts_with("host ") + || lower.starts_with("host=") + || lower.starts_with("match ") + || lower.starts_with("match="); + if !starts_new_scope { + current_content.push_str(inherited_scope); + current_content.push('\n'); + current_source_lines.push(line_number); + current_precomputed_matches.push(inherited_match_result); + current_precomputed_final_requests.push(inherited_final_request); + } + pending_initial_scope = false; + } + if pending_scope_restore && !trimmed.is_empty() && !trimmed.starts_with('#') { + let lower = trimmed.to_ascii_lowercase(); + let starts_new_scope = lower.starts_with("host ") + || lower.starts_with("host=") + || lower.starts_with("match ") + || lower.starts_with("match="); + if !starts_new_scope { + current_content.push_str(&active_scope); + current_content.push('\n'); + current_source_lines.push(line_number); + current_precomputed_matches.push(active_match_result); + current_precomputed_final_requests.push(active_final_request); + } + pending_scope_restore = false; + } // Regular line - add to current content current_content.push_str(line); current_content.push('\n'); + current_source_lines.push(line_number); + let lower = trimmed.to_ascii_lowercase(); + let mut precomputed_match = None; + let mut precomputed_final_request = None; + if lower.starts_with("host ") + || lower.starts_with("host=") + || lower.starts_with("match ") + || lower.starts_with("match=") + { + active_scope = trimmed.to_string(); + let is_match = lower.starts_with("match ") || lower.starts_with("match="); + let evaluation = if inherited_active { + scope_evaluation(trimmed, expansion)? + } else { + ScopeEvaluation::default() + }; + let matched = inherited_active && evaluation.matched; + scope_active = matched; + let can_precompute = expansion.original_hostname.is_some(); + active_match_result = (is_match && can_precompute).then_some(matched); + active_final_request = + (is_match && can_precompute).then_some(evaluation.requests_final); + precomputed_match = active_match_result; + precomputed_final_request = active_final_request; + } else if scope_active { + update_expansion_state(trimmed, expansion)?; + } + current_precomputed_matches.push(precomputed_match); + current_precomputed_final_requests.push(precomputed_final_request); } } @@ -273,7 +425,14 @@ async fn process_file_with_includes( result.push(IncludedFile { path: file_path.to_path_buf(), content: current_content, - source_line_start: current_source_line, + source_lines: current_source_lines, + precomputed_matches: current_precomputed_matches, + precomputed_final_requests: current_precomputed_final_requests, + precomputed_scope_active: expansion + .original_hostname + .as_ref() + .map(|_| inherited_active), + scope_guards: scope_guards.to_vec(), }); } @@ -282,13 +441,174 @@ async fn process_file_with_includes( result.push(IncludedFile { path: file_path.to_path_buf(), content: content.to_string(), - source_line_start: 1, + source_lines: (1..=content.lines().count()).collect(), + precomputed_matches: vec![None; content.lines().count()], + precomputed_final_requests: vec![None; content.lines().count()], + precomputed_scope_active: expansion + .original_hostname + .as_ref() + .map(|_| inherited_active), + scope_guards: scope_guards.to_vec(), }); } Ok(result) } +#[derive(Debug, Clone, Copy, Default)] +struct ScopeEvaluation { + matched: bool, + requests_final: bool, +} + +fn scope_evaluation(line: &str, state: &IncludeExpansionState) -> Result { + let Some(original_hostname) = state.original_hostname.as_deref() else { + return Ok(ScopeEvaluation { + matched: true, + requests_final: false, + }); + }; + let lower = line.trim_start().to_ascii_lowercase(); + if lower.starts_with("host ") || lower.starts_with("host\t") || lower.starts_with("host=") { + let (_, patterns) = split_directive(line, 0)?; + return Ok(ScopeEvaluation { + matched: super::pattern::matches_host_pattern(original_hostname, &patterns), + requests_final: false, + }); + } + let conditions = super::match_directive::MatchCondition::parse_match_line(line, 0)?; + let context = super::match_directive::MatchContext::with_original_hostname( + state + .effective_hostname + .clone() + .unwrap_or_else(|| original_hostname.to_string()), + original_hostname.to_string(), + state.remote_user.clone(), + )? + .with_config(&state.config) + .with_final_pass(state.final_pass) + .with_trusted_shell_exec(); + let block = super::match_directive::MatchBlock { + conditions, + config: super::types::SshHostConfig::default(), + line_number: 0, + }; + let evaluation = block.evaluate(&context)?; + Ok(ScopeEvaluation { + matched: evaluation.matched, + requests_final: evaluation.requests_final, + }) +} + +fn update_expansion_state(line: &str, state: &mut IncludeExpansionState) -> Result<()> { + let (keyword, args) = split_directive(line, 0)?; + if keyword == "hostname" && !state.hostname_obtained { + let value = args.first().context("HostName requires a value")?; + state.effective_hostname = Some(state.original_hostname.as_deref().map_or_else( + || value.clone(), + |original| super::resolver::expand_hostname_value(value, original), + )); + state.hostname_obtained = true; + } else if keyword == "user" && state.remote_user.is_none() { + state.remote_user = args.first().cloned(); + state.config.user = state.remote_user.clone(); + } else if keyword == "port" && state.config.port.is_none() { + state.config.port = Some( + args.first() + .context("Port requires a value")? + .parse() + .context("Invalid Port value")?, + ); + } else if keyword == "hostkeyalias" && state.config.host_key_alias.is_none() { + state.config.host_key_alias = args.first().cloned(); + } else if keyword == "proxyjump" && state.config.proxy_jump.is_none() { + state.config.proxy_jump = args.first().cloned(); + } + Ok(()) +} + +fn expand_include_percent(pattern: &str, state: &IncludeExpansionState) -> Result { + let Some(original) = state.original_hostname.as_deref() else { + return Ok(pattern.to_string()); + }; + let context = super::match_directive::MatchContext::with_original_hostname( + state + .effective_hostname + .clone() + .unwrap_or_else(|| original.to_string()), + original.to_string(), + state.remote_user.clone(), + )? + .with_config(&state.config); + let mut output = String::with_capacity(pattern.len()); + let mut chars = pattern.chars(); + while let Some(character) = chars.next() { + if character != '%' { + output.push(character); + continue; + } + let token = chars + .next() + .context("Incomplete '%' token in Include path")?; + if token == '%' { + output.push('%'); + continue; + } + let value = context + .variables + .get(&token.to_string()) + .with_context(|| format!("Unsupported Include percent token: %{token}"))?; + output.push_str(value); + } + Ok(output) +} + +fn split_directive(line: &str, line_number: usize) -> Result<(String, Vec)> { + let line = line.trim(); + let boundary = line + .char_indices() + .find(|(_, ch)| ch.is_whitespace() || *ch == '='); + let (keyword, remainder) = boundary.map_or((line, ""), |(index, delimiter)| { + ( + &line[..index], + line[index + delimiter.len_utf8()..].trim_start(), + ) + }); + Ok(( + keyword.to_ascii_lowercase(), + super::value::tokenize(remainder, line_number)?, + )) +} + +fn expand_include_environment(pattern: &str) -> Result { + let mut output = String::with_capacity(pattern.len()); + let mut remaining = pattern; + while let Some(start) = remaining.find("${") { + output.push_str(&remaining[..start]); + let variable = &remaining[start + 2..]; + let end = variable + .find('}') + .context("Include environment variable is missing closing '}'")?; + let name = &variable[..end]; + if name.is_empty() + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + anyhow::bail!("Include contains an invalid environment variable name"); + } + let value = std::env::var(name) + .with_context(|| format!("Include environment variable ${{{name}}} is not set"))?; + if value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n')) { + anyhow::bail!("Include environment variable contains a control character"); + } + output.push_str(&value); + remaining = &variable[end + 1..]; + } + output.push_str(remaining); + Ok(output) +} + /// Combine multiple included files into a single configuration string #[cfg(test)] pub fn combine_included_files(files: &[IncludedFile]) -> String { @@ -310,9 +630,25 @@ pub fn combine_included_files(files: &[IncludedFile]) -> String { #[cfg(test)] mod tests { use super::*; - use std::fs; use tempfile::TempDir; + mod fs { + pub use std::fs::{Permissions, create_dir, create_dir_all, set_permissions}; + + pub fn write( + path: impl AsRef, + contents: impl AsRef<[u8]>, + ) -> std::io::Result<()> { + std::fs::write(&path, contents)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) + } + } + #[tokio::test] async fn test_resolve_includes_simple() { let temp_dir = TempDir::new().unwrap(); @@ -397,7 +733,8 @@ mod tests { assert!( err_chain.contains("cycle") || err_chain.contains("already been processed") - || err_chain.contains("Include cycle"), + || err_chain.contains("Include cycle") + || err_chain.contains("depth"), "Expected cycle detection in error chain but got: {err_chain}" ); } @@ -612,4 +949,141 @@ mod tests { assert_eq!(result.len(), 1); assert!(result[0].content.contains("Host example.com")); } + + #[tokio::test] + async fn nested_relative_includes_keep_the_origin_anchor() { + let temp_dir = TempDir::new().unwrap(); + let anchor = temp_dir.path().join("anchor"); + let elsewhere = temp_dir.path().join("elsewhere"); + fs::create_dir_all(&anchor).unwrap(); + fs::create_dir_all(&elsewhere).unwrap(); + fs::write(anchor.join("first.conf"), "Include nested.conf\n").unwrap(); + fs::write( + anchor.join("nested.conf"), + "Host target\n User anchored\n", + ) + .unwrap(); + fs::write( + elsewhere.join("nested.conf"), + "Host target\n User wrong\n", + ) + .unwrap(); + let main = elsewhere.join("config"); + let content = "Include first.conf\n"; + fs::write(&main, content).unwrap(); + + let files = resolve_includes_for_host_at(&main, content, Some("target"), anchor.clone()) + .await + .unwrap(); + + assert!( + files + .iter() + .any(|file| file.path == anchor.join("nested.conf")) + ); + assert!( + !files + .iter() + .any(|file| file.path == elsewhere.join("nested.conf")) + ); + } + + #[tokio::test] + async fn percent_h_uses_streaming_effective_hostname() { + let temp_dir = TempDir::new().unwrap(); + let main = temp_dir.path().join("config"); + let content = "Host alias\n HostName effective.example\n Include %h.conf\n"; + fs::write(&main, content).unwrap(); + fs::write( + temp_dir.path().join("effective.example.conf"), + "User effective\n", + ) + .unwrap(); + fs::write(temp_dir.path().join("alias.conf"), "User alias\n").unwrap(); + + let files = resolve_includes_for_host_at( + &main, + content, + Some("alias"), + temp_dir.path().to_path_buf(), + ) + .await + .unwrap(); + + assert!( + files + .iter() + .any(|file| file.path.ends_with("effective.example.conf")) + ); + assert!(!files.iter().any(|file| file.path.ends_with("alias.conf"))); + } + + #[test] + fn include_percent_tokens_use_current_streaming_context() { + let initial = super::super::types::SshHostConfig { + hostname: Some("effective.example".to_string()), + user: Some("deploy".to_string()), + port: Some(2200), + host_key_alias: Some("key-alias".to_string()), + proxy_jump: Some("jump".to_string()), + ..Default::default() + }; + let state = IncludeExpansionState::new(Some("alias"), &initial, false); + let expanded = expand_include_percent("%h-%n-%r-%p-%k-%j-%%", &state).unwrap(); + assert_eq!( + expanded, + "effective.example-alias-deploy-2200-key-alias-jump-%" + ); + assert!(expand_include_percent("%Z", &state).is_err()); + } + + async fn resolve_include_chain(edge_count: usize) -> Result> { + let temp_dir = TempDir::new().unwrap(); + let main = temp_dir.path().join("config"); + let content = "Include level1.conf\n"; + fs::write(&main, content).unwrap(); + for level in 1..=edge_count { + let value = if level == edge_count { + "Host target\n".to_string() + } else { + format!("Include level{}.conf\n", level + 1) + }; + fs::write(temp_dir.path().join(format!("level{level}.conf")), value).unwrap(); + } + resolve_includes_for_host_at( + &main, + content, + Some("target"), + temp_dir.path().to_path_buf(), + ) + .await + } + + #[tokio::test] + async fn include_depth_accepts_sixteen_edges_and_rejects_seventeen() { + assert!(resolve_include_chain(16).await.is_ok()); + let error = resolve_include_chain(17).await.unwrap_err(); + assert!(format!("{error:?}").contains("Maximum include depth (16)")); + } + + #[cfg(unix)] + #[tokio::test] + async fn include_follows_safe_symlink_and_rejects_writable_target() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let temp_dir = TempDir::new().unwrap(); + let main = temp_dir.path().join("config"); + let target = temp_dir.path().join("target.conf"); + let link = temp_dir.path().join("link.conf"); + fs::write(&target, "Host target\n").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&target, &link).unwrap(); + let content = format!("Include {}\n", link.display()); + fs::write(&main, &content).unwrap(); + + assert!(resolve_includes(&main, &content).await.is_ok()); + fs::set_permissions(&target, fs::Permissions::from_mode(0o622)).unwrap(); + let error = resolve_includes(&main, &content).await.unwrap_err(); + assert!(format!("{error:?}").contains("Bad permissions")); + } } diff --git a/src/ssh/ssh_config/include/resolver.rs b/src/ssh/ssh_config/include/resolver.rs index 8687747a..4acd53d8 100644 --- a/src/ssh/ssh_config/include/resolver.rs +++ b/src/ssh/ssh_config/include/resolver.rs @@ -19,47 +19,30 @@ use std::path::PathBuf; use super::super::diagnostic::{escape_field, escape_path}; use super::super::path::expand_path_internal; -#[cfg(not(test))] -use super::validation::is_path_allowed; -use super::validation::{validate_glob_pattern, validate_include_path}; +use super::validation::validate_glob_pattern; use crate::ssh::ssh_config::include::IncludeContext; /// Parse an Include directive line -pub fn parse_include_line(line: &str) -> Option> { - // Support both "Include pattern" and "Include=pattern" syntax +pub fn parse_include_line(line: &str) -> Result>> { let line = line.trim(); - - // Check if it starts with Include directive (case-insensitive) - if !line.to_lowercase().starts_with("include") { - return None; - } - - // Extract the patterns part - let patterns_part = if let Some(pos) = line.find('=') { - // Include=pattern syntax - line[pos + 1..].trim() - } else { - // Include pattern syntax - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 2 || parts[0].to_lowercase() != "include" { - return None; - } - // Join all parts after "Include" keyword - line[parts[0].len()..].trim() + let boundary = line + .char_indices() + .find(|(_, ch)| ch.is_whitespace() || *ch == '='); + let (keyword, remainder) = match boundary { + Some((index, delimiter)) => ( + &line[..index], + line[index + delimiter.len_utf8()..].trim_start(), + ), + None => (line, ""), }; - - if patterns_part.is_empty() { - return None; + if !keyword.eq_ignore_ascii_case("include") { + return Ok(None); } - - // Split multiple patterns (space-separated) - let patterns: Vec<&str> = patterns_part.split_whitespace().collect(); - + let patterns = super::super::value::tokenize(remainder, 0)?; if patterns.is_empty() { - None - } else { - Some(patterns) + anyhow::bail!("Include directive requires at least one path"); } + Ok(Some(patterns)) } /// Resolve a single include pattern to a list of files @@ -70,12 +53,16 @@ pub async fn resolve_include_pattern( // Validate pattern for security before expansion validate_glob_pattern(pattern)?; + if pattern.starts_with('~') && !context.allow_tilde { + anyhow::bail!("Tilde expansion is not permitted in system SSH config Includes"); + } + // Expand environment variables and tilde let expanded = expand_path_internal(pattern)?; // Make relative paths relative to the config directory let search_path = if expanded.is_relative() { - context.base_dir.join(&expanded) + context.anchor.join(&expanded) } else { expanded }; @@ -110,60 +97,14 @@ pub async fn resolve_include_pattern( } match entry { - Ok(path) => { - // Additional security: ensure resolved path doesn't escape expected directories - // Skip this check in test mode - #[cfg(not(test))] - { - let canonical = match path.canonicalize() { - Ok(c) => c, - Err(_) if !path.exists() => continue, // Skip non-existent files - Err(e) => { - tracing::debug!( - "Failed to canonicalize {}: {}", - escape_path(&path), - escape_field(&e.to_string()) - ); - continue; - } - }; - - // Verify the canonical path is still under an allowed directory - if !is_path_allowed(&canonical) { - tracing::warn!( - "Glob result {} escapes allowed directories, skipping", - escape_path(&path) - ); - continue; - } - } - - // Skip directories and symlinks - match std::fs::symlink_metadata(&path) { - Ok(metadata) => { - if metadata.is_file() && !metadata.is_symlink() { - // Security check: validate the path - if validate_include_path(&path).is_ok() { - files.push(path); - } - } - } - Err(e) => { - tracing::debug!( - "Failed to get metadata for {}: {}", - escape_path(&path), - escape_field(&e.to_string()) - ); - } - } - } - Err(e) => { - // Log glob errors but continue - tracing::warn!( - "Error processing glob pattern '{}': {}", - escape_field(pattern_str), - escape_field(&e.to_string()) - ); + Ok(path) => files.push(path), + Err(error) => { + return Err(error).with_context(|| { + format!( + "Error processing glob pattern '{}'", + escape_field(pattern_str) + ) + }); } } } @@ -186,37 +127,53 @@ pub async fn resolve_include_pattern( mod tests { use super::*; use std::fs; + use std::path::Path; use tempfile::TempDir; + fn write_config(path: impl AsRef, contents: impl AsRef<[u8]>) { + let path = path.as_ref(); + fs::write(path, contents).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + } + #[test] fn test_parse_include_line() { // Test space syntax assert_eq!( - parse_include_line("Include ~/.ssh/config.d/*"), - Some(vec!["~/.ssh/config.d/*"]) + parse_include_line("Include ~/.ssh/config.d/*").unwrap(), + Some(vec!["~/.ssh/config.d/*".to_string()]) ); // Test equals syntax assert_eq!( - parse_include_line("Include=~/.ssh/config.d/*"), - Some(vec!["~/.ssh/config.d/*"]) + parse_include_line("Include=~/.ssh/config.d/*").unwrap(), + Some(vec!["~/.ssh/config.d/*".to_string()]) ); // Test multiple patterns assert_eq!( - parse_include_line("Include /etc/ssh/config.d/* ~/.ssh/extra/*"), - Some(vec!["/etc/ssh/config.d/*", "~/.ssh/extra/*"]) + parse_include_line("Include /etc/ssh/config.d/* ~/.ssh/extra/*").unwrap(), + Some(vec![ + "/etc/ssh/config.d/*".to_string(), + "~/.ssh/extra/*".to_string() + ]) ); // Test case insensitivity assert_eq!( - parse_include_line("include ~/.ssh/config.d/*"), - Some(vec!["~/.ssh/config.d/*"]) + parse_include_line("include ~/.ssh/config.d/*").unwrap(), + Some(vec!["~/.ssh/config.d/*".to_string()]) ); // Test non-include lines - assert_eq!(parse_include_line("Host example.com"), None); - assert_eq!(parse_include_line("User testuser"), None); + assert_eq!(parse_include_line("Host example.com").unwrap(), None); + assert_eq!(parse_include_line("Included yes").unwrap(), None); + assert_eq!(parse_include_line("Included=yes").unwrap(), None); + assert!(parse_include_line("Include").is_err()); } #[tokio::test] @@ -229,9 +186,9 @@ mod tests { let config_dir = temp_dir.path().join("config.d"); fs::create_dir(&config_dir).unwrap(); - fs::write(config_dir.join("01-first.conf"), "Host first\n").unwrap(); - fs::write(config_dir.join("02-second.conf"), "Host second\n").unwrap(); - fs::write(config_dir.join("03-third.conf"), "Host third\n").unwrap(); + write_config(config_dir.join("01-first.conf"), "Host first\n"); + write_config(config_dir.join("02-second.conf"), "Host second\n"); + write_config(config_dir.join("03-third.conf"), "Host third\n"); // Create context let main_config = temp_dir.path().join("config"); @@ -270,13 +227,30 @@ mod tests { ); } + #[tokio::test] + async fn system_source_rejects_tilde_include_while_user_source_accepts_it() { + let anchor = tempfile::tempdir().unwrap(); + let system = IncludeContext::with_anchor(anchor.path().to_path_buf(), false); + assert!( + resolve_include_pattern("~/.ssh/config", &system) + .await + .is_err() + ); + + let user = IncludeContext::with_anchor(anchor.path().to_path_buf(), true); + assert!( + resolve_include_pattern("~/.ssh/definitely-missing", &user) + .await + .is_ok() + ); + } + #[tokio::test] async fn test_include_with_tilde_expansion() { // Test that tilde expansion is handled - let patterns = parse_include_line("Include ~/.ssh/config.d/*.conf"); - assert!(patterns.is_some()); - - let patterns = patterns.unwrap(); + let patterns = parse_include_line("Include ~/.ssh/config.d/*.conf") + .unwrap() + .unwrap(); assert_eq!(patterns.len(), 1); assert!(patterns[0].starts_with("~/")); } diff --git a/src/ssh/ssh_config/include/validation.rs b/src/ssh/ssh_config/include/validation.rs index acb1837f..a46c58eb 100644 --- a/src/ssh/ssh_config/include/validation.rs +++ b/src/ssh/ssh_config/include/validation.rs @@ -15,7 +15,8 @@ //! Security validation for Include directive use anyhow::{Context, Result}; -use std::path::{Path, PathBuf}; +use std::path::Path; +use tokio::io::AsyncReadExt as _; use super::super::diagnostic::{escape_field, escape_path}; @@ -52,106 +53,74 @@ pub fn validate_glob_pattern(pattern: &str) -> Result<()> { Ok(()) } -/// Check if a path is in an allowed directory -#[cfg(not(test))] -pub fn is_path_allowed(path: &Path) -> bool { - let allowed_prefixes = [ - dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")), - PathBuf::from("/etc/ssh"), - PathBuf::from("/usr/local/etc/ssh"), - std::env::temp_dir(), // Allow temp directories for testing - ]; - - allowed_prefixes - .iter() - .any(|prefix| path.starts_with(prefix)) -} - -/// Validate an include file path for security -pub fn validate_include_path(path: &Path) -> Result<()> { - // Check if file exists - if !path.exists() { - // Non-existent files are silently ignored per SSH spec - return Ok(()); - } - - // Get metadata without following symlinks - let metadata = std::fs::symlink_metadata(path) - .with_context(|| format!("Failed to get metadata for {}", escape_path(path)))?; - - // Reject symbolic links for security - if metadata.is_symlink() { - anyhow::bail!( - "Include path {} is a symbolic link. Symlinks are not allowed for security reasons.", - escape_path(path) - ); - } - - // Check if it's a regular file +fn validate_opened_metadata(path: &Path, metadata: &std::fs::Metadata) -> Result<()> { if !metadata.is_file() { anyhow::bail!("Include path is not a regular file: {}", escape_path(path)); } - // Canonicalize and verify the path doesn't escape expected directories - let canonical = path - .canonicalize() - .with_context(|| format!("Failed to canonicalize {}", escape_path(path)))?; - - // Check for directory traversal attempts - let path_str = canonical.to_string_lossy(); - if path_str.contains("../") || path_str.contains("..\\") { - anyhow::bail!( - "Include path {} contains directory traversal sequences", - escape_path(path) - ); - } - - // Restrict includes to safe directories - let safe_prefixes = [ - dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")), - PathBuf::from("/etc/ssh"), - PathBuf::from("/usr/local/etc/ssh"), - std::env::temp_dir(), // Allow temp directories for testing - ]; - - let is_safe = safe_prefixes - .iter() - .any(|prefix| canonical.starts_with(prefix)); - - if !is_safe { - tracing::warn!( - "Include path {} is outside of standard SSH config directories. This may be a security risk.", - escape_path(&canonical) - ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let uid = unsafe { libc::getuid() }; + if metadata.uid() != 0 && metadata.uid() != uid { + anyhow::bail!("Bad owner for SSH config file {}", escape_path(path)); + } + if metadata.mode() & 0o22 != 0 { + anyhow::bail!("Bad permissions for SSH config file {}", escape_path(path)); + } } - // Check file permissions (warn on world-writable or group-writable) - // Skip permission checks in test mode to allow temporary test files - #[cfg(all(unix, not(test)))] - { - use std::os::unix::fs::PermissionsExt; + Ok(()) +} - let permissions = metadata.permissions(); - let mode = permissions.mode(); +/// Open, validate with `fstat`, and read from the same handle. +pub(crate) async fn read_config_file( + path: &Path, + check_permissions: bool, + missing_ok: bool, +) -> Result> { + read_config_file_with_hook(path, check_permissions, missing_ok, || {}).await +} - // Check if world-writable (other-write bit set) - if mode & 0o002 != 0 { - anyhow::bail!( - "SSH config file {} is world-writable. This is a security vulnerability.", - escape_path(path) - ); +async fn read_config_file_with_hook( + path: &Path, + check_permissions: bool, + missing_ok: bool, + after_open: F, +) -> Result> +where + F: FnOnce(), +{ + let mut file = match tokio::fs::File::open(path).await { + Ok(file) => file, + Err(error) if missing_ok && error.kind() == std::io::ErrorKind::NotFound => { + return Ok(None); } - - // Check if group-writable (group-write bit set) - if mode & 0o020 != 0 { - tracing::warn!( - "SSH config file {} is group-writable. This is a potential security risk.", - escape_path(path) - ); + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to open SSH config file: {}", escape_path(path))); } + }; + after_open(); + let metadata = file.metadata().await.with_context(|| { + format!( + "Failed to inspect opened SSH config file: {}", + escape_path(path) + ) + })?; + if check_permissions { + validate_opened_metadata(path, &metadata)?; + } else if !metadata.is_file() { + anyhow::bail!( + "SSH config path is not a regular file: {}", + escape_path(path) + ); } - - Ok(()) + let mut content = String::new(); + file.read_to_string(&mut content) + .await + .with_context(|| format!("Failed to read SSH config file: {}", escape_path(path)))?; + Ok(Some(content)) } #[cfg(test)] @@ -193,4 +162,29 @@ mod tests { // Path with ../ is allowed in pattern validation (checked later by is_path_allowed) assert!(validate_glob_pattern("../../../etc/passwd").is_ok()); } + + #[cfg(unix)] + #[tokio::test] + async fn symlink_swap_after_open_reads_and_checks_the_opened_target() { + use std::os::unix::fs::{PermissionsExt as _, symlink}; + + let directory = tempfile::tempdir().unwrap(); + let safe = directory.path().join("safe.conf"); + let unsafe_file = directory.path().join("unsafe.conf"); + let link = directory.path().join("config"); + std::fs::write(&safe, "User safe\n").unwrap(); + std::fs::write(&unsafe_file, "User unsafe\n").unwrap(); + std::fs::set_permissions(&safe, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::set_permissions(&unsafe_file, std::fs::Permissions::from_mode(0o622)).unwrap(); + symlink(&safe, &link).unwrap(); + + let content = read_config_file_with_hook(&link, true, false, || { + std::fs::remove_file(&link).unwrap(); + symlink(&unsafe_file, &link).unwrap(); + }) + .await + .unwrap() + .unwrap(); + assert_eq!(content, "User safe\n"); + } } diff --git a/src/ssh/ssh_config/integration_tests/certificate_forwarding_integration_test.rs b/src/ssh/ssh_config/integration_tests/certificate_forwarding_integration_test.rs index 6f8459a5..0de03084 100644 --- a/src/ssh/ssh_config/integration_tests/certificate_forwarding_integration_test.rs +++ b/src/ssh/ssh_config/integration_tests/certificate_forwarding_integration_test.rs @@ -23,8 +23,19 @@ pub(crate) mod tests { use crate::ssh::ssh_config::SshConfig; use std::fs; + use std::path::Path; use tempfile::TempDir; + fn write_config(path: impl AsRef, contents: impl AsRef<[u8]>) { + let path = path.as_ref(); + fs::write(path, contents).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + } + #[tokio::test] async fn test_include_with_certificate_options() { let temp_dir = TempDir::new().unwrap(); @@ -39,7 +50,7 @@ Host *.prod.example.com HostbasedAuthentication yes HostbasedAcceptedAlgorithms ssh-ed25519,rsa-sha2-512 "#; - fs::write(&include_file, include_content).unwrap(); + write_config(&include_file, include_content); // Create main config that includes the certificate config let main_config = temp_dir.path().join("config"); @@ -53,7 +64,7 @@ Host web.prod.example.com "#, include_file.display() ); - fs::write(&main_config, &main_content).unwrap(); + write_config(&main_config, &main_content); // Parse the configuration let config = SshConfig::load_from_file(&main_config).await.unwrap(); @@ -98,7 +109,7 @@ Host *.secure.example.com PermitRemoteOpen localhost:8080 PermitRemoteOpen db.internal:5432 "#; - fs::write(&include_file, include_content).unwrap(); + write_config(&include_file, include_content); // Create main config let main_config = temp_dir.path().join("config"); @@ -111,7 +122,7 @@ Host app.secure.example.com "#, include_file.display() ); - fs::write(&main_config, &main_content).unwrap(); + write_config(&main_config, &main_content); // Parse the configuration let config = SshConfig::load_from_file(&main_config).await.unwrap(); @@ -213,7 +224,7 @@ Match host *.corp.example.com CertificateFile ~/.ssh/corp-cert.pub HostbasedAuthentication yes "#; - fs::write(&base_file, base_content).unwrap(); + write_config(&base_file, base_content); // Create forwarding config let forward_file = temp_dir.path().join("forward.conf"); @@ -223,7 +234,7 @@ Host *.prod.corp.example.com ExitOnForwardFailure yes PermitRemoteOpen localhost:8080 "#; - fs::write(&forward_file, forward_content).unwrap(); + write_config(&forward_file, forward_content); // Main config includes both let main_config = temp_dir.path().join("config"); @@ -240,7 +251,7 @@ Host web.prod.corp.example.com base_file.display(), forward_file.display() ); - fs::write(&main_config, &main_content).unwrap(); + write_config(&main_config, &main_content); // Parse let config = SshConfig::load_from_file(&main_config).await.unwrap(); @@ -267,19 +278,18 @@ Host web.prod.corp.example.com // Deep include: base authentication let deep_file = temp_dir.path().join("deep.conf"); - fs::write( + write_config( &deep_file, r#" Host * HostbasedAuthentication no CertificateFile ~/.ssh/default-cert.pub "#, - ) - .unwrap(); + ); // Middle include: prod-specific let middle_file = temp_dir.path().join("middle.conf"); - fs::write( + write_config( &middle_file, format!( r#" @@ -292,12 +302,11 @@ Host *.prod.example.com "#, deep_file.display() ), - ) - .unwrap(); + ); // Main config let main_config = temp_dir.path().join("config"); - fs::write( + write_config( &main_config, format!( r#" @@ -313,8 +322,7 @@ Host web1.prod.example.com "#, middle_file.display() ), - ) - .unwrap(); + ); // Parse let config = SshConfig::load_from_file(&main_config).await.unwrap(); @@ -379,7 +387,7 @@ Host web.secure.prod.example.com CertificateFile ~/.ssh/web-specific-cert.pub PermitRemoteOpen cache.internal:6379 "#; - fs::write(&config_file, config_content).unwrap(); + write_config(&config_file, config_content); // Parse let config = SshConfig::load_from_file(&config_file).await.unwrap(); diff --git a/src/ssh/ssh_config/match_directive/exec.rs b/src/ssh/ssh_config/match_directive/exec.rs index e98a2148..9460b247 100644 --- a/src/ssh/ssh_config/match_directive/exec.rs +++ b/src/ssh/ssh_config/match_directive/exec.rs @@ -16,7 +16,7 @@ use anyhow::{Context, Result}; use std::collections::HashMap; -use std::process::Command; +use std::process::{Command, Stdio}; use std::time::Duration; use super::MatchContext; @@ -26,145 +26,134 @@ const EXEC_TIMEOUT_SECS: u64 = 5; /// Execute a command for Match exec condition pub fn execute_match_command(command: &str, context: &MatchContext) -> Result { - // Security validation validate_exec_command(command)?; - - // Expand variables in command let expanded_command = expand_variables(command, &context.variables); - tracing::debug!("Executing Match exec command: {}", expanded_command); - - // Parse command into program and args using shell parsing for proper handling - let parts = shell_words::split(&expanded_command) - .with_context(|| format!("Failed to parse command: {expanded_command}"))?; - - if parts.is_empty() { - anyhow::bail!("Empty command for Match exec"); - } - - let program = &parts[0]; - let args = &parts[1..]; - - // Execute with proper timeout enforcement + let mut shell = shell_command(&expanded_command); + shell + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); #[cfg(unix)] { - use std::process::Stdio; - use std::time::Instant; - - let start = Instant::now(); - let timeout = Duration::from_secs(EXEC_TIMEOUT_SECS); - - let mut cmd = Command::new(program); - cmd.args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - // Set environment variables - for (key, value) in &context.variables { - cmd.env(format!("SSH_MATCH_{}", key.to_uppercase()), value); + use std::os::unix::process::CommandExt as _; + // SAFETY: setpgid only changes the child process group between fork + // and exec; it touches no Rust-managed memory. + unsafe { + shell.pre_exec(|| { + if libc::setpgid(0, 0) == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + }); } + } - // Spawn the process - let mut child = match cmd.spawn() { - Ok(child) => child, - Err(e) => { - tracing::debug!("Failed to spawn Match exec command '{}': {}", program, e); - return Ok(false); // Command execution failure means condition doesn't match + let mut child = match shell.spawn() { + Ok(child) => child, + Err(error) => { + tracing::debug!("Failed to spawn Match exec shell: {error}"); + return Ok(false); + } + }; + let started = std::time::Instant::now(); + let timeout = Duration::from_secs(EXEC_TIMEOUT_SECS); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status.success()), + Ok(None) if started.elapsed() < timeout => { + std::thread::sleep(Duration::from_millis(20)); } - }; - - // Wait with timeout using a loop - loop { - // Try to get the exit status without blocking - match child.try_wait() { - Ok(Some(status)) => { - // Process exited - let success = status.success(); - let elapsed = start.elapsed(); - - tracing::debug!( - "Match exec command '{}' completed in {:.1}s with status: {} (exit code: {:?})", - program, - elapsed.as_secs_f64(), - success, - status.code() - ); - - return Ok(success); - } - Ok(None) => { - // Process still running, check timeout - if start.elapsed() > timeout { - // Timeout exceeded, kill the process - tracing::warn!( - "Match exec command '{}' exceeded timeout of {}s, killing process", - program, - EXEC_TIMEOUT_SECS - ); - - // Try to kill the process - let _ = child.kill(); - // Wait a bit for it to die - std::thread::sleep(Duration::from_millis(100)); - // Force wait to clean up zombie - let _ = child.wait(); - - return Ok(false); - } - - // Sleep a bit before checking again - std::thread::sleep(Duration::from_millis(50)); - } - Err(e) => { - tracing::error!("Error waiting for Match exec command '{}': {}", program, e); - // Try to kill the process just in case - let _ = child.kill(); - return Ok(false); - } + Ok(None) => { + tracing::warn!( + "Match exec command exceeded timeout of {}s; terminating process group", + EXEC_TIMEOUT_SECS + ); + kill_match_process(&mut child); + return Ok(false); + } + Err(error) => { + tracing::debug!("Failed while waiting for Match exec shell: {error}"); + kill_match_process(&mut child); + return Ok(false); } } } +} - #[cfg(not(unix))] +/// Preserve the legacy non-shell Match exec behavior outside config-dump. +/// +/// Normal runtime configuration is resolved repeatedly by several getters, so +/// the explicitly authorized OpenSSH shell behavior must not leak out of the +/// host-aware `-G` preprocessing path. +pub(super) fn execute_match_command_direct(command: &str, context: &MatchContext) -> Result { + validate_direct_exec_command(command)?; + let expanded_command = expand_variables(command, &context.variables); + let parts = shell_words::split(&expanded_command) + .with_context(|| format!("Failed to parse Match exec command: {expanded_command}"))?; + let Some((program, args)) = parts.split_first() else { + anyhow::bail!("Empty Match exec command"); + }; + + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (key, value) in &context.variables { + command.env(format!("SSH_MATCH_{}", key.to_uppercase()), value); + } + #[cfg(unix)] { - use std::process::Stdio; - - // On non-Unix systems, use a simpler approach - let mut cmd = Command::new(program); - cmd.args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - // Set environment variables - for (key, value) in &context.variables { - cmd.env(format!("SSH_MATCH_{}", key.to_uppercase()), value); + use std::os::unix::process::CommandExt as _; + // SAFETY: setpgid only changes the child process group between fork + // and exec; it touches no Rust-managed memory. + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + }); } + } - // Note: Windows doesn't have good timeout support without additional dependencies - match cmd.status() { - Ok(status) => { - let success = status.success(); - tracing::debug!( - "Match exec command '{}' returned: {} (exit code: {:?})", - program, - success, - status.code() + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + tracing::debug!("Failed to spawn Match exec command '{program}': {error}"); + return Ok(false); + } + }; + let started = std::time::Instant::now(); + let timeout = Duration::from_secs(EXEC_TIMEOUT_SECS); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status.success()), + Ok(None) if started.elapsed() < timeout => { + std::thread::sleep(Duration::from_millis(20)); + } + Ok(None) => { + tracing::warn!( + "Match exec command exceeded timeout of {}s; terminating process group", + EXEC_TIMEOUT_SECS ); - Ok(success) + kill_match_process(&mut child); + return Ok(false); } - Err(e) => { - tracing::debug!("Match exec command '{}' failed: {}", program, e); - Ok(false) + Err(error) => { + tracing::debug!("Failed while waiting for Match exec command: {error}"); + kill_match_process(&mut child); + return Ok(false); } } } } -/// Validate an exec command for security -pub fn validate_exec_command(command: &str) -> Result<()> { - // Check command length first +fn validate_direct_exec_command(command: &str) -> Result<()> { const MAX_COMMAND_LENGTH: usize = 1024; if command.len() > MAX_COMMAND_LENGTH { anyhow::bail!( @@ -173,133 +162,97 @@ pub fn validate_exec_command(command: &str) -> Result<()> { MAX_COMMAND_LENGTH ); } - - // Check for newlines and control characters if command .chars() - .any(|c| c.is_control() && c != ' ' && c != '\t') + .any(|character| character.is_control() && character != '\t') { - anyhow::bail!( - "Match exec command contains control characters. This is blocked for security." - ); + anyhow::bail!("Match exec command contains a control character"); } - // Check for dangerous patterns with more comprehensive list const DANGEROUS_PATTERNS: &[&str] = &[ - "rm ", "rm\t", "rm-", "rmdir", "dd ", "dd\t", "mkfs", "format", "fdisk", ">", ">>", "<", - "<<", // File redirection - "|", // Pipes - ";", // Command chaining - "&&", "||", // Conditional execution - "&", // Background execution - "`", // Command substitution - "$(", // Command substitution - "${", // Variable expansion that could be dangerous - "\\n", "\\r", // Escaped newlines - "../", "..\\", // Directory traversal - "~/.", "~root", // Hidden file or root access attempts + "rm ", "rm\t", "rm-", "rmdir", "dd ", "dd\t", "mkfs", "format", "fdisk", ">", "<", "|", + ";", "&&", "||", "&", "`", "$(", "${", "\\n", "\\r", "../", "..\\", "~/.", "~root", ]; - - for pattern in DANGEROUS_PATTERNS { - if command.contains(pattern) { - anyhow::bail!( - "Match exec command contains potentially dangerous pattern '{pattern}'. \ - This is blocked for security reasons." - ); - } + if let Some(pattern) = DANGEROUS_PATTERNS + .iter() + .find(|pattern| command.contains(**pattern)) + { + anyhow::bail!("Match exec command contains potentially dangerous pattern '{pattern}'"); } - // Check for quotes that might hide dangerous patterns - let mut in_single_quote = false; - let mut in_double_quote = false; - let mut prev_char = '\0'; - - for ch in command.chars() { - match ch { - '\'' if prev_char != '\\' => in_single_quote = !in_single_quote, - '"' if prev_char != '\\' => in_double_quote = !in_double_quote, - '`' if !in_single_quote => { - anyhow::bail!( - "Match exec command contains backtick outside single quotes. \ - This could allow command substitution." - ); - } - '$' if !in_single_quote => { - // $ is dangerous in double quotes or unquoted - if let Some(next) = command.chars().nth(command.find('$').unwrap() + 1) - && (next == '(' || next == '{') - { - anyhow::bail!( - "Match exec command contains potential command or variable substitution. \ - This is blocked for security." - ); - } - } - _ => {} - } - prev_char = ch; + let parts = shell_words::split(command).context("Failed to parse Match exec command")?; + let first_word = parts.first().map_or("", String::as_str); + const BLOCKED_COMMANDS: &[&str] = &[ + "sh", "bash", "zsh", "ksh", "csh", "fish", "python", "python2", "python3", "perl", "ruby", + "php", "node", "nc", "netcat", "ncat", "socat", "wget", "curl", "fetch", "chmod", "chown", + "chgrp", + ]; + if let Some(blocked) = BLOCKED_COMMANDS + .iter() + .find(|blocked| first_word == **blocked || first_word.ends_with(&format!("/{blocked}"))) + { + anyhow::bail!("Match exec command uses blocked executable '{blocked}'"); } + Ok(()) +} - // Ensure quotes are balanced - if in_single_quote || in_double_quote { - anyhow::bail!("Match exec command has unbalanced quotes."); - } +#[cfg(unix)] +fn shell_command(command: &str) -> Command { + let mut shell = Command::new("/bin/sh"); + shell.arg("-c").arg(command); + shell +} - // Block potentially dangerous executables - const BLOCKED_COMMANDS: &[&str] = &[ - "sh", "bash", "zsh", "ksh", "csh", "fish", // Shells - "python", "python2", "python3", "perl", "ruby", "php", "node", // Interpreters - "nc", "netcat", "ncat", "socat", // Network tools - "wget", "curl", "fetch", // Download tools - "chmod", "chown", "chgrp", // Permission changes - ]; +#[cfg(windows)] +fn shell_command(command: &str) -> Command { + let mut shell = Command::new("cmd.exe"); + shell.arg("/C").arg(command); + shell +} - // Extract the first word (command name) - let first_word = command - .split_whitespace() - .next() - .unwrap_or("") - .trim_start_matches('/'); - - // Check against blocked commands - for blocked in BLOCKED_COMMANDS { - if first_word == *blocked || first_word.ends_with(&format!("/{blocked}")) { - anyhow::bail!( - "Match exec command uses blocked executable '{blocked}'. \ - Executing shells or interpreters is not allowed for security." - ); - } - } +#[cfg(not(any(unix, windows)))] +fn shell_command(command: &str) -> Command { + let mut shell = Command::new("sh"); + shell.arg("-c").arg(command); + shell +} - // Warn about potentially sensitive commands - const SENSITIVE_COMMANDS: &[&str] = &["sudo", "su", "doas", "passwd", "ssh", "scp", "sftp"]; - for cmd in SENSITIVE_COMMANDS { - if first_word == *cmd || first_word.ends_with(&format!("/{cmd}")) { - tracing::warn!( - "Match exec command uses potentially sensitive command '{}'. \ - Please ensure this is intentional and secure.", - cmd - ); +#[cfg(unix)] +fn kill_match_process(child: &mut std::process::Child) { + let pid = child.id(); + if let Ok(pid) = i32::try_from(pid) { + // SAFETY: a negative pid targets the process group created in pre_exec. + unsafe { + libc::kill(-pid, libc::SIGKILL); } } + let _ = child.kill(); + let _ = child.wait(); +} - // Restrict to allowlisted commands for maximum security (optional, logged as info) - const SAFE_COMMANDS: &[&str] = &[ - "test", "[", "ls", "cat", "grep", "head", "tail", "echo", "true", "false", "date", - "hostname", - ]; - if !SAFE_COMMANDS - .iter() - .any(|&safe| first_word == safe || first_word.ends_with(&format!("/{safe}"))) - { - tracing::info!( - "Match exec command '{}' is not in the safe command allowlist. \ - Consider using one of: {:?}", - first_word, - SAFE_COMMANDS +#[cfg(not(unix))] +fn kill_match_process(child: &mut std::process::Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +/// Validate an exec command for security +pub fn validate_exec_command(command: &str) -> Result<()> { + const MAX_COMMAND_LENGTH: usize = 8192; + if command.len() > MAX_COMMAND_LENGTH { + anyhow::bail!( + "Match exec command is too long ({} bytes). Maximum allowed is {} bytes.", + command.len(), + MAX_COMMAND_LENGTH ); } + if command + .chars() + .any(|character| character.is_control() && character != '\t') + { + anyhow::bail!("Match exec command contains a control character"); + } Ok(()) } @@ -316,6 +269,11 @@ pub fn expand_variables(command: &str, variables: &HashMap) -> S while let Some(ch) = chars.next() { if ch == '%' { if let Some(&next_ch) = chars.peek() { + if next_ch == '%' { + result.push('%'); + chars.next(); + continue; + } // Look for single character variable let key = next_ch.to_string(); if let Some(value) = variables.get(&key) { @@ -341,18 +299,23 @@ mod tests { use crate::ssh::ssh_config::match_directive::MatchContext; #[test] - fn test_validate_exec_command() { - // Valid commands - assert!(validate_exec_command("test -f /tmp/file").is_ok()); - assert!(validate_exec_command("ls -la").is_ok()); - assert!(validate_exec_command("echo hello").is_ok()); - - // Dangerous commands - assert!(validate_exec_command("rm -rf /").is_err()); - assert!(validate_exec_command("ls; rm file").is_err()); - assert!(validate_exec_command("echo `whoami`").is_err()); - assert!(validate_exec_command("cat file | grep pattern").is_err()); - assert!(validate_exec_command("dd if=/dev/zero of=/dev/sda").is_err()); + fn validation_preserves_shell_grammar_and_bounds_untrusted_size() { + // Match exec is trusted configuration and OpenSSH evaluates it with a + // shell. Operators, substitutions, and redirections are grammar rather + // than input to a command allowlist. + for command in [ + "test -f /tmp/file", + "printf x | grep x", + "false || true", + "value=$(printf x); test \"$value\" = x", + "printf x > /tmp/match-exec-output", + ] { + assert!(validate_exec_command(command).is_ok(), "{command}"); + } + assert!(validate_exec_command(&"x".repeat(8192)).is_ok()); + assert!(validate_exec_command(&"x".repeat(8193)).is_err()); + assert!(validate_exec_command("printf x\nprintf y").is_err()); + assert!(validate_exec_command("printf \0").is_err()); } #[test] @@ -372,27 +335,18 @@ mod tests { } #[test] - fn test_validate_exec_security_edge_cases() { - // Test boundary condition: exactly 1024 characters - let long_cmd = "a".repeat(1024); - assert!(validate_exec_command(&long_cmd).is_ok()); - - // Test over limit: 1025 characters - let too_long_cmd = "a".repeat(1025); - assert!(validate_exec_command(&too_long_cmd).is_err()); - - // Test unbalanced quotes - assert!(validate_exec_command("echo \"hello").is_err()); - assert!(validate_exec_command("echo 'hello").is_err()); - assert!(validate_exec_command("echo \"hello'").is_err()); - - // Test dangerous patterns with spaces (validation checks for "rm ") - assert!(validate_exec_command("rm -rf /").is_err()); - assert!(validate_exec_command("dd if=/dev/zero").is_err()); - - // Test semicolon (shell command separator) - assert!(validate_exec_command("ls;rm file").is_err()); - assert!(validate_exec_command("echo hello ; rm file").is_err()); + #[cfg(unix)] + fn shell_operators_and_substitutions_follow_openssh_semantics() { + let context = MatchContext::new("example.com".to_string(), None).unwrap(); + assert!( + execute_match_command( + "value=$(printf shell); test \"$value\" = shell && true", + &context, + ) + .unwrap() + ); + assert!(execute_match_command("false || true", &context).unwrap()); + assert!(!execute_match_command("true && false", &context).unwrap()); } #[test] @@ -418,6 +372,22 @@ mod tests { ); } + #[test] + #[cfg(unix)] + fn timeout_kills_descendant_process_group() { + let directory = tempfile::tempdir().unwrap(); + let marker = directory.path().join("descendant-survived"); + let context = MatchContext::new("example.com".to_string(), None).unwrap(); + let command = format!("(sleep 6; printf x > '{}') & sleep 10", marker.display()); + + assert!(!execute_match_command(&command, &context).unwrap()); + std::thread::sleep(Duration::from_secs(2)); + assert!( + !marker.exists(), + "a timed-out Match exec descendant escaped the killed process group" + ); + } + #[test] #[cfg(unix)] fn test_exec_nonexistent_command() { @@ -446,16 +416,9 @@ mod tests { #[test] #[cfg(windows)] - fn test_exec_disabled_on_windows() { + fn test_exec_uses_platform_shell_on_windows() { let context = MatchContext::new("example.com".to_string(), None).unwrap(); - - // exec should be disabled on Windows - let result = execute_match_command("echo test", &context); - - assert!( - result.is_err(), - "exec should be disabled on Windows for security" - ); + assert!(execute_match_command("exit /B 0", &context).unwrap()); } #[test] diff --git a/src/ssh/ssh_config/match_directive/mod.rs b/src/ssh/ssh_config/match_directive/mod.rs index f8ad34a6..367323ab 100644 --- a/src/ssh/ssh_config/match_directive/mod.rs +++ b/src/ssh/ssh_config/match_directive/mod.rs @@ -17,10 +17,11 @@ //! This module handles the Match directive which provides conditional configuration //! based on various criteria like hostname, username, and command execution results. -use anyhow::Result; +use anyhow::{Context, Result}; +use sha1::{Digest, Sha1}; use std::collections::HashMap; -use super::pattern::matches_pattern; +use super::pattern::matches_host_pattern; mod exec; @@ -33,6 +34,8 @@ pub use exec::{execute_match_command, expand_variables, validate_exec_command}; pub enum MatchCondition { /// Match by hostname pattern Host(Vec), + /// Match by the destination name as written on the command line. + OriginalHost(Vec), /// Match by remote username User(Vec), /// Match by local username @@ -41,6 +44,12 @@ pub enum MatchCondition { Exec(String), /// Match all connections (always true) All, + /// Match the explicit final configuration pass. + Final, + /// Match the canonical/final pass without requesting it. + Canonical, + /// Negation of one Match attribute. + Negated(Box), } /// A Match block with its conditions and configuration @@ -67,130 +76,262 @@ impl MatchBlock { /// Check if all conditions match for the given context pub fn matches(&self, context: &MatchContext) -> Result { - // All conditions must match (AND logic) + Ok(self.evaluate(context)?.matched) + } + + /// Evaluate conditions in source order and carry the separately parsed + /// positive-`final` request bit. + pub(crate) fn evaluate(&self, context: &MatchContext) -> Result { + // OpenSSH records a positive `final` attribute while parsing the whole + // Match line, even when an earlier runtime predicate is false. A + // negated `!final` never requests the extra pass. + let requests_final = self + .conditions + .iter() + .any(MatchCondition::requests_final_pass); for condition in &self.conditions { if !condition.matches(context)? { - return Ok(false); + return Ok(MatchEvaluation { + matched: false, + requests_final, + }); } } - Ok(true) + Ok(MatchEvaluation { + matched: true, + requests_final, + }) } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MatchEvaluation { + pub(crate) matched: bool, + pub(crate) requests_final: bool, +} + /// Context for evaluating Match conditions #[derive(Debug, Clone)] pub struct MatchContext { /// The hostname being connected to pub hostname: String, + /// The destination name before applying `HostName`. + pub original_hostname: String, /// The remote username (if specified) pub remote_user: Option, /// The local username pub local_user: String, /// Additional context variables for exec commands pub variables: HashMap, + /// Whether this is OpenSSH's requested final configuration pass. + pub final_pass: bool, + /// Host-aware `-G` preprocessing is the only path authorized to use + /// OpenSSH-compatible shell evaluation for trusted configuration. + exec_policy: ExecPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ExecPolicy { + #[default] + Direct, + TrustedShell, } impl MatchContext { /// Create a new match context pub fn new(hostname: String, remote_user: Option) -> Result { - // Get local username + Self::with_original_hostname(hostname.clone(), hostname, remote_user) + } + + /// Create a context whose effective and original host names differ. + pub fn with_original_hostname( + hostname: String, + original_hostname: String, + remote_user: Option, + ) -> Result { let local_user = whoami::username().unwrap_or_else(|_| "user".to_string()); + let remote_user = remote_user.or_else(|| Some(local_user.clone())); + let local_host = whoami::hostname().unwrap_or_else(|_| "localhost".to_string()); + let local_host_short = local_host + .split('.') + .next() + .unwrap_or(&local_host) + .to_string(); + let local_home = dirs::home_dir() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); let mut variables = HashMap::new(); variables.insert("h".to_string(), hostname.clone()); variables.insert("host".to_string(), hostname.clone()); - variables.insert("l".to_string(), local_user.clone()); + variables.insert("n".to_string(), original_hostname.clone()); + variables.insert("u".to_string(), local_user.clone()); + variables.insert("l".to_string(), local_host.clone()); + variables.insert("L".to_string(), local_host_short); + variables.insert("d".to_string(), local_home); + variables.insert("i".to_string(), local_uid()); + variables.insert("p".to_string(), "22".to_string()); + variables.insert("k".to_string(), original_hostname.clone()); + variables.insert("j".to_string(), String::new()); variables.insert("localuser".to_string(), local_user.clone()); if let Some(ref user) = remote_user { - variables.insert("u".to_string(), user.clone()); + variables.insert("r".to_string(), user.clone()); variables.insert("user".to_string(), user.clone()); } Ok(Self { hostname, + original_hostname, remote_user, local_user, variables, + final_pass: false, + exec_policy: ExecPolicy::Direct, }) } + + pub fn with_final_pass(mut self, final_pass: bool) -> Self { + self.final_pass = final_pass; + self + } + + pub(super) fn with_trusted_shell_exec(mut self) -> Self { + self.exec_policy = ExecPolicy::TrustedShell; + self + } + + pub(super) fn with_config(mut self, config: &super::types::SshHostConfig) -> Self { + let port = config.port.unwrap_or(22).to_string(); + let key_alias = config + .host_key_alias + .clone() + .unwrap_or_else(|| self.original_hostname.clone()); + let jump = config.proxy_jump.clone().unwrap_or_default(); + self.variables.insert("p".to_string(), port.clone()); + self.variables.insert("k".to_string(), key_alias); + self.variables.insert("j".to_string(), jump.clone()); + + let mut digest = Sha1::new(); + digest.update(self.variables["l"].as_bytes()); + digest.update(self.hostname.as_bytes()); + digest.update(port.as_bytes()); + digest.update(self.variables["r"].as_bytes()); + digest.update(jump.as_bytes()); + self.variables.insert( + "C".to_string(), + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ); + self + } +} + +#[cfg(unix)] +fn local_uid() -> String { + // SAFETY: getuid has no arguments, dereferences no pointers, and cannot fail. + unsafe { libc::getuid() }.to_string() +} + +#[cfg(not(unix))] +fn local_uid() -> String { + "0".to_string() } impl MatchCondition { /// Parse a Match directive line into conditions pub fn parse_match_line(line: &str, line_number: usize) -> Result> { let line = line.trim(); - - // Remove "Match" keyword (case-insensitive) - let conditions_str = if line.to_lowercase().starts_with("match ") { - &line[6..] - } else if let Some(pos) = line.find('=') { - // Match=conditions syntax - if line[..pos].trim().to_lowercase() == "match" { - line[pos + 1..].trim() - } else { - anyhow::bail!("Invalid Match directive at line {line_number}"); - } - } else { + let boundary = line + .char_indices() + .find(|(_, ch)| ch.is_whitespace() || *ch == '='); + let Some((index, delimiter)) = boundary else { anyhow::bail!("Invalid Match directive at line {line_number}"); }; - - if conditions_str.is_empty() { + if !line[..index].eq_ignore_ascii_case("match") { + anyhow::bail!("Invalid Match directive at line {line_number}"); + } + let remainder = line[index + delimiter.len_utf8()..].trim_start(); + let tokens = super::value::tokenize(remainder, line_number)?; + if tokens.is_empty() { anyhow::bail!("Match directive requires conditions at line {line_number}"); } - - // Parse conditions let mut conditions = Vec::new(); - let mut parts = conditions_str.split_whitespace(); - - while let Some(keyword) = parts.next() { + let mut position = 0usize; + while position < tokens.len() { + let token = tokens[position].as_str(); + position += 1; + let (keyword, attached_pattern) = token + .split_once('=') + .map_or((token, None), |(keyword, value)| (keyword, Some(value))); + let (negated, keyword) = keyword + .strip_prefix('!') + .map_or((false, keyword), |keyword| (true, keyword)); let keyword_lower = keyword.to_lowercase(); match keyword_lower.as_str() { "host" => { - let patterns = collect_patterns(&mut parts)?; + let patterns = collect_patterns(attached_pattern, &tokens, &mut position); if patterns.is_empty() { anyhow::bail!("Match host requires patterns at line {line_number}"); } - conditions.push(MatchCondition::Host(patterns)); + push_condition(&mut conditions, MatchCondition::Host(patterns), negated); + } + "originalhost" => { + let patterns = collect_patterns(attached_pattern, &tokens, &mut position); + if patterns.is_empty() { + anyhow::bail!("Match originalhost requires patterns at line {line_number}"); + } + push_condition( + &mut conditions, + MatchCondition::OriginalHost(patterns), + negated, + ); } "user" => { - let patterns = collect_patterns(&mut parts)?; + let patterns = collect_patterns(attached_pattern, &tokens, &mut position); if patterns.is_empty() { anyhow::bail!("Match user requires patterns at line {line_number}"); } - conditions.push(MatchCondition::User(patterns)); + push_condition(&mut conditions, MatchCondition::User(patterns), negated); } "localuser" => { - let patterns = collect_patterns(&mut parts)?; + let patterns = collect_patterns(attached_pattern, &tokens, &mut position); if patterns.is_empty() { anyhow::bail!("Match localuser requires patterns at line {line_number}"); } - conditions.push(MatchCondition::LocalUser(patterns)); + push_condition( + &mut conditions, + MatchCondition::LocalUser(patterns), + negated, + ); } "exec" => { - // Exec condition takes the rest of the line as command - let remaining: Vec<&str> = parts.collect(); - if remaining.is_empty() { - anyhow::bail!("Match exec requires a command at line {line_number}"); - } - - // Check if the command is quoted - let exec_part = conditions_str - [conditions_str.to_lowercase().find("exec").unwrap() + 4..] - .trim(); - let command = if exec_part.starts_with('"') && exec_part.ends_with('"') { - // Remove quotes - exec_part[1..exec_part.len() - 1].to_string() - } else { - remaining.join(" ") - }; - - conditions.push(MatchCondition::Exec(command)); - break; // Exec consumes the rest of the line + let command = attached_pattern + .filter(|command| !command.is_empty()) + .map(str::to_string) + .or_else(|| { + let command = tokens.get(position).cloned(); + position += usize::from(command.is_some()); + command + }) + .with_context(|| { + format!("Match exec requires a command at line {line_number}") + })?; + push_condition(&mut conditions, MatchCondition::Exec(command), negated); } "all" => { - conditions.push(MatchCondition::All); + push_condition(&mut conditions, MatchCondition::All, negated); + } + "final" => { + push_condition(&mut conditions, MatchCondition::Final, negated); + } + "canonical" => { + push_condition(&mut conditions, MatchCondition::Canonical, negated); } _ => { anyhow::bail!("Unknown Match condition '{keyword}' at line {line_number}"); @@ -201,6 +342,13 @@ impl MatchCondition { if conditions.is_empty() { anyhow::bail!("Match directive requires at least one condition at line {line_number}"); } + if conditions.iter().any(|condition| { + matches!(condition, MatchCondition::All) + || matches!(condition, MatchCondition::Negated(inner) if matches!(inner.as_ref(), MatchCondition::All)) + }) && (conditions.len() != 1 || !matches!(conditions[0], MatchCondition::All)) + { + anyhow::bail!("Match all must appear alone and non-negated at line {line_number}"); + } Ok(conditions) } @@ -208,70 +356,86 @@ impl MatchCondition { /// Check if this condition matches the given context pub fn matches(&self, context: &MatchContext) -> Result { match self { - MatchCondition::Host(patterns) => { - // Check if hostname matches any of the patterns - for pattern in patterns { - if matches_pattern(&context.hostname, pattern) { - return Ok(true); - } - } - Ok(false) + MatchCondition::Host(patterns) => Ok(matches_host_pattern(&context.hostname, patterns)), + MatchCondition::OriginalHost(patterns) => { + Ok(matches_host_pattern(&context.original_hostname, patterns)) } MatchCondition::User(patterns) => { // Check if remote username matches any of the patterns if let Some(ref user) = context.remote_user { - for pattern in patterns { - if matches_pattern(user, pattern) { - return Ok(true); - } - } + return Ok(matches_host_pattern(user, patterns)); } Ok(false) } MatchCondition::LocalUser(patterns) => { // Check if local username matches any of the patterns - for pattern in patterns { - if matches_pattern(&context.local_user, pattern) { - return Ok(true); - } - } - Ok(false) - } - MatchCondition::Exec(command) => { - // Execute the command and check exit status - execute_match_command(command, context) + Ok(matches_host_pattern(&context.local_user, patterns)) } + MatchCondition::Exec(command) => match context.exec_policy { + ExecPolicy::Direct => exec::execute_match_command_direct(command, context), + ExecPolicy::TrustedShell => execute_match_command(command, context), + }, MatchCondition::All => { // Always matches Ok(true) } + MatchCondition::Final | MatchCondition::Canonical => Ok(context.final_pass), + MatchCondition::Negated(condition) => Ok(!condition.matches(context)?), } } -} -/// Collect patterns until the next keyword -fn collect_patterns(parts: &mut std::str::SplitWhitespace) -> Result> { - let mut patterns = Vec::new(); + pub(crate) fn requests_final_pass(&self) -> bool { + matches!(self, MatchCondition::Final) + } +} - // Peek at upcoming parts to collect patterns - let remaining: Vec<&str> = parts.clone().collect(); +fn push_condition(conditions: &mut Vec, condition: MatchCondition, negated: bool) { + conditions.push(if negated { + MatchCondition::Negated(Box::new(condition)) + } else { + condition + }); +} - for part in remaining { - // Stop if we hit another Match keyword - let lower = part.to_lowercase(); +/// Collect patterns until the next keyword +fn collect_patterns( + attached: Option<&str>, + tokens: &[String], + position: &mut usize, +) -> Vec { + let mut patterns: Vec = attached + .filter(|value| !value.is_empty()) + .map(|value| { + value + .split(',') + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + + while let Some(part) = tokens.get(*position) { + let lower = part + .as_str() + .split_once('=') + .map_or(part.as_str(), |(keyword, _)| keyword) + .trim_start_matches('!') + .to_lowercase(); if matches!( lower.as_str(), - "host" | "user" | "localuser" | "exec" | "all" + "host" | "originalhost" | "user" | "localuser" | "exec" | "all" | "final" | "canonical" ) { break; } - patterns.push(part.to_string()); - // Consume the part from the iterator - parts.next(); + patterns.extend( + part.split(',') + .filter(|pattern| !pattern.is_empty()) + .map(str::to_string), + ); + *position += 1; } - - Ok(patterns) + patterns } #[cfg(test)] @@ -298,6 +462,12 @@ mod tests { assert_eq!(conditions.len(), 1); assert_eq!(conditions[0], MatchCondition::All); + let conditions = MatchCondition::parse_match_line("Match all\t# comment", 1).unwrap(); + assert_eq!(conditions, [MatchCondition::All]); + + let conditions = MatchCondition::parse_match_line("Match exec=\"test x = x\"", 1).unwrap(); + assert_eq!(conditions, [MatchCondition::Exec("test x = x".to_string())]); + // Test exec condition let conditions = MatchCondition::parse_match_line("Match exec \"test -f /tmp/vpn\"", 1).unwrap(); @@ -359,6 +529,13 @@ mod tests { assert!(condition.matches(&context).unwrap()); } + #[test] + fn match_all_must_be_standalone_and_non_negated() { + assert!(MatchCondition::parse_match_line("Match all", 1).is_ok()); + assert!(MatchCondition::parse_match_line("Match all user deploy", 1).is_err()); + assert!(MatchCondition::parse_match_line("Match !all", 1).is_err()); + } + #[test] fn test_match_block() { let mut block = MatchBlock::new(10); @@ -387,20 +564,20 @@ mod tests { #[test] fn test_match_host_with_negation() { - // Test negation pattern: !*.internal.com matches hosts that DON'T match *.internal.com + // A negated pattern vetoes a positive wildcard match. let context_internal = MatchContext::new("web.internal.com".to_string(), Some("testuser".to_string())) .unwrap(); let context_external = MatchContext::new("web.example.com".to_string(), None).unwrap(); // Negation pattern should NOT match internal hosts - let condition = MatchCondition::Host(vec!["!*.internal.com".to_string()]); + let condition = MatchCondition::Host(vec!["*".to_string(), "!*.internal.com".to_string()]); assert!(!condition.matches(&context_internal).unwrap()); // But SHOULD match external hosts assert!(condition.matches(&context_external).unwrap()); // Test wildcard negation - let condition = MatchCondition::Host(vec!["!db*.example.com".to_string()]); + let condition = MatchCondition::Host(vec!["*".to_string(), "!db*.example.com".to_string()]); let context_db = MatchContext::new("db1.example.com".to_string(), None).unwrap(); let context_web = MatchContext::new("web.example.com".to_string(), None).unwrap(); @@ -408,7 +585,8 @@ mod tests { assert!(condition.matches(&context_web).unwrap()); // Test exact negation - let condition = MatchCondition::Host(vec!["!production.example.com".to_string()]); + let condition = + MatchCondition::Host(vec!["*".to_string(), "!production.example.com".to_string()]); let context_prod = MatchContext::new("production.example.com".to_string(), None).unwrap(); let context_staging = MatchContext::new("staging.example.com".to_string(), None).unwrap(); @@ -443,7 +621,8 @@ mod tests { } // Test negation - let condition = MatchCondition::LocalUser(vec!["!nonexistent*".to_string()]); + let condition = + MatchCondition::LocalUser(vec!["*".to_string(), "!nonexistent*".to_string()]); assert!(condition.matches(&context).unwrap()); } @@ -496,4 +675,46 @@ mod tests { assert!(block2.matches(&context_match).unwrap()); assert!(!block2.matches(&context_nomatch).unwrap()); } + + #[test] + #[cfg(unix)] + fn trusted_shell_policy_is_explicit_and_negated_exec_is_preserved() { + let direct = MatchContext::new("example.com".to_string(), None).unwrap(); + let shell = direct.clone().with_trusted_shell_exec(); + let shell_expression = MatchCondition::Exec("false || true".to_string()); + assert!(shell_expression.matches(&direct).is_err()); + assert!(shell_expression.matches(&shell).unwrap()); + + let negated_false = + MatchCondition::Negated(Box::new(MatchCondition::Exec("false".to_string()))); + let negated_true = + MatchCondition::Negated(Box::new(MatchCondition::Exec("true".to_string()))); + assert!(negated_false.matches(&shell).unwrap()); + assert!(!negated_true.matches(&shell).unwrap()); + } + + #[test] + fn only_positive_final_requests_the_second_pass() { + let context = MatchContext::new("example.com".to_string(), None).unwrap(); + let positive_after_false = MatchBlock { + conditions: vec![ + MatchCondition::Host(vec!["no-match".to_string()]), + MatchCondition::Final, + ], + config: super::super::types::SshHostConfig::default(), + line_number: 1, + }; + let evaluation = positive_after_false.evaluate(&context).unwrap(); + assert!(!evaluation.matched); + assert!(evaluation.requests_final); + + let negated = MatchBlock { + conditions: vec![MatchCondition::Negated(Box::new(MatchCondition::Final))], + config: super::super::types::SshHostConfig::default(), + line_number: 1, + }; + let evaluation = negated.evaluate(&context).unwrap(); + assert!(evaluation.matched); + assert!(!evaluation.requests_final); + } } diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index 1132d8d9..deda3c02 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -25,6 +25,9 @@ use std::{ // Internal modules pub(crate) mod diagnostic; +mod dump; +#[cfg(test)] +mod dump_tests; mod env_cache; mod include; #[cfg(test)] @@ -42,8 +45,12 @@ mod security; #[cfg(test)] mod security_fix_tests; mod types; +mod value; + +pub(crate) use value::encode as encode_config_value; // Re-export public types +pub use dump::render_resolved_config; pub use ip_qos::{IpQosParseError, IpQosPolicy, IpQosValue}; pub use rekey::{ RUSSH_REKEY_BYTE_CEILING, RekeyDataLimit, RekeyLimit, RekeyLimitParseError, RekeyTimeLimit, @@ -91,22 +98,19 @@ impl SshConfig { /// Load SSH configuration from the default locations pub async fn load_default() -> Result { - // Try user-specific SSH config first + let mut config = Self::new(); if let Some(home_dir) = dirs::home_dir() { let user_config = home_dir.join(".ssh").join("config"); - if tokio::fs::try_exists(&user_config).await.unwrap_or(false) { - return Self::load_from_file(&user_config).await; + if path_exists(&user_config).await? { + config.append(Self::load_from_file(&user_config).await?); } } - // Try system-wide SSH config let system_config = Path::new("/etc/ssh/ssh_config"); - if tokio::fs::try_exists(system_config).await.unwrap_or(false) { - return Self::load_from_file(system_config).await; + if path_exists(system_config).await? { + config.append(Self::load_from_file(system_config).await?); } - - // Return empty config if no files found - Ok(Self::new()) + Ok(config) } /// Load SSH configuration from the default locations with caching @@ -149,11 +153,230 @@ impl SshConfig { }) } + /// Load an explicit file for resolved-config output. + /// + /// This intentionally enables OpenSSH-compatible `Match exec` shell + /// evaluation for trusted configuration. General library callers should + /// use [`Self::load_from_file`] instead. + /// + /// The lower-level host-aware loader is deliberately crate-private: + /// + /// ```compile_fail + /// use bssh::ssh::ssh_config::SshConfig; + /// + /// # async fn check() -> anyhow::Result<()> { + /// let _ = SshConfig::load_from_file_for_host_with_options("config", "host", &[]).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn load_explicit_for_config_dump_with_options>( + path: P, + hostname: &str, + options: &[String], + ) -> Result { + Self::load_from_file_for_host_with_options(path, hostname, options).await + } + + /// Load default user and system files for resolved-config output. + /// + /// This is the explicit public boundary that enables trusted `Match exec` + /// shell evaluation while producing `ssh -G` compatible output. + pub async fn load_default_for_config_dump_with_options( + hostname: &str, + options: &[String], + ) -> Result { + Self::load_default_for_host_with_options(hostname, options).await + } + + /// Load a file with host-dependent Include tokens resolved for `hostname`. + #[cfg(test)] + pub(crate) async fn load_from_file_for_host>( + path: P, + hostname: &str, + ) -> Result { + Self::load_from_file_for_host_with_options(path, hostname, &[]).await + } + + pub(crate) async fn load_from_file_for_host_with_options>( + path: P, + hostname: &str, + options: &[String], + ) -> Result { + let path = path.as_ref(); + let anchor = dirs::home_dir() + .map(|home| home.join(".ssh")) + .or_else(|| path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from("/")); + let mut config = Self::new(); + config.apply_cli_options(options)?; + let initial = resolver::find_host_config_first_pass(&config.hosts, hostname); + config + .append_file_for_host(path, hostname, &initial, anchor, false) + .await?; + Ok(config) + } + + /// Load user and system configuration, in OpenSSH precedence order. + async fn load_default_for_host_with_options( + hostname: &str, + options: &[String], + ) -> Result { + let mut config = Self::new(); + config.apply_cli_options(options)?; + let initial = resolver::find_host_config_first_pass(&config.hosts, hostname); + let user_source = if let Some(home_dir) = dirs::home_dir() { + let path = home_dir.join(".ssh").join("config"); + path_exists(&path) + .await? + .then_some((path, home_dir.join(".ssh"))) + } else { + None + }; + if let Some((path, anchor)) = &user_source { + config + .append_file_for_host_pass( + path, + hostname, + &initial, + anchor.clone(), + true, + false, + true, + ) + .await?; + } + let system_config = Path::new("/etc/ssh/ssh_config"); + let has_system_config = path_exists(system_config).await?; + if has_system_config { + let accumulated = resolver::find_host_config_first_pass(&config.hosts, hostname); + config + .append_file_for_host_pass( + system_config, + hostname, + &accumulated, + PathBuf::from("/etc/ssh"), + false, + false, + false, + ) + .await?; + } + + if resolver::requests_final_pass_for_host(&config.hosts, hostname) { + let first_pass = config.find_host_config(hostname); + if let Some((path, anchor)) = &user_source { + config + .append_file_for_host_pass( + path, + hostname, + &first_pass, + anchor.clone(), + true, + true, + true, + ) + .await?; + } + if has_system_config { + let accumulated = config.find_host_config(hostname); + config + .append_file_for_host_pass( + system_config, + hostname, + &accumulated, + PathBuf::from("/etc/ssh"), + false, + true, + false, + ) + .await?; + } + } + Ok(config) + } + + async fn append_file_for_host( + &mut self, + path: &Path, + hostname: &str, + initial_config: &SshHostConfig, + anchor: PathBuf, + check_top_permissions: bool, + ) -> Result<()> { + self.append_file_for_host_pass( + path, + hostname, + initial_config, + anchor.clone(), + check_top_permissions, + false, + true, + ) + .await?; + if resolver::requests_final_pass_for_host(&self.hosts, hostname) { + let preliminary = self.find_host_config(hostname); + self.append_file_for_host_pass( + path, + hostname, + &preliminary, + anchor, + check_top_permissions, + true, + true, + ) + .await?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn append_file_for_host_pass( + &mut self, + path: &Path, + hostname: &str, + initial_config: &SshHostConfig, + anchor: PathBuf, + check_top_permissions: bool, + final_pass: bool, + allow_tilde: bool, + ) -> Result<()> { + let content = include::read_config_file(path, check_top_permissions, false) + .await? + .context("Top-level SSH config disappeared after selection")?; + let hosts = parser::parse_from_file_for_host_pass_at_with_diagnostics( + path, + &content, + hostname, + initial_config, + anchor, + final_pass, + allow_tilde, + &mut self.reported_diagnostics, + ) + .await?; + self.hosts.extend(hosts); + Ok(()) + } + + fn append(&mut self, other: Self) { + self.hosts.extend(other.hosts); + self.reported_diagnostics.extend(other.reported_diagnostics); + } + /// Find configuration for a specific hostname pub fn find_host_config(&self, hostname: &str) -> SshHostConfig { resolver::find_host_config(&self.hosts, hostname) } + /// Find configuration for a destination with an explicit remote user. + pub fn find_host_config_with_user( + &self, + hostname: &str, + remote_user: Option<&str>, + ) -> SshHostConfig { + resolver::find_host_config_with_user(&self.hosts, hostname, remote_user) + } + /// Get the effective hostname (resolves HostName directive) pub fn get_effective_hostname(&self, hostname: &str) -> String { resolver::get_effective_hostname(&self.hosts, hostname) @@ -324,11 +547,693 @@ impl SshConfig { } } +async fn path_exists(path: &Path) -> Result { + match tokio::fs::metadata(path).await { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| { + format!( + "Failed to inspect SSH config file: {}", + diagnostic::escape_path(path) + ) + }), + } +} + #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; + fn write_config(path: impl AsRef, content: &str) { + let path = path.as_ref(); + std::fs::write(path, content).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + } + + #[tokio::test] + async fn later_config_files_use_earlier_effective_include_context() { + let temp_dir = TempDir::new().unwrap(); + let user_anchor = temp_dir.path().join("user"); + let system_anchor = temp_dir.path().join("system"); + std::fs::create_dir_all(&user_anchor).unwrap(); + std::fs::create_dir_all(&system_anchor).unwrap(); + let user = user_anchor.join("config"); + let system = system_anchor.join("ssh_config"); + write_config( + &user, + "Host alias\n HostName effective.example\n User selected\n", + ); + write_config( + &system, + "Include %h.conf\nMatch user selected\n Include selected.conf\n", + ); + write_config(system_anchor.join("effective.example.conf"), "Port 2201\n"); + write_config( + system_anchor.join("selected.conf"), + "ServerAliveInterval 9\n", + ); + + let mut config = SshConfig::new(); + let initial = SshHostConfig::default(); + config + .append_file_for_host(&user, "alias", &initial, user_anchor, false) + .await + .unwrap(); + let accumulated = config.find_host_config("alias"); + config + .append_file_for_host(&system, "alias", &accumulated, system_anchor, false) + .await + .unwrap(); + + let resolved = config.find_host_config("alias"); + assert_eq!(resolved.hostname.as_deref(), Some("effective.example")); + assert_eq!(resolved.user.as_deref(), Some("selected")); + assert_eq!(resolved.port, Some(2201)); + assert_eq!(resolved.server_alive_interval, Some(9)); + } + + #[cfg(unix)] + #[tokio::test] + async fn file_match_exec_runs_once_per_pass_and_is_cached_for_resolution() { + let temp_dir = TempDir::new().unwrap(); + let marker = temp_dir.path().join("match-exec-marker"); + let config_path = temp_dir.path().join("config"); + write_config( + &config_path, + &format!( + "Match exec \"printf x >> {}\"\n Port 2201\n", + marker.display() + ), + ); + + let config = SshConfig::load_from_file_for_host(&config_path, "target") + .await + .unwrap(); + assert_eq!(config.find_host_config("target").port, Some(2201)); + assert_eq!(config.find_host_config("target").port, Some(2201)); + assert_eq!(std::fs::read_to_string(marker).unwrap(), "x"); + } + + #[cfg(unix)] + #[tokio::test] + async fn cli_port_is_available_to_match_exec_and_include_tokens() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config"); + write_config( + &config_path, + &format!( + "Match exec=\"test %p = 2202\"\n Include {}/%p.conf\n", + temp_dir.path().display() + ), + ); + write_config(temp_dir.path().join("2202.conf"), "ServerAliveInterval 9\n"); + + let config = SshConfig::load_from_file_for_host_with_options( + &config_path, + "target", + &["Port=2202".to_string()], + ) + .await + .unwrap(); + let resolved = config.find_host_config("target"); + assert_eq!(resolved.port, Some(2202)); + assert_eq!(resolved.server_alive_interval, Some(9)); + } + + #[cfg(unix)] + #[tokio::test] + async fn cli_canonicalization_does_not_fix_hostname_before_explicit_file() { + use sha1::{Digest as _, Sha1}; + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config"); + let local_host = whoami::hostname().unwrap_or_else(|_| "localhost".to_string()); + let mut digest = Sha1::new(); + for value in [ + local_host.as_str(), + "effective.example", + "2207", + "deploy", + "cli-jump", + ] { + digest.update(value.as_bytes()); + } + let connection_hash = digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + write_config( + &config_path, + &format!( + concat!( + "Host *\n", + " HostName effective.example\n", + "Match exec=\"test %h = effective.example && test %p = 2207 && ", + "test %k = cli-key && test %j = cli-jump && test %C = {}\"\n", + " Include {}/%h-%p-%k-%j-%C.conf\n" + ), + connection_hash, + temp_dir.path().display() + ), + ); + write_config( + temp_dir.path().join(format!( + "effective.example-2207-cli-key-cli-jump-{connection_hash}.conf" + )), + "ServerAliveInterval 17\n", + ); + + let config = SshConfig::load_explicit_for_config_dump_with_options( + &config_path, + "alias", + &[ + "CanonicalizeHostname=yes".to_string(), + "User=deploy".to_string(), + "Port=2207".to_string(), + "HostKeyAlias=cli-key".to_string(), + "ProxyJump=cli-jump".to_string(), + ], + ) + .await + .unwrap(); + let resolved = config.find_host_config("alias"); + assert_eq!(resolved.hostname.as_deref(), Some("effective.example")); + assert_eq!(resolved.user.as_deref(), Some("deploy")); + assert_eq!(resolved.port, Some(2207)); + assert_eq!(resolved.host_key_alias.as_deref(), Some("cli-key")); + assert_eq!(resolved.proxy_jump.as_deref(), Some("cli-jump")); + assert_eq!(resolved.server_alive_interval, Some(17)); + } + + #[cfg(unix)] + #[tokio::test] + async fn later_source_match_and_include_use_all_prior_source_tokens_once() { + let temp_dir = TempDir::new().unwrap(); + let user_anchor = temp_dir.path().join("user"); + let system_anchor = temp_dir.path().join("system"); + std::fs::create_dir_all(&user_anchor).unwrap(); + std::fs::create_dir_all(&system_anchor).unwrap(); + let user = user_anchor.join("config"); + let system = system_anchor.join("ssh_config"); + let marker = temp_dir.path().join("prior-source-exec"); + write_config( + &user, + concat!( + "Host alias\n", + " HostName effective.example\n", + " User deploy\n", + " Port 2203\n", + " HostKeyAlias prior-key\n", + " ProxyJump prior-jump\n" + ), + ); + write_config( + &system, + &format!( + concat!( + "Match exec=\"printf x >> '{}'; test %p = 2203 && ", + "test %k = prior-key && test %j = prior-jump\"\n", + " Include %p-%k-%j.conf\n" + ), + marker.display() + ), + ); + write_config( + system_anchor.join("2203-prior-key-prior-jump.conf"), + "ServerAliveInterval 11\n", + ); + + let mut config = SshConfig::new(); + let initial = SshHostConfig::default(); + config + .append_file_for_host(&user, "alias", &initial, user_anchor, false) + .await + .unwrap(); + let accumulated = config.find_host_config("alias"); + config + .append_file_for_host_pass( + &system, + "alias", + &accumulated, + system_anchor, + false, + false, + false, + ) + .await + .unwrap(); + + assert_eq!( + config.find_host_config("alias").server_alive_interval, + Some(11) + ); + assert_eq!( + config.find_host_config("alias").server_alive_interval, + Some(11) + ); + assert_eq!(std::fs::read_to_string(marker).unwrap(), "x"); + } + + #[cfg(unix)] + #[tokio::test] + async fn final_pass_match_and_include_use_first_pass_tokens_and_hash_once() { + use sha1::{Digest as _, Sha1}; + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config"); + let marker = temp_dir.path().join("final-pass-exec"); + let local_host = whoami::hostname().unwrap_or_else(|_| "localhost".to_string()); + let mut digest = Sha1::new(); + for value in [ + local_host.as_str(), + "effective.example", + "2204", + "deploy", + "jump.example", + ] { + digest.update(value.as_bytes()); + } + let connection_hash = digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + write_config( + &config_path, + &format!( + concat!( + "Host *\n", + " HostName effective.example\n", + " User deploy\n", + " Port 2204\n", + " HostKeyAlias final-key\n", + " ProxyJump jump.example\n", + "Match final exec=\"printf x >> '{}'; test %p = 2204 && ", + "test %k = final-key && test %j = jump.example && test %C = {}\"\n", + " Include {}/%p-%k-%j-%C.conf\n" + ), + marker.display(), + connection_hash, + temp_dir.path().display() + ), + ); + write_config( + temp_dir.path().join(format!( + "2204-final-key-jump.example-{connection_hash}.conf" + )), + "ServerAliveInterval 13\n", + ); + + let config = SshConfig::load_from_file_for_host(&config_path, "alias") + .await + .unwrap(); + let resolved = config.find_host_config("alias"); + assert_eq!(resolved.server_alive_interval, Some(13)); + assert_eq!( + config.find_host_config("alias").server_alive_interval, + Some(13) + ); + assert_eq!(std::fs::read_to_string(marker).unwrap(), "x"); + } + + #[cfg(unix)] + #[tokio::test] + async fn outer_match_exec_guards_include_without_reexecution() { + let temp_dir = TempDir::new().unwrap(); + let marker = temp_dir.path().join("outer-marker"); + let config_path = temp_dir.path().join("config"); + let included_path = temp_dir.path().join("included.conf"); + write_config(&included_path, "User included\n"); + write_config( + &config_path, + &format!( + "Match exec=\"printf x >> '{}'\"\n Include {}\n", + marker.display(), + included_path.display() + ), + ); + + let config = SshConfig::load_from_file_for_host(&config_path, "target") + .await + .unwrap(); + assert_eq!( + config.find_host_config("target").user.as_deref(), + Some("included") + ); + assert_eq!( + config.find_host_config("target").user.as_deref(), + Some("included") + ); + assert_eq!(std::fs::read_to_string(marker).unwrap(), "x"); + } + + #[cfg(unix)] + #[tokio::test] + async fn positive_final_requests_pass_independently_of_exec_order() { + let temp_dir = TempDir::new().unwrap(); + let before = temp_dir.path().join("before-final"); + let after = temp_dir.path().join("after-final"); + let false_before = temp_dir.path().join("false-before-final"); + let config_path = temp_dir.path().join("config"); + write_config( + &config_path, + &format!( + concat!( + "Match exec=\"printf a >> '{}'\" final\n Port 2201\n", + "Match final exec=\"printf b >> '{}'\"\n User final-user\n", + "Match exec=\"printf c >> '{}'; false\" final\n", + " ServerAliveInterval 9\n" + ), + before.display(), + after.display(), + false_before.display() + ), + ); + + let config = SshConfig::load_from_file_for_host(&config_path, "target") + .await + .unwrap(); + let resolved = config.find_host_config("target"); + assert_eq!(resolved.port, Some(2201)); + assert_eq!(resolved.user.as_deref(), Some("final-user")); + assert_eq!(resolved.server_alive_interval, None); + assert_eq!(std::fs::read_to_string(before).unwrap(), "aa"); + assert_eq!(std::fs::read_to_string(after).unwrap(), "b"); + assert_eq!(std::fs::read_to_string(false_before).unwrap(), "cc"); + } + + #[cfg(unix)] + #[tokio::test] + async fn generic_runtime_does_not_inherit_config_dump_shell_policy() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config"); + write_config( + &config_path, + "Match exec=\"false || true\"\n User shell-only\n", + ); + + let config = SshConfig::load_from_file(&config_path).await.unwrap(); + assert_eq!(config.find_host_config("target").user, None); + } + + #[tokio::test] + async fn later_sources_expand_raw_hostname_percent_h_once_for_includes() { + let temp_dir = TempDir::new().unwrap(); + let user_anchor = temp_dir.path().join("user"); + let system_anchor = temp_dir.path().join("system"); + std::fs::create_dir_all(&user_anchor).unwrap(); + std::fs::create_dir_all(&system_anchor).unwrap(); + let user = user_anchor.join("config"); + let system = system_anchor.join("ssh_config"); + write_config(&user, "Host alias\n HostName %h.example\n"); + write_config(&system, "Include %h.conf\n"); + write_config(system_anchor.join("alias.example.conf"), "Port 2201\n"); + + let mut config = SshConfig::new(); + let initial = SshHostConfig::default(); + config + .append_file_for_host(&user, "alias", &initial, user_anchor, false) + .await + .unwrap(); + let accumulated = config.find_host_config("alias"); + config + .append_file_for_host(&system, "alias", &accumulated, system_anchor, false) + .await + .unwrap(); + + assert_eq!( + resolver::get_effective_hostname(&config.hosts, "alias"), + "alias.example" + ); + assert_eq!(config.find_host_config("alias").port, Some(2201)); + } + + #[tokio::test] + async fn final_pass_keeps_entry_hostname_and_preserves_first_include_values() { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().unwrap(); + let main = temp_dir.path().join("config"); + let first_include = temp_dir.path().join("alias.conf"); + let final_include = temp_dir.path().join("final.example.conf"); + let content = "Host alias\n Include %h.conf\nMatch final\n HostName final.example\n"; + std::fs::write(&main, content).unwrap(); + std::fs::write(&first_include, "User first\n").unwrap(); + std::fs::write(&final_include, "User second\nPort 2202\n").unwrap(); + #[cfg(unix)] + std::fs::set_permissions(&first_include, std::fs::Permissions::from_mode(0o600)).unwrap(); + #[cfg(unix)] + std::fs::set_permissions(&final_include, std::fs::Permissions::from_mode(0o600)).unwrap(); + let mut diagnostics = HashSet::new(); + + let hosts = parser::parse_from_file_for_host_at_with_diagnostics( + &main, + content, + "alias", + None, + None, + temp_dir.path().to_path_buf(), + &mut diagnostics, + ) + .await + .unwrap(); + let resolved = resolver::find_host_config(&hosts, "alias"); + + assert_eq!(resolved.user.as_deref(), Some("first")); + assert_eq!(resolved.hostname.as_deref(), Some("alias")); + assert_eq!(resolved.port, None, "{hosts:#?}"); + } + + #[test] + fn canonical_only_activates_when_nonnegated_final_requests_second_pass() { + let without_final = SshConfig::parse("Match canonical\n Port 2201\n").unwrap(); + assert_eq!(without_final.find_host_config("host").port, None); + + let with_final = + SshConfig::parse("Match canonical\n Port 2201\nMatch final\n User final-user\n") + .unwrap(); + let resolved = with_final.find_host_config("host"); + assert_eq!(resolved.port, Some(2201)); + assert_eq!(resolved.user.as_deref(), Some("final-user")); + } + + #[tokio::test] + async fn final_inside_inactive_include_scope_does_not_request_second_pass() { + let directory = TempDir::new().unwrap(); + let main = directory.path().join("config"); + let child = directory.path().join("child.conf"); + write_config(&child, "Match final\n User final-user\n"); + let content = + "Host other\n Include child.conf\nHost *\nMatch canonical\n Port 2201\n"; + write_config(&main, content); + let mut diagnostics = HashSet::new(); + + let hosts = parser::parse_from_file_for_host_at_with_diagnostics( + &main, + content, + "alias", + None, + None, + directory.path().to_path_buf(), + &mut diagnostics, + ) + .await + .unwrap(); + + assert_eq!(resolver::find_host_config(&hosts, "alias").port, None); + } + + #[test] + fn canonicalization_value_requests_canonical_match_pass_without_dns() { + let config = + SshConfig::parse("CanonicalizeHostname yes\nMatch canonical\n Port 2201\n").unwrap(); + let resolved = config.find_host_config("alias"); + assert_eq!(resolved.port, Some(2201)); + assert_eq!( + resolved + .unimplemented_options + .get("canonicalizehostname") + .map(Vec::as_slice), + Some(["yes".to_string()].as_slice()) + ); + } + + #[tokio::test] + async fn all_first_pass_sources_precede_every_final_pass_source() { + let directory = TempDir::new().unwrap(); + let user = directory.path().join("user.conf"); + let system = directory.path().join("system.conf"); + write_config(&user, "Match final\n Port 2201\n"); + write_config(&system, "Host *\n Port 2202\n"); + + let mut config = SshConfig::new(); + let initial = SshHostConfig::default(); + config + .append_file_for_host_pass( + &user, + "alias", + &initial, + directory.path().to_path_buf(), + false, + false, + true, + ) + .await + .unwrap(); + let after_user = config.find_host_config("alias"); + config + .append_file_for_host_pass( + &system, + "alias", + &after_user, + directory.path().to_path_buf(), + false, + false, + false, + ) + .await + .unwrap(); + let first = config.find_host_config("alias"); + config + .append_file_for_host_pass( + &user, + "alias", + &first, + directory.path().to_path_buf(), + false, + true, + true, + ) + .await + .unwrap(); + + assert_eq!(config.find_host_config("alias").port, Some(2202)); + } + + #[cfg(unix)] + #[tokio::test] + async fn system_first_pass_can_obtain_hostname_after_user_requests_final() { + let directory = TempDir::new().unwrap(); + let user = directory.path().join("user.conf"); + let system = directory.path().join("system.conf"); + write_config(&user, "Match final\n User final-user\n"); + write_config( + &system, + &format!( + concat!( + "Host *\n", + " HostName effective.example\n", + "Match exec=\"test %h = effective.example\"\n", + " Include {}/%h.conf\n" + ), + directory.path().display() + ), + ); + write_config( + directory.path().join("effective.example.conf"), + "Port 2205\n", + ); + + let mut config = SshConfig::new(); + let initial = SshHostConfig::default(); + config + .append_file_for_host_pass( + &user, + "alias", + &initial, + directory.path().to_path_buf(), + false, + false, + true, + ) + .await + .unwrap(); + + let boundary = resolver::find_host_config_first_pass(&config.hosts, "alias"); + assert_eq!(boundary.hostname, None); + assert_eq!( + config.find_host_config("alias").hostname.as_deref(), + Some("alias") + ); + config + .append_file_for_host_pass( + &system, + "alias", + &boundary, + directory.path().to_path_buf(), + false, + false, + false, + ) + .await + .unwrap(); + + let first_pass = resolver::find_host_config_first_pass(&config.hosts, "alias"); + assert_eq!(first_pass.hostname.as_deref(), Some("effective.example")); + assert_eq!(first_pass.port, Some(2205)); + config + .append_file_for_host_pass( + &user, + "alias", + &first_pass, + directory.path().to_path_buf(), + false, + true, + true, + ) + .await + .unwrap(); + let after_final_user = config.find_host_config("alias"); + config + .append_file_for_host_pass( + &system, + "alias", + &after_final_user, + directory.path().to_path_buf(), + false, + true, + false, + ) + .await + .unwrap(); + + let resolved = config.find_host_config("alias"); + assert_eq!(resolved.hostname.as_deref(), Some("effective.example")); + assert_eq!(resolved.user.as_deref(), Some("final-user")); + assert_eq!(resolved.port, Some(2205)); + } + + #[test] + fn final_pass_refreshes_user_before_each_match_but_keeps_entry_hostname() { + let config = SshConfig::parse( + "Match final\n User final-user\n HostName final.example\nMatch user final-user host final.example\n Port 2202\n", + ) + .unwrap(); + let resolved = config.find_host_config("alias"); + assert_eq!(resolved.user.as_deref(), Some("final-user")); + assert_eq!(resolved.hostname.as_deref(), Some("alias")); + assert_eq!(resolved.port, None); + } + + #[test] + fn final_replay_does_not_duplicate_identical_additive_values() { + let config = SshConfig::parse( + "IdentityFile /tmp/key\nSendEnv LANG\nLocalForward 8080 localhost:80\nMatch final\n User final\n", + ) + .unwrap(); + let resolved = config.find_host_config("host"); + assert_eq!(resolved.identity_files.len(), 1); + assert_eq!(resolved.send_env, ["LANG"]); + assert_eq!(resolved.local_forward.len(), 1); + } + #[test] fn test_parse_basic_host_config() { let config_content = r#" @@ -876,24 +1781,26 @@ Host test Some("lb-1.example.com".to_string()) ); - // Test BindInterface - should reject shell metacharacters + // BindInterface is passed as a structured socket option, not a shell + // command. OpenSSH retains arbitrary non-empty values in `-G`. let config_content = r#" Host test BindInterface "eth0;rm -rf /" "#; - assert!( - SshConfig::parse(config_content).is_err(), - "Should reject shell metacharacters in BindInterface" + let config = SshConfig::parse(config_content).unwrap(); + assert_eq!( + config.hosts[0].bind_interface.as_deref(), + Some("eth0;rm -rf /") ); - // Test BindInterface - should reject too long names let config_content = r#" Host test BindInterface "verylonginterfacename123456789" "#; - assert!( - SshConfig::parse(config_content).is_err(), - "Should reject too long interface names" + let config = SshConfig::parse(config_content).unwrap(); + assert_eq!( + config.hosts[0].bind_interface.as_deref(), + Some("verylonginterfacename123456789") ); // Test BindInterface - should accept valid interface names diff --git a/src/ssh/ssh_config/parser/core.rs b/src/ssh/ssh_config/parser/core.rs index 6c385701..32317e5a 100644 --- a/src/ssh/ssh_config/parser/core.rs +++ b/src/ssh/ssh_config/parser/core.rs @@ -17,10 +17,12 @@ //! This module contains the main parsing logic for SSH configurations, //! including the 2-pass parsing strategy for Include and Match directives. -use crate::ssh::ssh_config::include::{IncludedFile, resolve_includes}; +use crate::ssh::ssh_config::include::{ + IncludedFile, resolve_includes, resolve_includes_for_host_at_pass, +}; use crate::ssh::ssh_config::match_directive::{MatchBlock, MatchCondition}; use crate::ssh::ssh_config::resolver::merge_host_config; -use crate::ssh::ssh_config::types::{ConfigBlock, SshHostConfig}; +use crate::ssh::ssh_config::types::{ConfigBlock, ConfigPass, SshHostConfig}; use anyhow::{Context, Result}; use std::collections::HashSet; use std::path::Path; @@ -42,7 +44,7 @@ pub(crate) fn parse_with_diagnostics( ) -> Result> { // For synchronous parsing without file path, we can't resolve includes // This maintains backward compatibility for tests and simple usage - parse_without_includes(content, reported_diagnostics) + parse_without_includes(content, reported_diagnostics).map(add_final_pass_configs) } /// Parse SSH configuration from a file with full Include support. @@ -55,7 +57,116 @@ pub(crate) async fn parse_from_file_with_diagnostics( let included_files = resolve_includes(path, content) .await .with_context(|| format!("Failed to resolve includes for {}", escape_path(path)))?; - parse_included_files(&included_files, reported_diagnostics) + parse_included_files(&included_files, reported_diagnostics).map(add_final_pass_configs) +} + +fn add_final_pass_configs(mut configs: Vec) -> Vec { + let canonicalization_present = configs.iter().any(|config| { + config + .unimplemented_options + .get("canonicalizehostname") + .and_then(|values| values.first()) + .is_some_and(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "yes" | "true" | "always" + ) + }) + }); + if !super::super::resolver::requests_final_pass(&configs) && !canonicalization_present { + return configs; + } + let mut final_pass = configs.clone(); + for config in &mut final_pass { + config.pass = ConfigPass::FinalOnly; + } + configs.extend(final_pass); + configs +} + +/// Parse a config file while resolving host-dependent Include paths. +#[cfg(test)] +pub(crate) async fn parse_from_file_for_host_at_with_diagnostics( + path: &Path, + content: &str, + hostname: &str, + initial_hostname: Option<&str>, + initial_user: Option<&str>, + anchor: std::path::PathBuf, + reported_diagnostics: &mut HashSet, +) -> Result> { + let initial_config = SshHostConfig { + hostname: initial_hostname.map(str::to_string), + user: initial_user.map(str::to_string), + ..Default::default() + }; + let first_pass = parse_from_file_for_host_pass_at_with_diagnostics( + path, + content, + hostname, + &initial_config, + anchor.clone(), + false, + true, + reported_diagnostics, + ) + .await?; + if !super::super::resolver::requests_final_pass_for_host(&first_pass, hostname) { + return Ok(first_pass); + } + let preliminary = super::super::resolver::find_host_config(&first_pass, hostname); + let final_pass = parse_from_file_for_host_pass_at_with_diagnostics( + path, + content, + hostname, + &preliminary, + anchor, + true, + true, + reported_diagnostics, + ) + .await?; + let mut combined = first_pass; + combined.extend(final_pass); + Ok(combined) +} + +/// Parse one OpenSSH configuration pass for a single top-level source. +/// +/// Callers that combine user and system files must run every source for pass +/// one before invoking this function for the final pass on any source. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn parse_from_file_for_host_pass_at_with_diagnostics( + path: &Path, + content: &str, + hostname: &str, + initial_config: &SshHostConfig, + anchor: std::path::PathBuf, + final_pass: bool, + allow_tilde: bool, + reported_diagnostics: &mut HashSet, +) -> Result> { + let included_files = resolve_includes_for_host_at_pass( + path, + content, + Some(hostname), + anchor, + initial_config, + final_pass, + allow_tilde, + ) + .await + .with_context(|| { + let pass = if final_pass { "final-pass " } else { "" }; + format!("Failed to resolve {pass}includes for {}", escape_path(path)) + })?; + let mut configs = parse_included_files(&included_files, reported_diagnostics)?; + if final_pass { + for config in &mut configs { + config.pass = ConfigPass::FinalOnly; + } + } + Ok(configs) } /// Parse SSH configuration content without Include resolution @@ -67,7 +178,7 @@ pub(super) fn parse_without_includes( content .lines() .enumerate() - .map(|(index, line)| (None, index + 1, line)), + .map(|(index, line)| (None, index + 1, line, &[][..], None, None, None)), reported_diagnostics, ) } @@ -102,7 +213,7 @@ pub(crate) fn parse_cli_options( anyhow::bail!("-o option #{option_number} exceeds {MAX_LINE_LENGTH} bytes"); } - let (keyword, args) = parse_config_line(option, option_number, MAX_VALUE_LENGTH) + let (keyword, args) = parse_option_line(option, option_number, MAX_VALUE_LENGTH) .with_context(|| format!("Invalid -o option #{option_number}"))?; if keyword.is_empty() { anyhow::bail!("-o option #{option_number} has no keyword"); @@ -129,8 +240,15 @@ fn parse_included_files( file.content.lines().enumerate().map(move |(index, line)| { ( Some(file.path.as_path()), - file.source_line_start + index, + file.source_lines.get(index).copied().unwrap_or(index + 1), line, + file.scope_guards.as_slice(), + file.precomputed_scope_active, + file.precomputed_matches.get(index).copied().flatten(), + file.precomputed_final_requests + .get(index) + .copied() + .flatten(), ) }) }), @@ -139,7 +257,17 @@ fn parse_included_files( } fn parse_lines<'a>( - lines: impl IntoIterator, usize, &'a str)>, + lines: impl IntoIterator< + Item = ( + Option<&'a Path>, + usize, + &'a str, + &'a [String], + Option, + Option, + Option, + ), + >, reported_diagnostics: &mut HashSet, ) -> Result> { // Security: Set reasonable limits to prevent DoS attacks @@ -150,7 +278,16 @@ fn parse_lines<'a>( let mut current_config: Option = None; let mut current_match: Option = None; let mut in_match_block = false; - for (source_path, line_number, line) in lines { + for ( + source_path, + line_number, + line, + scope_guards, + precomputed_scope_active, + precomputed_match, + precomputed_requests_final, + ) in lines + { // Security: Check line length to prevent DoS if line.len() > MAX_LINE_LENGTH { anyhow::bail!("Line {line_number} exceeds maximum length of {MAX_LINE_LENGTH} bytes"); @@ -166,8 +303,8 @@ fn parse_lines<'a>( // Get lowercase version of line for keyword detection let lower_line = line.to_lowercase(); - // Check for Include directive (should have been resolved in pass 1) - if lower_line.starts_with("include") { + // Check for exact Include directive (should have been resolved in pass 1). + if super::super::include::parse_include_line(line)?.is_some() { // In direct parsing mode, we skip Include directives tracing::debug!( "Skipping Include directive at line {} (not in file mode)", @@ -200,6 +337,10 @@ fn parse_lines<'a>( // Create config for this Match block let config = SshHostConfig { block_type: Some(ConfigBlock::Match(conditions)), + precomputed_match, + precomputed_requests_final, + precomputed_scope_active, + scope_guards: parse_scope_guards(scope_guards, line_number)?, ..Default::default() }; match_block.config = config; @@ -231,6 +372,8 @@ fn parse_lines<'a>( let config = SshHostConfig { host_patterns: patterns.clone(), block_type: Some(ConfigBlock::Host(patterns)), + precomputed_scope_active, + scope_guards: parse_scope_guards(scope_guards, line_number)?, ..Default::default() }; @@ -241,7 +384,7 @@ fn parse_lines<'a>( } // Parse configuration option - let (keyword, args) = parse_config_line(line, line_number, MAX_VALUE_LENGTH)?; + let (keyword, args) = parse_option_line(line, line_number, MAX_VALUE_LENGTH)?; if keyword.is_empty() { continue; @@ -279,11 +422,16 @@ fn parse_lines<'a>( // global defaults. Model that region as the first `Host *` block // so the resolver's first-obtained merge semantics apply without // losing the original directive order. - let config = current_config.get_or_insert_with(|| SshHostConfig { - host_patterns: vec!["*".to_string()], - block_type: Some(ConfigBlock::Host(vec!["*".to_string()])), - ..Default::default() - }); + if current_config.is_none() { + current_config = Some(SshHostConfig { + host_patterns: vec!["*".to_string()], + block_type: Some(ConfigBlock::Host(vec!["*".to_string()])), + precomputed_scope_active, + scope_guards: parse_scope_guards(scope_guards, line_number)?, + ..Default::default() + }); + } + let config = current_config.as_mut().expect("config was initialized"); parse_option_first( config, &keyword, @@ -309,6 +457,20 @@ fn parse_lines<'a>( Ok(configs) } +fn parse_scope_guards(scopes: &[String], line_number: usize) -> Result> { + scopes + .iter() + .map(|scope| { + let lower = scope.trim().to_ascii_lowercase(); + if lower.starts_with("host ") || lower.starts_with("host=") { + parse_host_line(scope, line_number).map(ConfigBlock::Host) + } else { + MatchCondition::parse_match_line(scope, line_number).map(ConfigBlock::Match) + } + }) + .collect() +} + /// Parse one directive independently, then merge it into its surrounding /// Host/Match block. This preserves OpenSSH's first-obtained rule even for /// repeated directives separated by Include file boundaries, while additive @@ -326,40 +488,96 @@ fn parse_option_first( Ok(()) } -/// Parse a Host directive line -pub(super) fn parse_host_line(line: &str, line_number: usize) -> Result> { - let line = line.trim(); +/// Parse an option while retaining the shell grammar of command-valued +/// directives. OpenSSH passes these remainders to a shell later, so removing +/// quotes here can turn protected metacharacters into live operators. +fn parse_option_line( + line: &str, + line_number: usize, + max_value_length: usize, +) -> Result<(String, Vec)> { + let (keyword, args) = parse_config_line(line, line_number, max_value_length)?; + if !matches!( + keyword.as_str(), + "proxycommand" | "localcommand" | "remotecommand" | "knownhostscommand" + ) || args.is_empty() + { + return Ok((keyword, args)); + } - // Support both "Host pattern" and "Host=pattern" syntax - let patterns_str = if let Some(pos) = line.find('=') { - // Host=pattern syntax - if line[..pos].trim().to_lowercase() != "host" { - anyhow::bail!("Invalid Host directive at line {line_number}"); - } - line[pos + 1..].trim() - } else { - // Host pattern syntax - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.is_empty() || parts[0].to_lowercase() != "host" { - anyhow::bail!("Invalid Host directive at line {line_number}"); - } - if parts.len() < 2 { - anyhow::bail!("Host directive requires at least one pattern at line {line_number}"); - } - // Join all parts after "Host" - line[parts[0].len()..].trim() + let remainder = config_value_remainder(line); + Ok((keyword, vec![normalize_command_remainder(remainder)])) +} + +fn config_value_remainder(line: &str) -> &str { + let line = line.trim(); + let Some((index, delimiter)) = line + .char_indices() + .find(|(_, ch)| ch.is_whitespace() || *ch == '=') + else { + return ""; }; + let mut remainder = line[index + delimiter.len_utf8()..].trim_start(); + if delimiter != '=' + && let Some(after_equals) = remainder.strip_prefix('=') + { + remainder = after_equals.trim_start(); + } + remainder +} - if patterns_str.is_empty() { - anyhow::bail!("Host directive requires at least one pattern at line {line_number}"); +/// Collapse configuration separators without rewriting shell syntax. This +/// preserves the parser's established single-separator representation while +/// retaining whitespace protected by quotes or backslashes for the shell. +fn normalize_command_remainder(remainder: &str) -> String { + let mut normalized = String::with_capacity(remainder.len()); + let mut chars = remainder.chars(); + let mut quote = None; + let mut pending_separator = false; + + while let Some(ch) = chars.next() { + if ch == '\\' { + if pending_separator && !normalized.is_empty() { + normalized.push(' '); + } + pending_separator = false; + normalized.push(ch); + if let Some(escaped) = chars.next() { + normalized.push(escaped); + } + } else if quote == Some(ch) { + normalized.push(ch); + quote = None; + } else if quote.is_none() && matches!(ch, '\'' | '"') { + if pending_separator && !normalized.is_empty() { + normalized.push(' '); + } + pending_separator = false; + normalized.push(ch); + quote = Some(ch); + } else if quote.is_none() && matches!(ch, ' ' | '\t') { + pending_separator = true; + } else { + if pending_separator && !normalized.is_empty() { + normalized.push(' '); + } + pending_separator = false; + normalized.push(ch); + } } - // Split into individual patterns - let patterns: Vec = patterns_str - .split_whitespace() - .map(|s| s.to_string()) - .collect(); + normalized +} +/// Parse a Host directive line +pub(super) fn parse_host_line(line: &str, line_number: usize) -> Result> { + let (keyword, patterns) = parse_config_line(line, line_number, 4096)?; + if keyword != "host" { + anyhow::bail!("Invalid Host directive at line {line_number}"); + } + if patterns.is_empty() { + anyhow::bail!("Host directive requires at least one pattern at line {line_number}"); + } Ok(patterns) } @@ -370,66 +588,61 @@ pub(super) fn parse_config_line( max_value_length: usize, ) -> Result<(String, Vec)> { let line = line.trim(); - - // Determine if using equals syntax - let eq_pos = line.find('='); - let uses_equals_syntax = if let Some(pos) = eq_pos { - // Only an equals sign immediately following the option name selects - // Option=Value syntax. Values such as `ProxyCommand env FOO=bar` - // must stay in the ordinary whitespace-separated form. - let key_candidate = line[..pos].trim(); - let equals_follows_option = - !key_candidate.is_empty() && !key_candidate.chars().any(char::is_whitespace); - // Host and Match never use equals syntax - equals_follows_option && !matches!(key_candidate.to_lowercase().as_str(), "host" | "match") - } else { - false - }; - - let (keyword, args) = if let Some(pos) = eq_pos.filter(|_| uses_equals_syntax) { - // Option=Value syntax - let key_part = line[..pos].trim(); - let value_part = &line[pos + 1..]; - - if key_part.is_empty() { - return Ok((String::new(), vec![])); - } - - let trimmed_value = value_part.trim(); - - // Security: Check value length - if trimmed_value.len() > max_value_length { - anyhow::bail!( - "Value at line {line_number} exceeds maximum length of {max_value_length} bytes" - ); + let boundary = line + .char_indices() + .find(|(_, ch)| ch.is_whitespace() || *ch == '='); + let (keyword, remainder, equals) = match boundary { + Some((index, delimiter)) => { + let mut remainder = line[index + delimiter.len_utf8()..].trim_start(); + let mut equals = delimiter == '='; + if !equals && let Some(after_equals) = remainder.strip_prefix('=') { + remainder = after_equals.trim_start(); + equals = true; + } + (&line[..index], remainder, equals) } - - let args = if trimmed_value.is_empty() { - vec![] - } else { - // Special handling for comma-separated options - match key_part.to_lowercase().as_str() { - "ciphers" + None => (line, "", false), + }; + if keyword.is_empty() { + return Ok((String::new(), Vec::new())); + } + if remainder.len() > max_value_length { + anyhow::bail!( + "Value at line {line_number} exceeds maximum length of {max_value_length} bytes" + ); + } + let keyword = keyword.to_ascii_lowercase(); + let mut args = super::super::value::tokenize(remainder, line_number)?; + if equals + && matches!( + keyword.as_str(), + "ciphers" | "macs" | "hostkeyalgorithms" | "kexalgorithms" | "preferredauthentications" - | "protocol" => trimmed_value - .split(',') - .map(|s| s.trim().to_string()) - .collect(), - _ => vec![trimmed_value.to_string()], - } - }; - - (key_part.to_lowercase(), args) - } else { - // Option Value syntax (space-separated) - let mut parts = line.split_whitespace(); - let keyword = parts.next().unwrap_or("").to_lowercase(); - let args: Vec = parts.map(|s| s.to_string()).collect(); - (keyword, args) - }; - + | "protocol" + ) + { + args = args + .iter() + .flat_map(|value| value.split(',')) + .map(str::trim) + .map(str::to_string) + .collect(); + } Ok((keyword, args)) } + +#[cfg(test)] +mod command_remainder_tests { + use super::normalize_command_remainder; + + #[test] + fn normalizes_only_unquoted_command_separators() { + assert_eq!( + normalize_command_remainder(r#"printf '%h %k' escaped\ space "%n %p""#), + r#"printf '%h %k' escaped\ space "%n %p""# + ); + } +} diff --git a/src/ssh/ssh_config/parser/mod.rs b/src/ssh/ssh_config/parser/mod.rs index 8a5937ab..a28e36cc 100644 --- a/src/ssh/ssh_config/parser/mod.rs +++ b/src/ssh/ssh_config/parser/mod.rs @@ -30,9 +30,10 @@ mod tests; // Re-export public items from core module #[cfg(test)] -pub(super) use core::parse; +pub(super) use core::{parse, parse_from_file_for_host_at_with_diagnostics}; pub(super) use core::{ - parse_cli_options, parse_from_file_with_diagnostics, parse_with_diagnostics, + parse_cli_options, parse_from_file_for_host_pass_at_with_diagnostics, + parse_from_file_with_diagnostics, parse_with_diagnostics, }; // Re-export helper functions that might be used elsewhere diff --git a/src/ssh/ssh_config/parser/options/authentication.rs b/src/ssh/ssh_config/parser/options/authentication.rs index 63defcd9..76a593fb 100644 --- a/src/ssh/ssh_config/parser/options/authentication.rs +++ b/src/ssh/ssh_config/parser/options/authentication.rs @@ -37,6 +37,7 @@ pub(super) fn parse_authentication_option( let path = secure_validate_path(&args[0], "identity", line_number) .with_context(|| format!("Invalid IdentityFile path at line {line_number}"))?; host.identity_files.push(path); + host.identity_file_args.push(args[0].clone()); } "identitiesonly" => { if args.is_empty() { @@ -195,6 +196,7 @@ pub(super) fn parse_authentication_option( let path = secure_validate_path(&args[0], "certificate", line_number) .with_context(|| format!("Invalid CertificateFile path at line {line_number}"))?; host.certificate_files.push(path); + host.certificate_file_args.push(args[0].clone()); } "pubkeyauthentication" => { if args.is_empty() { @@ -280,7 +282,10 @@ pub(super) fn parse_authentication_option( } // Security: Validate algorithm name contains only safe characters - // Allow alphanumeric, hyphens, dots, underscores, @ and + + // OpenSSH permits +, -, and ^ list modifiers, and glob + // patterns for the removal form. Resolution against the + // supported key-signature set is deliberately deferred to + // the -G renderer; these values do not alter runtime auth. if !trimmed.chars().all(|c| { c.is_ascii_alphanumeric() || c == '-' @@ -288,6 +293,9 @@ pub(super) fn parse_authentication_option( || c == '_' || c == '@' || c == '+' + || c == '^' + || c == '*' + || c == '?' }) { anyhow::bail!( "HostbasedAcceptedAlgorithms at line {line_number} contains invalid characters in algorithm name '{trimmed}'. \ diff --git a/src/ssh/ssh_config/parser/options/connection.rs b/src/ssh/ssh_config/parser/options/connection.rs index 5e11335d..0a5af3ae 100644 --- a/src/ssh/ssh_config/parser/options/connection.rs +++ b/src/ssh/ssh_config/parser/options/connection.rs @@ -115,51 +115,10 @@ pub(super) fn parse_connection_option( if args.is_empty() { anyhow::bail!("BindInterface requires a value at line {line_number}"); } - // Security: Validate network interface name to prevent injection attacks let interface = &args[0]; if interface.is_empty() { anyhow::bail!("BindInterface cannot be empty at line {line_number}"); } - // Network interface names on Linux/macOS are typically: - // - eth0, eth1, etc. (Linux) - // - en0, en1, etc. (macOS) - // - lo, lo0 (loopback) - // - wlan0, wlp3s0, etc. (wireless) - // - docker0, br0, tun0, tap0, etc. (virtual interfaces) - // - bond0, team0, etc. (bonded interfaces) - // - vlan interfaces like eth0.100 - // Maximum length is typically 15 characters on Linux (IFNAMSIZ - 1) - if interface.len() > 15 { - anyhow::bail!( - "BindInterface '{interface}' at line {line_number} exceeds maximum interface name length of 15 characters" - ); - } - - // Only allow alphanumeric, dots, hyphens, underscores, and colons (for aliases like eth0:1) - if !interface - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == ':') - { - anyhow::bail!( - "BindInterface '{interface}' at line {line_number} contains invalid characters. \ - Network interface names can only contain alphanumeric characters, dots, hyphens, underscores, and colons" - ); - } - - // Additional validation: interface name shouldn't start with a dot or hyphen - if interface.starts_with('.') || interface.starts_with('-') { - anyhow::bail!( - "BindInterface '{interface}' at line {line_number} cannot start with a dot or hyphen" - ); - } - - // Prevent potential path traversal or command injection - if interface.contains("..") || interface.contains("/") || interface.contains("\\") { - anyhow::bail!( - "BindInterface '{interface}' at line {line_number} contains dangerous characters that could be used for injection attacks" - ); - } - host.bind_interface = Some(interface.clone()); } "ipqos" => { diff --git a/src/ssh/ssh_config/parser/options/environment.rs b/src/ssh/ssh_config/parser/options/environment.rs index fe099dc8..f2eddf39 100644 --- a/src/ssh/ssh_config/parser/options/environment.rs +++ b/src/ssh/ssh_config/parser/options/environment.rs @@ -40,17 +40,7 @@ pub(super) fn parse_environment_option( if args.is_empty() { anyhow::bail!("SetEnv requires at least one name=value pair at line {line_number}"); } - // SetEnv can have multiple name=value pairs - // If we have a single arg (from equals syntax), it might contain multiple pairs - let pairs: Vec<&str> = if args.len() == 1 && args[0].contains('=') { - // Single arg from equals syntax - might have multiple name=value pairs - args[0].split_whitespace().collect() - } else { - // Multiple args from space syntax - convert to &str references - args.iter().map(String::as_str).collect() - }; - - for pair in pairs { + for pair in args { if let Some(eq_pos) = pair.find('=') { let name = pair[..eq_pos].to_string(); let value = pair[eq_pos + 1..].to_string(); @@ -115,6 +105,12 @@ mod tests { parse_environment_option(&mut config, "setenv", &["EMPTY=".into()], 8).unwrap(); assert_eq!(config.set_env.get("EMPTY").map(String::as_str), Some("")); + + parse_environment_option(&mut config, "setenv", &["SPACED=a b#c".into()], 9).unwrap(); + assert_eq!( + config.set_env.get("SPACED").map(String::as_str), + Some("a b#c") + ); } #[test] diff --git a/src/ssh/ssh_config/parser/options/forwarding.rs b/src/ssh/ssh_config/parser/options/forwarding.rs index 73211014..b8a07daa 100644 --- a/src/ssh/ssh_config/parser/options/forwarding.rs +++ b/src/ssh/ssh_config/parser/options/forwarding.rs @@ -33,7 +33,12 @@ pub(super) fn parse_forwarding_option( if args.is_empty() { anyhow::bail!("ForwardAgent requires a value at line {line_number}"); } - host.forward_agent = Some(parse_yes_no(&args[0], line_number)?); + // OpenSSH also accepts an agent socket path. Runtime forwarding is + // declared unimplemented, so retain that raw value for `-G` while + // preserving the existing typed yes/no representation when possible. + if matches!(args[0].to_ascii_lowercase().as_str(), "yes" | "no") { + host.forward_agent = Some(parse_yes_no(&args[0], line_number)?); + } } "forwardx11" => { if args.is_empty() { @@ -47,6 +52,7 @@ pub(super) fn parse_forwarding_option( } let value = args.join(" "); host.local_forward.push(value.clone()); + host.local_forward_args.push(args.to_vec()); host.forwarding_directives .push(crate::forwarding::ForwardingDirective::Local(value)); } @@ -56,6 +62,7 @@ pub(super) fn parse_forwarding_option( } let value = args.join(" "); host.remote_forward.push(value.clone()); + host.remote_forward_args.push(args.to_vec()); host.forwarding_directives .push(crate::forwarding::ForwardingDirective::Remote(value)); } @@ -65,6 +72,7 @@ pub(super) fn parse_forwarding_option( } let value = args.join(" "); host.dynamic_forward.push(value.clone()); + host.dynamic_forward_args.push(args.to_vec()); host.forwarding_directives .push(crate::forwarding::ForwardingDirective::Dynamic(value)); } diff --git a/src/ssh/ssh_config/parser/options/mod.rs b/src/ssh/ssh_config/parser/options/mod.rs index 05b6a09f..14c9761b 100644 --- a/src/ssh/ssh_config/parser/options/mod.rs +++ b/src/ssh/ssh_config/parser/options/mod.rs @@ -48,6 +48,9 @@ pub fn parse_option( ) -> Result<()> { let line_number = source.number(); let Some(spec) = support::keyword_spec(accepted_keyword) else { + host.unknown_options + .entry(accepted_keyword.to_string()) + .or_insert_with(|| args.to_vec()); if reported_diagnostics.insert(format!("unknown:{accepted_keyword}")) { let keyword = escape_field(accepted_keyword); let location = source.location(); @@ -57,13 +60,19 @@ pub fn parse_option( }; let keyword = spec.canonical; - if spec.support == support::KeywordSupport::Unimplemented - && reported_diagnostics.insert(format!("unsupported:{keyword}")) - { - let location = source.location(); - crate::diagnosticln!( - "Unsupported SSH config option '{keyword}' at {location}; bssh parses this value for inspection but does not implement its runtime behavior" - ); + if spec.support == support::KeywordSupport::Unimplemented { + validate_retained_option(keyword, args, line_number)?; + if !args.is_empty() { + host.unimplemented_options + .entry(keyword.to_string()) + .or_insert_with(|| args.to_vec()); + } + if reported_diagnostics.insert(format!("unsupported:{keyword}")) { + let location = source.location(); + crate::diagnosticln!( + "Unsupported SSH config option '{keyword}' at {location}; bssh parses this value for inspection but does not implement its runtime behavior" + ); + } } match keyword { @@ -178,7 +187,186 @@ pub fn parse_option( | "userknownhostsfile2" | "useroaming" | "usersh" - | "useprivilegedport" => Ok(()), + | "useprivilegedport" + | "tunneldevice" + | "canonicalizefallbacklocal" + | "canonicalizehostname" + | "canonicalizemaxdots" + | "canonicaldomains" + | "canonicalizepermittedcnames" + | "channeltimeout" + | "enableescapecommandline" + | "logverbose" + | "obscurekeystroketiming" + | "streamlocalbindunlink" + | "streamlocalbindmask" + | "tunnel" + | "warnweakcrypto" + | "xauthlocation" + | "revokedhostkeys" => Ok(()), _ => unreachable!("accepted keyword is missing a parser: {keyword}"), } } + +fn validate_retained_option(keyword: &str, args: &[String], line_number: usize) -> Result<()> { + let one = || { + if args.len() != 1 || args[0].is_empty() { + anyhow::bail!("{keyword} expects exactly one value at line {line_number}"); + } + Ok(()) + }; + let boolean = || { + one()?; + if !matches!( + args[0].to_ascii_lowercase().as_str(), + "yes" | "no" | "true" | "false" + ) { + anyhow::bail!("Invalid boolean for {keyword} at line {line_number}"); + } + Ok(()) + }; + match keyword { + "canonicalizefallbacklocal" => { + boolean()?; + } + "canonicalizehostname" => { + one()?; + if !matches!( + args[0].to_ascii_lowercase().as_str(), + "yes" | "no" | "always" | "true" | "false" + ) { + anyhow::bail!("Invalid canonicalizehostname at line {line_number}"); + } + } + "canonicalizemaxdots" => { + one()?; + args[0].parse::().map_err(|_| { + anyhow::anyhow!("Invalid canonicalizemaxdots at line {line_number}") + })?; + } + "canonicaldomains" | "canonicalizepermittedcnames" => { + if args.is_empty() { + anyhow::bail!("{keyword} requires at least one value at line {line_number}"); + } + } + "enableescapecommandline" | "streamlocalbindunlink" | "warnweakcrypto" => boolean()?, + "tunnel" => { + one()?; + if !matches!( + args[0].to_ascii_lowercase().as_str(), + "yes" | "no" | "true" | "false" | "point-to-point" | "ethernet" + ) { + anyhow::bail!("Invalid tunnel value at line {line_number}"); + } + } + "streamlocalbindmask" => { + one()?; + let mode = u32::from_str_radix(&args[0], 8).map_err(|_| { + anyhow::anyhow!("Invalid streamlocalbindmask at line {line_number}") + })?; + if mode > 0o777 { + anyhow::bail!("Invalid streamlocalbindmask at line {line_number}"); + } + } + "tunneldevice" => { + one()?; + let component = |part: &str| part == "any" || part.parse::().is_ok(); + let valid = args[0].split_once(':').map_or_else( + || component(&args[0]), + |(local, remote)| component(local) && component(remote), + ); + if !valid { + anyhow::bail!("Invalid tunneldevice at line {line_number}"); + } + } + "obscurekeystroketiming" => { + one()?; + let value = args[0].to_ascii_lowercase(); + if !matches!(value.as_str(), "yes" | "no" | "true" | "false") + && !value + .strip_prefix("interval:") + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| (1..=1000).contains(&value)) + { + anyhow::bail!("Invalid obscurekeystroketiming at line {line_number}"); + } + } + "securitykeyprovider" | "xauthlocation" | "revokedhostkeys" => one()?, + "channeltimeout" | "logverbose" if args.is_empty() => { + anyhow::bail!("{keyword} expects at least one value at line {line_number}"); + } + _ => {} + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::ssh::ssh_config::SshConfig; + + #[test] + fn accepts_audited_retained_value_grammars() { + let valid = r#" +Host * + CanonicalizeFallbackLocal yes + CanonicalizeHostname false + CanonicalizeMaxDots 1 + CanonicalDomains none + CanonicalizePermittedCNAMEs none + ChannelTimeout none + EnableEscapeCommandline no + LogVerbose none + ObscureKeystrokeTiming interval:1000 + StreamLocalBindUnlink yes + StreamLocalBindMask 0000 + Tunnel point-to-point + TunnelDevice any + WarnWeakCrypto yes + SecurityKeyProvider /tmp/provider + XAuthLocation /usr/bin/xauth + RevokedHostKeys none +"#; + assert!(SshConfig::parse(valid).is_ok()); + assert!(SshConfig::parse("Host *\nStreamLocalBindMask 0777\n").is_ok()); + assert!(SshConfig::parse("Host *\nTunnelDevice 1:any\n").is_ok()); + assert!(SshConfig::parse("Host *\nObscureKeystrokeTiming interval:1\n").is_ok()); + assert!(SshConfig::parse("Host *\nCanonicalizeFallbackLocal no\n").is_ok()); + assert!(SshConfig::parse("Host *\nCanonicalizeHostname yes\n").is_ok()); + assert!(SshConfig::parse("Host *\nCanonicalizeMaxDots 2\n").is_ok()); + assert!(SshConfig::parse("Host *\nCanonicalDomains example.com\n").is_ok()); + assert!(SshConfig::parse("Host *\nCanonicalizePermittedCNAMEs *.a:*.b\n").is_ok()); + } + + #[test] + fn rejects_invalid_retained_values() { + for option in [ + "CanonicalizeFallbackLocal maybe", + "CanonicalizeHostname maybe", + "CanonicalizeMaxDots nope", + "CanonicalDomains", + "CanonicalizePermittedCNAMEs", + "ChannelTimeout", + "EnableEscapeCommandline maybe", + "LogVerbose", + "ObscureKeystrokeTiming interval:0", + "ObscureKeystrokeTiming interval:1001", + "StreamLocalBindUnlink maybe", + "StreamLocalBindMask 1000", + "Tunnel invalid", + "TunnelDevice any:invalid", + "WarnWeakCrypto maybe", + "SecurityKeyProvider", + "XAuthLocation", + "RevokedHostKeys", + ] { + let config = format!("Host *\n {option}\n"); + assert!(SshConfig::parse(&config).is_err(), "accepted {option}"); + } + } + + #[test] + fn existing_opaque_legacy_values_keep_their_previous_behavior() { + assert!(SshConfig::parse("Host *\n UseKeychain yes\n").is_ok()); + assert!(SshConfig::parse("Host *\n UseKeychain arbitrary legacy value\n").is_err()); + } +} diff --git a/src/ssh/ssh_config/parser/options/security.rs b/src/ssh/ssh_config/parser/options/security.rs index 8f56c212..14b6ec31 100644 --- a/src/ssh/ssh_config/parser/options/security.rs +++ b/src/ssh/ssh_config/parser/options/security.rs @@ -409,7 +409,9 @@ pub(super) fn parse_security_option( } // Security: Validate algorithm name contains only safe characters - // Allow alphanumeric, hyphens, dots, underscores, @ and + + // OpenSSH permits +, -, and ^ list modifiers, and glob + // patterns for the removal form. The -G renderer resolves + // this policy independently from runtime authentication. if !trimmed.chars().all(|c| { c.is_ascii_alphanumeric() || c == '-' @@ -417,6 +419,9 @@ pub(super) fn parse_security_option( || c == '_' || c == '@' || c == '+' + || c == '^' + || c == '*' + || c == '?' }) { anyhow::bail!( "CASignatureAlgorithms at line {line_number} contains invalid characters in algorithm name '{trimmed}'. \ diff --git a/src/ssh/ssh_config/parser/options/support.rs b/src/ssh/ssh_config/parser/options/support.rs index 39eef257..de57c331 100644 --- a/src/ssh/ssh_config/parser/options/support.rs +++ b/src/ssh/ssh_config/parser/options/support.rs @@ -240,6 +240,46 @@ pub(super) const ACCEPTED_KEYWORDS: &[(&str, &str, KeywordSupport)] = &[ ("useroaming", "useroaming", Unimplemented), ("usersh", "usersh", Unimplemented), ("useprivilegedport", "useprivilegedport", Unimplemented), + ("tunneldevice", "tunneldevice", Unimplemented), + ( + "canonicalizefallbacklocal", + "canonicalizefallbacklocal", + Unimplemented, + ), + ( + "canonicalizehostname", + "canonicalizehostname", + Unimplemented, + ), + ("canonicalizemaxdots", "canonicalizemaxdots", Unimplemented), + ("canonicaldomains", "canonicaldomains", Unimplemented), + ( + "canonicalizepermittedcnames", + "canonicalizepermittedcnames", + Unimplemented, + ), + ("channeltimeout", "channeltimeout", Unimplemented), + ( + "enableescapecommandline", + "enableescapecommandline", + Unimplemented, + ), + ("logverbose", "logverbose", Unimplemented), + ( + "obscurekeystroketiming", + "obscurekeystroketiming", + Unimplemented, + ), + ( + "streamlocalbindunlink", + "streamlocalbindunlink", + Unimplemented, + ), + ("streamlocalbindmask", "streamlocalbindmask", Unimplemented), + ("tunnel", "tunnel", Unimplemented), + ("warnweakcrypto", "warnweakcrypto", Unimplemented), + ("xauthlocation", "xauthlocation", Unimplemented), + ("revokedhostkeys", "revokedhostkeys", Unimplemented), ]; pub(super) fn keyword_spec(keyword: &str) -> Option { @@ -258,9 +298,9 @@ mod tests { use super::*; use std::collections::HashSet; - const ACCEPTED_SPELLING_COUNT: usize = 91; + const ACCEPTED_SPELLING_COUNT: usize = 107; const RUNTIME_SPELLING_COUNT: usize = 51; - const UNIMPLEMENTED_SPELLING_COUNT: usize = 40; + const UNIMPLEMENTED_SPELLING_COUNT: usize = 56; #[test] fn accepted_keywords_and_aliases_have_one_consistent_classification() { @@ -410,6 +450,22 @@ mod tests { "useroaming", "usersh", "useprivilegedport", + "tunneldevice", + "canonicalizefallbacklocal", + "canonicalizehostname", + "canonicalizemaxdots", + "canonicaldomains", + "canonicalizepermittedcnames", + "channeltimeout", + "enableescapecommandline", + "logverbose", + "obscurekeystroketiming", + "streamlocalbindunlink", + "streamlocalbindmask", + "tunnel", + "warnweakcrypto", + "xauthlocation", + "revokedhostkeys", ]; let unimplemented = ACCEPTED_KEYWORDS .iter() diff --git a/src/ssh/ssh_config/parser/tests.rs b/src/ssh/ssh_config/parser/tests.rs index bdcefc71..17394928 100644 --- a/src/ssh/ssh_config/parser/tests.rs +++ b/src/ssh/ssh_config/parser/tests.rs @@ -17,6 +17,16 @@ use super::core::*; use super::helpers::*; +fn write_config(path: impl AsRef, contents: impl AsRef<[u8]>) { + let path = path.as_ref(); + std::fs::write(path, contents).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } +} + #[test] fn test_parse_yes_no_values() { assert!(parse_yes_no("yes", 1).unwrap()); @@ -148,7 +158,7 @@ fn equals_in_url_value_is_preserved_by_config_line_parser() { vec![ "curl", "-s", - r#""https://api.example.com/keys?host=%H&format=ssh""# + "https://api.example.com/keys?host=%H&format=ssh" ] ); } @@ -316,7 +326,6 @@ fn test_parse_very_long_value() { #[tokio::test] async fn test_include_with_match_blocks() { use crate::ssh::ssh_config::types::ConfigBlock; - use std::fs; use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); @@ -331,7 +340,7 @@ Match host *.prod.example.com user admin Match localuser developer RequestTTY yes "#; - fs::write(&include_file, include_content).unwrap(); + write_config(&include_file, include_content); // Create main config that includes the Match rules let main_config = temp_dir.path().join("config"); @@ -345,7 +354,7 @@ Host example.com "#, include_file.display() ); - fs::write(&main_config, &main_content).unwrap(); + write_config(&main_config, &main_content); // Parse the configuration let config = crate::ssh::ssh_config::SshConfig::load_from_file(&main_config) @@ -385,26 +394,24 @@ Host example.com async fn test_nested_includes_with_match() { use crate::ssh::ssh_config::match_directive::MatchCondition; use crate::ssh::ssh_config::types::ConfigBlock; - use std::fs; use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); // Create a deeply included file with Host config let deep_include = temp_dir.path().join("deep.conf"); - fs::write( + write_config( &deep_include, r#" Host deep.example.com User deepuser Port 3333 "#, - ) - .unwrap(); + ); // Create a middle include with Match and Include let middle_include = temp_dir.path().join("middle.conf"); - fs::write( + write_config( &middle_include, format!( r#" @@ -416,12 +423,11 @@ Include {} "#, deep_include.display() ), - ) - .unwrap(); + ); // Create main config let main_config = temp_dir.path().join("config"); - fs::write( + write_config( &main_config, format!( r#" @@ -435,8 +441,7 @@ Match all "#, middle_include.display() ), - ) - .unwrap(); + ); // Parse the configuration let config = crate::ssh::ssh_config::SshConfig::load_from_file(&main_config) @@ -1856,96 +1861,71 @@ Host proxy.example.com #[tokio::test] async fn test_includes_preserve_global_and_host_first_obtained_context() { - use std::fs; use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); let nested = temp_dir.path().join("nested.conf"); - fs::write(&nested, "Port 2200\nSetEnv ORDER=nested NESTED=yes\n").unwrap(); + write_config(&nested, "Port 2200\nSetEnv ORDER=nested NESTED=yes\n"); let global = temp_dir.path().join("global.conf"); - fs::write( + write_config( &global, format!( "User include-first\nSetEnv ORDER=global GLOBAL=yes\nInclude {}\nPort 2300\nSetEnv ORDER=global-late\n", nested.display() ), - ) - .unwrap(); + ); let host = temp_dir.path().join("host.conf"); - fs::write( + write_config( &host, "HostName included.example.com\nSetEnv HOST_CONTEXT=yes\n", - ) - .unwrap(); + ); let main = temp_dir.path().join("config"); - fs::write( + write_config( &main, format!( "Include {}\nUser main-late\nSetEnv ORDER=main-late\n\nHost foo\n HostKeyAlias caller-context\n Include {}\n HostName main-late.example.com\n", global.display(), host.display() ), - ) - .unwrap(); + ); let config = crate::ssh::ssh_config::SshConfig::load_from_file(&main) .await .unwrap(); + let effective = config.find_host_config("foo"); + assert_eq!(effective.user.as_deref(), Some("include-first")); + assert_eq!(effective.port, Some(2200)); + assert_eq!(effective.hostname.as_deref(), Some("included.example.com")); + assert_eq!(effective.host_key_alias.as_deref(), Some("caller-context")); assert_eq!( - config.hosts.len(), - 2, - "Include must not create block boundaries" - ); - - let global_block = &config.hosts[0]; - assert_eq!(global_block.host_patterns, ["*"]); - assert_eq!(global_block.user.as_deref(), Some("include-first")); - assert_eq!(global_block.port, Some(2200)); - assert_eq!( - global_block.set_env.get("ORDER").map(String::as_str), + effective.set_env.get("ORDER").map(String::as_str), Some("global") ); assert_eq!( - global_block.set_env.get("NESTED").map(String::as_str), + effective.set_env.get("NESTED").map(String::as_str), Some("yes") ); - - let host_block = &config.hosts[1]; - assert_eq!(host_block.host_patterns, ["foo"]); - assert_eq!(host_block.host_key_alias.as_deref(), Some("caller-context")); - assert_eq!(host_block.hostname.as_deref(), Some("included.example.com")); assert_eq!( - host_block.set_env.get("HOST_CONTEXT").map(String::as_str), + effective.set_env.get("HOST_CONTEXT").map(String::as_str), Some("yes") ); - - let effective = config.find_host_config("foo"); - assert_eq!(effective.user.as_deref(), Some("include-first")); - assert_eq!(effective.port, Some(2200)); - assert_eq!(effective.hostname.as_deref(), Some("included.example.com")); - assert_eq!( - effective.set_env.get("ORDER").map(String::as_str), - Some("global") - ); } #[tokio::test] async fn include_errors_use_structured_source_lines_not_source_comments() { - use std::fs; use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); let included = temp_dir.path().join("included.conf"); - fs::write( + write_config( &included, "# Source: /spoofed/config:9000\n# ordinary comment\nHost target\nConnectionAttempts 0\n", - ) - .unwrap(); + ); let main = temp_dir.path().join("config"); - fs::write(&main, format!("Include {}\n", included.display())).unwrap(); + write_config(&main, format!("Include {}\n", included.display())); let error = crate::ssh::ssh_config::SshConfig::load_from_file(&main) .await diff --git a/src/ssh/ssh_config/pattern.rs b/src/ssh/ssh_config/pattern.rs index 3d5f5d22..37160a11 100644 --- a/src/ssh/ssh_config/pattern.rs +++ b/src/ssh/ssh_config/pattern.rs @@ -19,12 +19,17 @@ /// Check if a hostname matches any of the host patterns pub(super) fn matches_host_pattern(hostname: &str, patterns: &[String]) -> bool { + let mut positive_match = false; for pattern in patterns { - if matches_pattern(hostname, pattern) { - return true; + if let Some(negated) = pattern.strip_prefix('!') { + if matches_pattern(hostname, negated) { + return false; + } + } else if matches_pattern(hostname, pattern) { + positive_match = true; } } - false + positive_match } /// Check if a hostname matches a single pattern (supports wildcards) diff --git a/src/ssh/ssh_config/resolver.rs b/src/ssh/ssh_config/resolver.rs index 2bc0be2b..be41f9de 100644 --- a/src/ssh/ssh_config/resolver.rs +++ b/src/ssh/ssh_config/resolver.rs @@ -19,7 +19,7 @@ use super::match_directive::MatchContext; use super::pattern::matches_host_pattern; -use super::types::{ConfigBlock, SshHostConfig}; +use super::types::{ConfigBlock, ConfigPass, SshHostConfig}; use std::path::PathBuf; /// Find configuration for a specific hostname @@ -33,60 +33,216 @@ pub(super) fn find_host_config_with_user( hostname: &str, remote_user: Option<&str>, ) -> SshHostConfig { - let mut merged_config = SshHostConfig::default(); + let (mut merged_config, requests_final) = + resolve_first_pass_with_user(hosts, hostname, remote_user); - // Create match context for evaluating Match blocks - let match_context = - match MatchContext::new(hostname.to_string(), remote_user.map(|s| s.to_string())) { - Ok(ctx) => Some(ctx), - Err(e) => { - tracing::warn!("Failed to create match context: {}", e); - None - } - }; + if requests_final || canonicalization_requested(&merged_config) { + // OpenSSH fixes HostName to the first-pass effective destination before + // reparsing. A Match final block therefore cannot obtain HostName when + // it was otherwise unset during pass one. + merged_config.hostname = Some(effective_hostname(&merged_config, hostname)); + for host_config in hosts + .iter() + .filter(|config| config.pass == ConfigPass::FinalOnly) + { + apply_source_block(&mut merged_config, host_config, hostname, remote_user, true); + } + } - for host_config in hosts { - let should_apply = match &host_config.block_type { - Some(ConfigBlock::Host(patterns)) => { - // For Host blocks, check pattern matching - matches_host_pattern(hostname, patterns) - } - Some(ConfigBlock::Match(conditions)) => { - // For Match blocks, evaluate conditions - if let Some(ref ctx) = match_context { - // Create a temporary MatchBlock to evaluate conditions - let match_block = super::match_directive::MatchBlock { - conditions: conditions.clone(), - config: host_config.clone(), - line_number: 0, // Not used for evaluation - }; - match match_block.matches(ctx) { - Ok(matches) => matches, - Err(e) => { - tracing::debug!("Failed to evaluate Match conditions: {}", e); - false - } - } - } else { - false + merged_config +} + +/// Resolve only pass-one blocks without fixing an unset HostName or replaying +/// final-pass blocks. Source loaders use this between user and system files so +/// a later first-pass HostName can still be obtained in OpenSSH source order. +pub(super) fn find_host_config_first_pass( + hosts: &[SshHostConfig], + hostname: &str, +) -> SshHostConfig { + resolve_first_pass_with_user(hosts, hostname, None).0 +} + +fn resolve_first_pass_with_user( + hosts: &[SshHostConfig], + hostname: &str, + remote_user: Option<&str>, +) -> (SshHostConfig, bool) { + let mut merged_config = SshHostConfig::default(); + let mut requests_final = false; + for host_config in hosts.iter().filter(|config| config.pass == ConfigPass::Any) { + requests_final |= apply_source_block( + &mut merged_config, + host_config, + hostname, + remote_user, + false, + ); + } + (merged_config, requests_final) +} + +fn apply_source_block( + merged: &mut SshHostConfig, + source: &SshHostConfig, + original_hostname: &str, + remote_user: Option<&str>, + final_pass: bool, +) -> bool { + let current_hostname = effective_hostname(merged, original_hostname); + let current_user = remote_user + .map(str::to_string) + .or_else(|| merged.user.clone()) + .or_else(|| whoami::username().ok()); + let context = MatchContext::with_original_hostname( + current_hostname, + original_hostname.to_string(), + current_user, + ) + .map(|context| context.with_config(merged).with_final_pass(final_pass)); + let Ok(context) = context else { + return false; + }; + if !scopes_match(source, original_hostname, Some(&context)) { + return false; + } + let evaluation = match &source.block_type { + Some(ConfigBlock::Host(patterns)) => super::match_directive::MatchEvaluation { + matched: matches_host_pattern(original_hostname, patterns), + requests_final: false, + }, + Some(ConfigBlock::Match(conditions)) => { + if let Some(matched) = source.precomputed_match { + super::match_directive::MatchEvaluation { + matched, + requests_final: source.precomputed_requests_final.unwrap_or(false), } + } else { + conditions_match(conditions, &context) } - None => { - // Legacy format without block_type - use host_patterns - matches_host_pattern(hostname, &host_config.host_patterns) - } - }; + } + None => super::match_directive::MatchEvaluation { + matched: matches_host_pattern(original_hostname, &source.host_patterns), + requests_final: false, + }, + }; + if evaluation.matched { + merge_host_config(merged, source); + } + evaluation.requests_final +} + +fn effective_hostname(config: &SshHostConfig, original_hostname: &str) -> String { + config.hostname.as_deref().map_or_else( + || original_hostname.to_string(), + |value| expand_hostname_value(value, original_hostname), + ) +} - if should_apply { - merge_host_config(&mut merged_config, host_config); +pub(super) fn expand_hostname_value(value: &str, original_hostname: &str) -> String { + let mut output = String::with_capacity(value.len() + original_hostname.len()); + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '%' { + output.push(ch); + continue; + } + match chars.next() { + Some('h') => output.push_str(original_hostname), + Some('%') => output.push('%'), + Some(other) => { + output.push('%'); + output.push(other); + } + None => output.push('%'), } } + output +} - merged_config +fn conditions_match( + conditions: &[super::match_directive::MatchCondition], + context: &MatchContext, +) -> super::match_directive::MatchEvaluation { + let block = super::match_directive::MatchBlock { + conditions: conditions.to_vec(), + config: SshHostConfig::default(), + line_number: 0, + }; + block + .evaluate(context) + .unwrap_or(super::match_directive::MatchEvaluation { + matched: false, + requests_final: false, + }) +} + +pub(super) fn requests_final_pass(hosts: &[SshHostConfig]) -> bool { + hosts.iter().any(|config| { + (match &config.block_type { + Some(ConfigBlock::Match(conditions)) => conditions.iter().any(requests_final), + _ => false, + }) || config.scope_guards.iter().any(|guard| match guard { + ConfigBlock::Match(conditions) => conditions.iter().any(requests_final), + ConfigBlock::Host(_) => false, + }) + }) +} + +pub(super) fn requests_final_pass_for_host(hosts: &[SshHostConfig], hostname: &str) -> bool { + let (merged, requests_final) = resolve_first_pass_with_user(hosts, hostname, None); + requests_final || canonicalization_requested(&merged) +} + +fn canonicalization_requested(config: &SshHostConfig) -> bool { + config + .unimplemented_options + .get("canonicalizehostname") + .and_then(|values| values.first()) + .is_some_and(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "yes" | "true" | "always" + ) + }) +} + +fn requests_final(condition: &super::match_directive::MatchCondition) -> bool { + condition.requests_final_pass() +} + +fn scopes_match( + config: &SshHostConfig, + original_hostname: &str, + context: Option<&MatchContext>, +) -> bool { + if let Some(active) = config.precomputed_scope_active { + return active; + } + config.scope_guards.iter().all(|guard| match guard { + ConfigBlock::Host(patterns) => matches_host_pattern(original_hostname, patterns), + ConfigBlock::Match(conditions) => context.is_some_and(|context| { + let block = super::match_directive::MatchBlock { + conditions: conditions.clone(), + config: SshHostConfig::default(), + line_number: 0, + }; + block.matches(context).unwrap_or(false) + }), + }) } /// Merge a matching block using OpenSSH's first-obtained-value rule. pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfig) { + for (keyword, value) in &overlay.unimplemented_options { + base.unimplemented_options + .entry(keyword.clone()) + .or_insert_with(|| value.clone()); + } + for (keyword, value) in &overlay.unknown_options { + base.unknown_options + .entry(keyword.clone()) + .or_insert_with(|| value.clone()); + } // Blocks are visited in source order, so scalar values only fill empty slots. if base.host_patterns.is_empty() && !overlay.host_patterns.is_empty() { base.host_patterns = overlay.host_patterns.clone(); @@ -101,9 +257,13 @@ pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfi base.port = overlay.port; } if !overlay.identity_files.is_empty() { - // For identity files, we append them - base.identity_files - .extend(overlay.identity_files.iter().cloned()); + extend_paths_for_pass( + &mut base.identity_files, + &mut base.identity_file_args, + &overlay.identity_files, + &overlay.identity_file_args, + overlay.pass, + ); } // OpenSSH keeps the first obtained proxy directive. ProxyCommand and // ProxyJump compete for the same slot, so either one suppresses all later @@ -188,7 +348,7 @@ pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfi base.resolved_macs = overlay.resolved_macs.clone(); } if !overlay.send_env.is_empty() { - base.send_env.extend(overlay.send_env.iter().cloned()); + extend_for_pass(&mut base.send_env, &overlay.send_env, overlay.pass); } for (name, value) in &overlay.set_env { base.set_env @@ -196,20 +356,38 @@ pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfi .or_insert_with(|| value.clone()); } if !overlay.local_forward.is_empty() { - base.local_forward - .extend(overlay.local_forward.iter().cloned()); + extend_forwardings_for_pass( + &mut base.local_forward, + &mut base.local_forward_args, + &overlay.local_forward, + &overlay.local_forward_args, + overlay.pass, + ); } if !overlay.remote_forward.is_empty() { - base.remote_forward - .extend(overlay.remote_forward.iter().cloned()); + extend_forwardings_for_pass( + &mut base.remote_forward, + &mut base.remote_forward_args, + &overlay.remote_forward, + &overlay.remote_forward_args, + overlay.pass, + ); } if !overlay.dynamic_forward.is_empty() { - base.dynamic_forward - .extend(overlay.dynamic_forward.iter().cloned()); + extend_forwardings_for_pass( + &mut base.dynamic_forward, + &mut base.dynamic_forward_args, + &overlay.dynamic_forward, + &overlay.dynamic_forward_args, + overlay.pass, + ); } if !overlay.forwarding_directives.is_empty() { - base.forwarding_directives - .extend(overlay.forwarding_directives.iter().cloned()); + extend_for_pass( + &mut base.forwarding_directives, + &overlay.forwarding_directives, + overlay.pass, + ); } if base.request_tty.is_none() && overlay.request_tty.is_some() { base.request_tty = overlay.request_tty.clone(); @@ -249,7 +427,13 @@ pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfi // For certificate files, we append them like identity files with deduplication and limit const MAX_CERTIFICATE_FILES: usize = 100; // Reasonable limit to prevent memory exhaustion - for cert_file in &overlay.certificate_files { + let arguments_are_aligned = base.certificate_file_args.len() + == base.certificate_files.len() + && overlay.certificate_file_args.len() == overlay.certificate_files.len(); + if !arguments_are_aligned { + base.certificate_file_args.clear(); + } + for (index, cert_file) in overlay.certificate_files.iter().enumerate() { // Skip if already present (deduplication) if !base.certificate_files.contains(cert_file) { if base.certificate_files.len() >= MAX_CERTIFICATE_FILES { @@ -260,6 +444,10 @@ pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfi break; } base.certificate_files.push(cert_file.clone()); + if arguments_are_aligned { + base.certificate_file_args + .push(overlay.certificate_file_args[index].clone()); + } } } } @@ -399,10 +587,73 @@ pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfi } } +fn extend_for_pass(base: &mut Vec, values: &[T], pass: ConfigPass) { + if pass == ConfigPass::FinalOnly { + let new_values = values + .iter() + .filter(|value| !base.contains(value)) + .cloned() + .collect::>(); + base.extend(new_values); + } else { + base.extend(values.iter().cloned()); + } +} + +fn extend_paths_for_pass( + base_paths: &mut Vec, + base_arguments: &mut Vec, + paths: &[PathBuf], + arguments: &[String], + pass: ConfigPass, +) { + if base_arguments.len() != base_paths.len() || arguments.len() != paths.len() { + extend_for_pass(base_paths, paths, pass); + base_arguments.clear(); + return; + } + if pass == ConfigPass::Any { + base_paths.extend_from_slice(paths); + base_arguments.extend_from_slice(arguments); + return; + } + for (path, argument) in paths.iter().zip(arguments) { + if !base_paths.contains(path) { + base_paths.push(path.clone()); + base_arguments.push(argument.clone()); + } + } +} + +fn extend_forwardings_for_pass( + base_values: &mut Vec, + base_arguments: &mut Vec>, + values: &[String], + arguments: &[Vec], + pass: ConfigPass, +) { + if arguments.len() != values.len() || base_arguments.len() != base_values.len() { + extend_for_pass(base_values, values, pass); + base_arguments.clear(); + return; + } + if pass == ConfigPass::Any { + base_values.extend_from_slice(values); + base_arguments.extend_from_slice(arguments); + return; + } + for (value, arguments) in values.iter().zip(arguments) { + if !base_values.contains(value) { + base_values.push(value.clone()); + base_arguments.push(arguments.clone()); + } + } +} + /// Get the effective hostname (resolves HostName directive) pub(super) fn get_effective_hostname(hosts: &[SshHostConfig], hostname: &str) -> String { let config = find_host_config(hosts, hostname); - config.hostname.unwrap_or_else(|| hostname.to_string()) + effective_hostname(&config, hostname) } /// Get the effective username diff --git a/src/ssh/ssh_config/resolver_tests.rs b/src/ssh/ssh_config/resolver_tests.rs index bdd527ff..72a06a76 100644 --- a/src/ssh/ssh_config/resolver_tests.rs +++ b/src/ssh/ssh_config/resolver_tests.rs @@ -257,6 +257,38 @@ Host * ); } + #[test] + fn later_host_user_does_not_retroactively_change_earlier_match() { + let hosts = parse( + "Match user audit-no-such-user\n Port 2200\nHost *\n User audit-no-such-user\n", + ) + .unwrap(); + + let config = find_host_config(&hosts, "alias"); + assert_eq!(config.port, None); + assert_eq!(config.user.as_deref(), Some("audit-no-such-user")); + } + + #[test] + fn match_final_cannot_supply_the_first_hostname() { + let hosts = parse("Host alias\nMatch final\n HostName final.example\n").unwrap(); + + let config = find_host_config(&hosts, "alias"); + assert_eq!(config.hostname.as_deref(), Some("alias")); + } + + #[test] + fn hostname_percent_h_expands_once_from_original_destination() { + let hosts = parse("Host alias\n HostName %h.example\n").unwrap(); + + let config = find_host_config(&hosts, "alias"); + assert_eq!(config.hostname.as_deref(), Some("%h.example")); + assert_eq!( + super::super::resolver::get_effective_hostname(&hosts, "alias"), + "alias.example" + ); + } + #[test] fn test_all_new_options_together() { let content = r#" diff --git a/src/ssh/ssh_config/security/string_validation.rs b/src/ssh/ssh_config/security/string_validation.rs index 08c28a79..d859ceea 100644 --- a/src/ssh/ssh_config/security/string_validation.rs +++ b/src/ssh/ssh_config/security/string_validation.rs @@ -139,7 +139,7 @@ fn validate_control_path_specific(value: &str, line_number: usize) -> Result<()> if chars[i] == '%' && i + 1 < chars.len() { let next_char = chars[i + 1]; match next_char { - 'h' | 'p' | 'r' | 'u' | 'L' | 'l' | 'n' | 'd' | '%' => { + 'C' | 'd' | 'h' | 'i' | 'k' | 'L' | 'l' | 'n' | 'p' | 'r' | 'u' | '%' => { // These are legitimate SSH substitution tokens i += 2; // Skip both % and the token character } @@ -147,7 +147,7 @@ fn validate_control_path_specific(value: &str, line_number: usize) -> Result<()> // Unknown substitution pattern - potentially dangerous anyhow::bail!( "Security violation: ControlPath contains unknown substitution pattern '%{next_char}' at line {line_number}. \ - Only %h, %p, %r, %u, %L, %l, %n, %d, and %% are allowed." + Only OpenSSH ControlPath tokens are allowed." ); } } @@ -316,7 +316,7 @@ pub fn validate_control_path(path: &str, line_number: usize) -> Result<()> { if chars[i] == '%' && i + 1 < chars.len() { let next_char = chars[i + 1]; match next_char { - 'h' | 'p' | 'r' | 'u' | 'L' | 'l' | 'n' | 'd' | '%' => { + 'C' | 'd' | 'h' | 'i' | 'k' | 'L' | 'l' | 'n' | 'p' | 'r' | 'u' | '%' => { // These are legitimate SSH substitution tokens i += 2; // Skip both % and the token character } @@ -324,7 +324,7 @@ pub fn validate_control_path(path: &str, line_number: usize) -> Result<()> { // Unknown substitution pattern - potentially dangerous anyhow::bail!( "Security violation: ControlPath contains unknown substitution pattern '%{next_char}' at line {line_number}. \ - Only %h, %p, %r, %u, %L, %l, %n, %d, and %% are allowed." + Only OpenSSH ControlPath tokens are allowed." ); } } diff --git a/src/ssh/ssh_config/types.rs b/src/ssh/ssh_config/types.rs index 8c999631..29ad2634 100644 --- a/src/ssh/ssh_config/types.rs +++ b/src/ssh/ssh_config/types.rs @@ -14,7 +14,7 @@ //! Core data structures for SSH configuration -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt; use std::path::PathBuf; @@ -31,9 +31,37 @@ pub enum ConfigBlock { Match(Vec), } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum ConfigPass { + #[default] + Any, + FinalOnly, +} + /// SSH configuration for a specific host or match block #[derive(Debug, Clone, PartialEq, Default)] pub struct SshHostConfig { + /// Parsing pass in which this source block is eligible. + pub(crate) pass: ConfigPass, + /// Match result already evaluated while expanding Includes for this pass. + /// + /// File-backed parsing must evaluate `Match exec` while streaming so it + /// can decide which Includes and context-changing directives are active. + /// Reusing that result prevents the resolver from executing the command a + /// second time for the same pass. In-memory parsing leaves this unset. + pub(crate) precomputed_match: Option, + /// Whether this Match block contains a positive `final` criterion. + /// + /// This differs from `precomputed_match`: during pass one, `final` is + /// false but parsing a positive `final` requests the second pass even when + /// an earlier runtime predicate does not match. + pub(crate) precomputed_requests_final: Option, + /// Whether all parent scopes at an Include site were active when the file + /// was expanded. This similarly prevents re-evaluating an outer + /// `Match exec` when the included blocks are resolved. + pub(crate) precomputed_scope_active: Option, + /// Parent Host/Match scopes active at an Include directive. + pub(crate) scope_guards: Vec, /// Block type (Host patterns or Match conditions) pub block_type: Option, /// Host patterns (for backward compatibility and Host blocks) @@ -42,6 +70,8 @@ pub struct SshHostConfig { pub user: Option, pub port: Option, pub identity_files: Vec, + /// Original IdentityFile arguments retained for OpenSSH-shaped `-G` output. + pub(crate) identity_file_args: Vec, pub proxy_jump: Option, pub proxy_command: Option, /// ProxyUseFdpass option - specifies whether ProxyCommand will pass a file descriptor @@ -82,6 +112,10 @@ pub struct SshHostConfig { pub local_forward: Vec, pub remote_forward: Vec, pub dynamic_forward: Vec, + /// Original argument boundaries retained for a reparse-safe `-G` dump. + pub(crate) local_forward_args: Vec>, + pub(crate) remote_forward_args: Vec>, + pub(crate) dynamic_forward_args: Vec>, /// Local, remote, and dynamic directives in source order. pub forwarding_directives: Vec, pub request_tty: Option, @@ -97,6 +131,8 @@ pub struct SshHostConfig { pub control_persist: Option, // Certificate authentication and advanced port forwarding pub certificate_files: Vec, + /// Original CertificateFile arguments retained for `-G` output. + pub(crate) certificate_file_args: Vec, pub ca_signature_algorithms: Vec, pub gateway_ports: Option, pub exit_on_forward_failure: Option, @@ -146,6 +182,11 @@ pub struct SshHostConfig { pub resolved_pubkey_accepted_algorithms: Option>, pub required_rsa_size: Option, pub fingerprint_hash: Option, // md5/sha256 + /// Canonical values for accepted keywords whose runtime behavior is not + /// implemented. Retaining them makes `-G` an honest inspection surface. + pub unimplemented_options: BTreeMap>, + /// Unknown keywords retained so strict inspection modes can reject them. + pub unknown_options: BTreeMap>, } impl fmt::Display for SshHostConfig { diff --git a/src/ssh/ssh_config/value.rs b/src/ssh/ssh_config/value.rs new file mode 100644 index 00000000..70152acc --- /dev/null +++ b/src/ssh/ssh_config/value.rs @@ -0,0 +1,108 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// Licensed under the Apache License, Version 2.0. + +//! OpenSSH-compatible configuration argument lexing and serialization. + +use anyhow::Result; + +pub(super) fn tokenize(input: &str, line_number: usize) -> Result> { + let chars = input.chars().collect::>(); + let mut result = Vec::new(); + let mut index = 0usize; + while index < chars.len() { + while index < chars.len() && matches!(chars[index], ' ' | '\t') { + index += 1; + } + if index == chars.len() || chars[index] == '#' { + break; + } + + let mut value = String::new(); + let mut quote = None; + while index < chars.len() { + let ch = chars[index]; + if ch == '\\' { + let next = chars.get(index + 1).copied(); + if next.is_some_and(|next| { + matches!(next, '\\' | '\'' | '"') || (quote.is_none() && next == ' ') + }) { + index += 1; + value.push(chars[index]); + } else { + value.push(ch); + } + } else if quote.is_none() && matches!(ch, '\'' | '"') { + quote = Some(ch); + } else if quote == Some(ch) { + quote = None; + } else if quote.is_none() && matches!(ch, ' ' | '\t') { + break; + } else { + value.push(ch); + } + index += 1; + } + if quote.is_some() { + anyhow::bail!("Invalid quotes at line {line_number}"); + } + result.push(value); + } + Ok(result) +} + +pub(crate) fn encode(value: &str) -> Result { + if value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n')) { + anyhow::bail!("Resolved SSH configuration contains an unsafe value"); + } + let needs_quotes = value.is_empty() + || value + .chars() + .any(|ch| matches!(ch, ' ' | '\t' | '#' | '\\' | '\'' | '"')); + if !needs_quotes { + return Ok(value.to_string()); + } + let mut encoded = String::with_capacity(value.len() + 2); + encoded.push('"'); + for ch in value.chars() { + if matches!(ch, '\\' | '\'' | '"') { + encoded.push('\\'); + } + encoded.push(ch); + } + encoded.push('"'); + Ok(encoded) +} + +#[cfg(test)] +mod tests { + use super::{encode, tokenize}; + + #[test] + fn matches_openssh_quote_escape_and_comment_rules() { + assert_eq!( + tokenize( + r#"one "two three" 'four' five\ six seven\#eight # comment"#, + 1 + ) + .unwrap(), + ["one", "two three", "four", "five six", r"seven\#eight"] + ); + assert!(tokenize("'unterminated", 1).is_err()); + } + + #[test] + fn encoded_values_round_trip_without_losing_boundaries() { + for value in [ + "", + "/tmp/a b", + "a\tb", + "a#b", + "#", + r#"a\"b'c"#, + r"unknown\qescape", + ] { + let encoded = encode(value).unwrap(); + assert_eq!(tokenize(&encoded, 1).unwrap(), [value]); + } + } +} diff --git a/tests/ssh_compat_output_test.rs b/tests/ssh_compat_output_test.rs index 069fce9c..07cbbab1 100644 --- a/tests/ssh_compat_output_test.rs +++ b/tests/ssh_compat_output_test.rs @@ -85,6 +85,12 @@ fn canonical_unimplemented_and_unknown_diagnostics_use_real_source_and_log_file( "# Source: /spoofed/config:9000\nChallengeResponseAuthentication no\nKbdInteractiveAuthentication yes\nSecurityKeyProvider /usr/lib/ssh/ssh-sk-helper\nDefinitelyUnknownOption yes\n", ) .expect("included ssh config should be written"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&included, fs::Permissions::from_mode(0o600)) + .expect("included ssh config permissions should be safe"); + } fs::write( &config, format!("Host *\n Include {}\n", included.display()), diff --git a/tests/ssh_config_dump_test.rs b/tests/ssh_config_dump_test.rs new file mode 100644 index 00000000..194393c1 --- /dev/null +++ b/tests/ssh_config_dump_test.rs @@ -0,0 +1,589 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +use std::path::Path; +use std::process::{Command, Output}; + +use tempfile::tempdir; + +mod fs { + pub use std::fs::read_to_string; + + pub fn write( + path: impl AsRef, + contents: impl AsRef<[u8]>, + ) -> std::io::Result<()> { + std::fs::write(&path, contents)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) + } +} + +fn run(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bssh")) + .env_remove("BSSH_PDSH_COMPAT") + .env_remove("RUST_LOG") + .args(arguments) + .output() + .expect("bssh should run") +} + +fn path(path: &Path) -> &str { + path.to_str().expect("temporary path should be UTF-8") +} + +#[test] +fn dump_exits_without_proxy_agent_prompt_or_connection_side_effects() { + let directory = tempdir().expect("temporary directory should be created"); + let config = directory.path().join("config"); + let marker = directory.path().join("proxy-ran"); + fs::write( + &config, + format!( + "Host target\n HostName does-not-resolve.invalid\n ProxyCommand sh -c 'touch {}'\n IdentityAgent /missing/agent.sock\n BatchMode no\n", + marker.display() + ), + ) + .expect("config should be written"); + + let output = run(&["-G", "-F", path(&config), "target"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!marker.exists(), "ProxyCommand must not execute in -G mode"); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("hostname does-not-resolve.invalid\n")); + assert!(stdout.contains("identityagent /missing/agent.sock\n")); +} + +#[test] +fn match_and_include_restore_parent_scope_for_destination() { + let directory = tempdir().expect("temporary directory should be created"); + let config = directory.path().join("config"); + let included = directory.path().join("target.conf"); + fs::write(&included, "User included\nHost other\n Port 9\n") + .expect("include should be written"); + fs::write( + &config, + format!( + "Host target\n Include {}/%h.conf\n Port 2200\nMatch user=included originalhost=target # comment\n IPQoS cs1\n", + directory.path().display() + ), + ) + .expect("config should be written"); + + let output = run(&["-GF", path(&config), "target"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("user included\n")); + assert!(stdout.contains("port 2200\n")); + assert!(stdout.contains("ipqos cs1 cs1\n")); +} + +#[test] +fn explicit_log_receives_success_warnings_and_fatal_errors() { + let directory = tempdir().expect("temporary directory should be created"); + let warning_config = directory.path().join("warning.conf"); + let warning_log = directory.path().join("warning.log"); + fs::write(&warning_config, "Host *\n TunnelDevice 1:2\n") + .expect("warning config should be written"); + let warning = run(&[ + "-G", + "-E", + path(&warning_log), + "-F", + path(&warning_config), + "host", + ]); + assert!(warning.status.success()); + assert!(warning.stderr.is_empty()); + assert!(String::from_utf8_lossy(&warning.stdout).contains("tunneldevice 1:2\n")); + assert!( + fs::read_to_string(&warning_log) + .expect("warning log should exist") + .contains("Unsupported SSH config option 'tunneldevice'") + ); + + let root_config = directory.path().join("invalid.conf"); + let child_config = directory.path().join("invalid-child.conf"); + let error_log = directory.path().join("error.log"); + fs::write( + &root_config, + format!("Include {}\n", child_config.display()), + ) + .expect("root config should be written"); + fs::write(&child_config, "Junk yes\n").expect("child config should be written"); + let invalid = run(&[ + "-G", + "-E", + path(&error_log), + "-F", + path(&root_config), + "host", + ]); + assert!(!invalid.status.success()); + assert!(invalid.stderr.is_empty()); + assert!( + fs::read_to_string(&error_log) + .expect("error log should exist") + .contains("Unknown SSH config option 'junk'") + ); +} + +#[test] +fn user_precedence_matches_openssh_second_argv_pass() { + let cases: &[(&[&str], &str)] = &[ + ( + &["-GF", "none", "-o", "user=foo", "-l", "bar", "baz@host"], + "foo", + ), + ( + &["-GF", "none", "-lbar", "baz@host", "user=foo", "baz@host"], + "bar", + ), + ( + &[ + "-GF", "none", "baz@host", "-o", "user=foo", "-l", "bar", "baz@host", + ], + "baz", + ), + ]; + for (arguments, expected) in cases { + let output = run(arguments); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains(&format!("user {expected}\n"))); + } +} + +#[test] +fn stdio_forward_sets_clear_all_forwardings_unless_explicitly_overridden() { + let implicit = run(&["-GF", "none", "-W", "localhost:9", "host"]); + assert!(implicit.status.success()); + assert!(String::from_utf8_lossy(&implicit.stdout).contains("clearallforwardings yes\n")); + assert!(String::from_utf8_lossy(&implicit.stdout).contains("exitonforwardfailure yes\n")); + + let explicit = run(&[ + "-GF", + "none", + "-W", + "localhost:9", + "-o", + "ClearAllForwardings=no", + "host", + ]); + assert!(explicit.status.success()); + assert!(String::from_utf8_lossy(&explicit.stdout).contains("clearallforwardings no\n")); + + let directory = tempdir().unwrap(); + let config = directory.path().join("config"); + fs::write( + &config, + "Host *\n ClearAllForwardings no\n ExitOnForwardFailure no\n", + ) + .unwrap(); + let from_file = run(&["-GF", path(&config), "-W", "localhost:9", "host"]); + assert!(from_file.status.success()); + let stdout = String::from_utf8_lossy(&from_file.stdout); + assert!(stdout.contains("clearallforwardings no\n")); + assert!(stdout.contains("exitonforwardfailure no\n")); +} + +#[test] +fn direct_argv_values_keep_spaces_hashes_and_quotes() { + let output = run(&[ + "-GF", + "none", + "-i", + "/tmp/a b#c", + "-S", + "/tmp/control 'quoted' #socket", + "-B", + "a b#c", + "host", + ]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains(r#"identityfile "/tmp/a b#c""#)); + assert!(stdout.contains(r#"controlpath "/tmp/control \'quoted\' #socket""#)); + assert!(stdout.contains(r#"bindinterface "a b#c""#)); +} + +#[test] +fn unbracketed_ipv6_destination_matches_ssh_config_dump_shape() { + let output = run(&["-GF", "none", "deploy@::1"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("host ::1\n")); + assert!(stdout.contains("hostname ::1\n")); + assert!(stdout.contains("user deploy\n")); +} + +#[test] +fn terminal_version_and_query_preempt_config_dump_without_destination() { + let version = run(&["-VG", "-Z"]); + assert!(version.status.success()); + assert!(String::from_utf8_lossy(&version.stderr).starts_with("bssh_")); + assert!(version.stdout.is_empty()); + + let query = run(&["-GQ", "cipher", "-Z"]); + assert!(query.status.success()); + assert!(!query.stdout.is_empty()); + assert!(query.stderr.is_empty()); + + let invalid = run(&["-GQ", "definitely-invalid"]); + assert_eq!(invalid.status.code(), Some(255)); + assert!(String::from_utf8_lossy(&invalid.stderr).contains("Unsupported query")); +} + +#[test] +fn include_expands_environment_and_repeated_host_tokens() { + let directory = tempdir().expect("temporary directory should be created"); + let included = directory.path().join("hosthost.conf"); + let config = directory.path().join("config"); + fs::write(&included, "Host host\n Port 2202\n").expect("include should be written"); + fs::write(&config, "Include ${REAL_FILE}/%h%h.conf\n").expect("config should be written"); + + let output = Command::new(env!("CARGO_BIN_EXE_bssh")) + .env_remove("BSSH_PDSH_COMPAT") + .env_remove("RUST_LOG") + .env("REAL_FILE", directory.path()) + .args(["-GF", path(&config), "host"]) + .output() + .expect("bssh should run"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("port 2202\n")); +} + +#[test] +fn inactive_include_scopes_validate_but_do_not_apply_values() { + let directory = tempdir().expect("temporary directory should be created"); + let config = directory.path().join("config"); + let included = directory.path().join("included.conf"); + fs::write( + &included, + "Host d\n HostName ddd\nHost e\n HostName eee\nMatch all\n HostName xxxx\n", + ) + .expect("include should be written"); + fs::write( + &config, + format!( + "Host d\n HostName dd\nHost e\n HostName ee\n Include {}\nHost n\n Include {}\n", + included.display(), + included.display() + ), + ) + .expect("config should be written"); + + for (host, expected) in [("d", "dd"), ("e", "ee"), ("x", "x")] { + let output = run(&["-GF", path(&config), host]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains(&format!("hostname {expected}\n")), + "unexpected output for {host}: {}", + String::from_utf8_lossy(&output.stdout) + ); + } +} + +#[test] +fn raw_dump_dispatch_treats_bssh_subcommand_names_as_destinations() { + for destination in ["list", "upload", "download", "ping"] { + let output = run(&["-GF", "none", destination]); + assert!( + output.status.success(), + "{destination}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains(&format!("host {destination}\n"))); + } +} + +#[test] +fn normal_mode_stdio_forward_fails_closed_before_connecting() { + let output = run(&["-W", "localhost:22", "host"]); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); +} + +#[test] +fn raw_dump_errors_use_status_255_and_post_destination_log_sink() { + for arguments in [ + vec!["-GF", "none", "-Z", "host"], + vec!["-GF", "none", "host", "-Z", "value"], + ] { + let output = run(&arguments); + assert_eq!(output.status.code(), Some(255)); + } + + let directory = tempdir().unwrap(); + let log = directory.path().join("argv-error.log"); + let output = run(&["-GF", "none", "host", "-Z", "-E", path(&log)]); + assert_eq!(output.status.code(), Some(255)); + assert!(output.stderr.is_empty()); + assert!( + fs::read_to_string(log) + .unwrap() + .contains("Unknown option '-Z'") + ); +} + +#[test] +fn stdio_forward_validates_ipv6_ports_and_services_without_dns() { + for target in ["[::1]:22", "host:ssh", "host:22"] { + let output = run(&["-GF", "none", "-W", target, "host"]); + assert!( + output.status.success(), + "{target}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + for target in ["::1:22", "host:0", "host:definitely-not-a-service"] { + let output = run(&["-GF", "none", "-W", target, "host"]); + assert_eq!(output.status.code(), Some(255), "{target}"); + } +} + +#[test] +fn direct_algorithm_and_inverse_flags_have_openssh_priority() { + for arguments in [ + [ + "-GF", + "none", + "-o", + "Ciphers=aes256-ctr", + "-c", + "aes128-ctr", + "host", + ], + [ + "-GF", + "none", + "-c", + "aes128-ctr", + "-o", + "Ciphers=aes256-ctr", + "host", + ], + ] { + let output = run(&arguments); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("ciphers aes128-ctr\n")); + } + for (flags, expected) in [("-GtT", "no"), ("-GTt", "yes")] { + let output = run(&["-F", "none", flags, "host"]); + assert!(output.status.success()); + assert!( + String::from_utf8_lossy(&output.stdout).contains(&format!("requesttty {expected}\n")) + ); + } +} + +#[test] +fn include_requires_an_exact_keyword_and_a_path() { + let directory = tempdir().unwrap(); + for (name, content) in [ + ("bare", "Include\n"), + ("prefix-space", "Included yes\n"), + ("prefix-equals", "Included=yes\n"), + ] { + let config = directory.path().join(name); + fs::write(&config, content).unwrap(); + let output = run(&["-GF", path(&config), "host"]); + assert_eq!(output.status.code(), Some(255), "{name}"); + } +} + +#[test] +#[cfg(unix)] +fn match_exec_uses_trusted_shell_without_leaking_output() { + let directory = tempdir().unwrap(); + let config = directory.path().join("config"); + fs::write( + &config, + concat!( + "Match exec=\"dd if=/dev/zero bs=1024 count=128; ", + "dd if=/dev/zero bs=1024 count=128 >&2; false || true\"\n", + " User shell-selected\n" + ), + ) + .unwrap(); + + let output = run(&["-GF", path(&config), "host"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + assert!(!output.stdout.contains(&0)); + assert!(String::from_utf8_lossy(&output.stdout).contains("user shell-selected\n")); +} + +#[test] +fn setenv_and_path_keywords_expand_at_their_openssh_dump_stages() { + let directory = tempdir().unwrap(); + let config = directory.path().join("config"); + let reparsed = directory.path().join("dumped-config"); + fs::write( + &config, + concat!( + "Host target\n", + " HostName final.example\n", + " SetEnv HOST=%h ENV=${BSSH_TEST_SETENV} ", + "LITERAL=$${BSSH_LITERAL}\n", + " IdentityFile ~/.ssh/%h-key\n", + " CertificateFile ~/.ssh/%h-cert.pub\n", + " UserKnownHostsFile ~/.ssh/%h-known-hosts\n" + ), + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_bssh")) + .env_remove("BSSH_PDSH_COMPAT") + .env_remove("RUST_LOG") + .env("BSSH_TEST_SETENV", "expanded-value") + .args(["-GF", path(&config), "target"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let home = std::env::var("HOME").unwrap(); + assert!(stdout.contains("setenv HOST=final.example\n")); + assert!(stdout.contains("setenv ENV=expanded-value\n")); + assert!(stdout.contains("setenv LITERAL=$${BSSH_LITERAL}\n")); + assert!(stdout.contains("identityfile ~/.ssh/%h-key\n")); + assert!(stdout.contains("certificatefile ~/.ssh/%h-cert.pub\n")); + assert!(stdout.contains(&format!( + "userknownhostsfile {home}/.ssh/final.example-known-hosts\n" + ))); + + fs::write(&reparsed, stdout.as_bytes()).unwrap(); + let second = Command::new(env!("CARGO_BIN_EXE_bssh")) + .env_remove("BSSH_PDSH_COMPAT") + .env_remove("RUST_LOG") + .env("BSSH_TEST_SETENV", "expanded-value") + .args(["-GF", path(&reparsed), "target"]) + .output() + .unwrap(); + assert!(second.status.success()); + assert_eq!(second.stdout, stdout.as_bytes()); +} + +#[test] +fn explicit_config_relative_includes_anchor_to_home_ssh() { + let directory = tempdir().unwrap(); + let home = directory.path().join("home"); + let ssh = home.join(".ssh"); + let elsewhere = directory.path().join("elsewhere"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + fs::write(ssh.join("first.conf"), "Include nested.conf\n").unwrap(); + fs::write(ssh.join("nested.conf"), "User anchored\n").unwrap(); + fs::write(elsewhere.join("nested.conf"), "User wrong\n").unwrap(); + let config = elsewhere.join("config"); + fs::write(&config, "Include first.conf\n").unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_bssh")) + .env_remove("BSSH_PDSH_COMPAT") + .env_remove("RUST_LOG") + .env("HOME", &home) + .args(["-GF", path(&config), "host"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("user anchored\n")); +} + +#[test] +fn cli_hostname_and_user_drive_streaming_include_and_match_selection() { + let directory = tempdir().unwrap(); + let home = directory.path().join("home"); + let ssh = home.join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + fs::write(ssh.join("effective.example.conf"), "ConnectionAttempts 4\n").unwrap(); + fs::write(ssh.join("user.conf"), "Port 2202\n").unwrap(); + let config = directory.path().join("config"); + fs::write( + &config, + "Include %h.conf\nMatch user cli-user\n Include user.conf\n", + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_bssh")) + .env_remove("BSSH_PDSH_COMPAT") + .env_remove("RUST_LOG") + .env("HOME", &home) + .args([ + "-GF", + path(&config), + "-o", + "HostName=effective.example", + "-l", + "cli-user", + "alias", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("hostname effective.example\n")); + assert!(stdout.contains("user cli-user\n")); + assert!(stdout.contains("connectionattempts 4\n")); + assert!(stdout.contains("port 2202\n")); +} + +#[test] +fn stdio_forward_clear_removes_rendered_explicit_forwards() { + let directory = tempdir().unwrap(); + let config = directory.path().join("config"); + fs::write(&config, "Host *\n LocalForward 8080 localhost:80\n").unwrap(); + + let output = run(&["-GF", path(&config), "-W", "localhost:22", "host"]); + assert!(output.status.success()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("localforward ")); +}