From ed39a79c09fcfb218c9ffa63a596eab60efce5f7 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Sun, 30 Aug 2026 21:12:31 +0900 Subject: [PATCH 1/9] feat(cli): add resolved ssh config dump Resolve OpenSSH-style configuration queries without starting connection services. Preserve argv provenance, contextual Match and Include behavior, and deterministic rendering for typed and retained configuration values. --- src/app/config_dump.rs | 44 ++ src/app/mod.rs | 1 + src/cli/bssh.rs | 14 + src/cli/mod.rs | 2 + src/cli/pdsh.rs | 2 + src/cli/ssh_args.rs | 361 +++++++++++++ src/main.rs | 7 + src/ssh/ssh_config/dump.rs | 499 ++++++++++++++++++ src/ssh/ssh_config/dump/tokens.rs | 141 +++++ src/ssh/ssh_config/dump_tests.rs | 46 ++ src/ssh/ssh_config/include/mod.rs | 121 ++++- src/ssh/ssh_config/match_directive/mod.rs | 135 +++-- src/ssh/ssh_config/mod.rs | 68 ++- src/ssh/ssh_config/parser/core.rs | 52 +- src/ssh/ssh_config/parser/mod.rs | 3 +- .../ssh_config/parser/options/forwarding.rs | 7 +- src/ssh/ssh_config/parser/options/mod.rs | 25 +- src/ssh/ssh_config/parser/options/support.rs | 6 +- src/ssh/ssh_config/parser/tests.rs | 35 +- src/ssh/ssh_config/pattern.rs | 11 +- src/ssh/ssh_config/resolver.rs | 86 ++- .../ssh_config/security/string_validation.rs | 8 +- src/ssh/ssh_config/types.rs | 9 +- tests/ssh_config_dump_test.rs | 228 ++++++++ 24 files changed, 1790 insertions(+), 121 deletions(-) create mode 100644 src/app/config_dump.rs create mode 100644 src/cli/ssh_args.rs create mode 100644 src/ssh/ssh_config/dump.rs create mode 100644 src/ssh/ssh_config/dump/tokens.rs create mode 100644 src/ssh/ssh_config/dump_tests.rs create mode 100644 tests/ssh_config_dump_test.rs diff --git a/src/app/config_dump.rs b/src/app/config_dump.rs new file mode 100644 index 00000000..49ba4594 --- /dev/null +++ b/src/app/config_dump.rs @@ -0,0 +1,44 @@ +// 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::{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(cli: &Cli, args: &[String]) -> Result<()> { + let destination = cli + .destination + .as_deref() + .context("-G requires a destination")?; + let invocation = SshDumpInvocation::from_argv(args, destination)?; + let mut config = match invocation.config_file.as_deref() { + Some(path) if path.as_os_str() == "none" => SshConfig::new(), + Some(path) => SshConfig::load_from_file_for_host(path, &invocation.destination) + .await + .with_context(|| format!("Failed to load SSH config from {path:?}"))?, + None => SshConfig::load_default_for_host(&invocation.destination).await?, + }; + 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 resolved = config.find_host_config(&invocation.destination); + 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/cli/bssh.rs b/src/cli/bssh.rs index f3fbd272..6f28c258 100644 --- a/src/cli/bssh.rs +++ b/src/cli/bssh.rs @@ -291,6 +291,20 @@ pub struct Cli { )] pub ssh_config: Option, + #[arg( + short = 'G', + long = "print-config", + help = "Print the resolved SSH configuration and exit" + )] + pub print_config: bool, + + #[arg( + short = 'W', + value_name = "host:port", + help = "Forward standard input/output to a host and port" + )] + pub stdio_forward: Option, + #[arg( short = 'q', long = "quiet", 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/pdsh.rs b/src/cli/pdsh.rs index cbb73020..6ccef9aa 100644 --- a/src/cli/pdsh.rs +++ b/src/cli/pdsh.rs @@ -320,6 +320,8 @@ impl PdshCli { cipher: None, macs: None, ssh_config: None, + print_config: false, + stdio_forward: None, quiet: false, force_tty: false, no_tty: false, diff --git a/src/cli/ssh_args.rs b/src/cli/ssh_args.rs new file mode 100644 index 00000000..16737a41 --- /dev/null +++ b/src/cli/ssh_args.rs @@ -0,0 +1,361 @@ +// 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 overrides: Vec, +} + +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 from_argv(args: &[String], destination: &str) -> Result { + let mut overrides = Vec::new(); + let mut config_file = None; + let mut index = 1usize; + let mut captured_destination = false; + let mut options_terminated = false; + let mut stdio_forward = false; + + while index < args.len() { + let argument = &args[index]; + if argument == "--" { + if captured_destination { + break; + } + options_terminated = true; + index += 1; + continue; + } + if argument == destination && !captured_destination { + add_destination_overrides(destination, &mut overrides)?; + captured_destination = true; + index += 1; + if options_terminated { + break; + } + continue; + } + if captured_destination && !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))); + match name { + "option" | "login" | "port" | "identity" | "jump-host" | "cipher" | "macs" + | "ssh-config" | "local-forward" | "remote-forward" | "dynamic-forward" => { + let (value, consumed) = value_for(args, index, attached, name)?; + apply_value(name, value, &mut config_file, &mut overrides)?; + stdio_forward |= name == "stdio-forward"; + index += consumed; + } + "ipv4" => overrides.push("AddressFamily=inet".to_string()), + "ipv6" => overrides.push("AddressFamily=inet6".to_string()), + "tty" => overrides.push("RequestTTY=yes".to_string()), + "no-tty" => overrides.push("RequestTTY=no".to_string()), + "no-x11" => overrides.push("ForwardX11=no".to_string()), + _ => {} + } + 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"), + _ => 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 overrides)?; + stdio_forward |= name == "stdio-forward"; + index += consumed; + break; + } + match short { + '4' => overrides.push("AddressFamily=inet".to_string()), + '6' => overrides.push("AddressFamily=inet6".to_string()), + 'A' => overrides.push("ForwardAgent=yes".to_string()), + 'x' => overrides.push("ForwardX11=no".to_string()), + 't' => overrides.push("RequestTTY=yes".to_string()), + 'T' => overrides.push("RequestTTY=no".to_string()), + _ => {} + } + } + } + index += 1; + } + + let destination = destination.strip_prefix("ssh://").unwrap_or(destination); + let parsed = crate::node::parse_node_spec(destination) + .context("Invalid destination for resolved configuration")?; + if stdio_forward { + for (keyword, implicit) in [ + ("clearallforwardings", "ClearAllForwardings=yes"), + ("exitonforwardfailure", "ExitOnForwardFailure=yes"), + ] { + if !overrides.iter().any(|option| { + option + .split_once('=') + .is_some_and(|(key, _)| key.eq_ignore_ascii_case(keyword)) + }) { + overrides.push(implicit.to_string()); + } + } + } + + Ok(Self { + destination: parsed.host.to_string(), + config_file, + overrides, + }) + } +} + +fn add_destination_overrides(destination: &str, overrides: &mut Vec) -> Result<()> { + let destination = destination.strip_prefix("ssh://").unwrap_or(destination); + let parsed = crate::node::parse_node_spec(destination) + .context("Invalid destination for resolved configuration")?; + if let Some(user) = parsed.user { + overrides.push(format!("User={}", literal_user(user)?)); + } + if let Some(port) = parsed.port { + overrides.push(format!("Port={port}")); + } + Ok(()) +} + +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, + overrides: &mut Vec, +) -> Result<()> { + let option = match name { + "option" => value.to_string(), + "login" => format!("User={}", literal_user(value)?), + "port" => { + value + .parse::() + .with_context(|| format!("Invalid port '{value}'"))?; + format!("Port={value}") + } + "identity" => format!("IdentityFile={value}"), + "jump-host" => format!("ProxyJump={value}"), + "cipher" => format!("Ciphers={value}"), + "macs" => format!("MACs={value}"), + "local-forward" => format!("LocalForward={value}"), + "remote-forward" => format!("RemoteForward={value}"), + "dynamic-forward" => format!("DynamicForward={value}"), + "stdio-forward" => return Ok(()), + "diagnostic-file" | "query" => return Ok(()), + "ssh-config" => { + *config_file = Some(PathBuf::from(value)); + return Ok(()); + } + _ => return Ok(()), + }; + overrides.push(option); + Ok(()) +} + +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, "host").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, "user@[::1]:2300").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, "host").unwrap(); + assert!( + parsed + .overrides + .contains(&"ClearAllForwardings=yes".to_string()) + ); + assert!( + parsed + .overrides + .contains(&"ExitOnForwardFailure=yes".to_string()) + ); + + let explicit = args(&[ + "bssh", + "-GF", + "none", + "-W", + "a:1", + "-o", + "ClearAllForwardings=no", + "host", + ]); + let parsed = SshDumpInvocation::from_argv(&explicit, "host").unwrap(); + assert!( + !parsed + .overrides + .contains(&"ClearAllForwardings=yes".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, "baz@host").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, "host").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, "host").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, "host").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, "host").is_err()); + } + } +} diff --git a/src/main.rs b/src/main.rs index 543aab60..67945dca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ mod app; use app::{ cache::handle_cache_stats, + config_dump::handle_config_dump, dispatcher::dispatch_command, initialization::{AppContext, initialize_app}, query::handle_query, @@ -283,6 +284,12 @@ async fn run_bssh_mode(args: &[String]) -> Result<()> { bssh::utils::diagnostics::set_log_file(path)?; } + // `-G` uses the minimal diagnostic sink above, but remains before + // Backend.AI discovery, DNS, agents, prompts, proxies, and all networking. + if cli.print_config { + return handle_config_dump(&cli, args).await; + } + // Handle SSH query option (-Q) if let Some(ref query) = cli.query { handle_query(query); diff --git a/src/ssh/ssh_config/dump.rs b/src/ssh/ssh_config/dump.rs new file mode 100644 index 00000000..0537a0b8 --- /dev/null +++ b/src/ssh/ssh_config/dump.rs @@ -0,0 +1,499 @@ +// 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 std::fmt::Write as _; +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +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 = tokens.expand(&tokens.effective_host)?; + tokens.remote_user = tokens.expand(&tokens.remote_user)?; + tokens.refresh_hash(config.proxy_jump.as_deref().unwrap_or("")); + + output.line("host", original_host)?; + output.line("user", &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(&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( + "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.expanded("bindaddress", config.bind_address.as_deref(), &tokens)?; + output.expanded("bindinterface", config.bind_interface.as_deref(), &tokens)?; + output.line("ciphers", cipher_names(config))?; + if let Some(value) = config.control_path.as_deref() { + output.line("controlpath", tokens.expand_path(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(value)?)?; + } + output.expanded("localcommand", config.local_command.as_deref(), &tokens)?; + output.expanded("remotecommand", config.remote_command.as_deref(), &tokens)?; + output.expanded( + "knownhostscommand", + config.known_hosts_command.as_deref(), + &tokens, + )?; + if let Some(proxy_jump) = config.proxy_jump.as_deref() { + output.line("proxyjump", tokens.expand(proxy_jump)?)?; + } else { + let proxy_command = config + .proxy_command + .as_deref() + .map(|value| tokens.expand(value)) + .transpose()? + .unwrap_or_else(|| "none".to_string()); + output.line("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().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", + list_or( + &config.hostbased_accepted_algorithms, + &host_key_names(config), + ), + )?; + output.line( + "casignaturealgorithms", + list_or(&config.ca_signature_algorithms, &pubkey_names(config)), + )?; + + for identity in identity_files(config) { + output.line("identityfile", identity.to_string_lossy())?; + } + for certificate in &config.certificate_files { + output.line("certificatefile", certificate.to_string_lossy())?; + } + let user_hosts = config.user_known_hosts_file.clone().unwrap_or_else(|| { + vec![ + "~/.ssh/known_hosts".to_string(), + "~/.ssh/known_hosts2".to_string(), + ] + }); + output.line("userknownhostsfile", tokens.expand(&user_hosts.join(" "))?)?; + 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.line( + "globalknownhostsfile", + tokens.expand(&global_hosts.join(" "))?, + )?; + 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(value)?))?; + } + for value in &config.local_forward { + output.line("localforward", tokens.expand(value)?)?; + } + for value in &config.remote_forward { + output.line("remoteforward", tokens.expand(value)?)?; + } + for value in &config.dynamic_forward { + output.line("dynamicforward", tokens.expand(value)?)?; + } + output.line( + "permitremoteopen", + if config.permit_remote_open.is_empty() { + "any".to_string() + } else { + config.permit_remote_open.join(" ") + }, + )?; + 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.line("ipqos", format_ipqos(config.ipqos.unwrap_or_default()))?; + output.line( + "rekeylimit", + format_rekey(config.rekey_limit.unwrap_or_default()), + )?; + + for (keyword, args) in &config.unimplemented_options { + if !EMITTED_UNIMPLEMENTED.contains(&keyword.as_str()) { + output.line(keyword, tokens.expand(&args.join(" "))?)?; + } + } + 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", +]; + +#[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"); + } + 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 expanded( + &mut self, + keyword: &str, + value: Option<&str>, + tokens: &TokenContext, + ) -> Result<()> { + if let Some(value) = value { + self.line(keyword, tokens.expand(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 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 { + if !config.identity_files.is_empty() { + return config.identity_files.clone(); + } + let base = dirs::home_dir().unwrap_or_default().join(".ssh"); + [ + "id_rsa", + "id_ecdsa", + "id_ecdsa_sk", + "id_ed25519", + "id_ed25519_sk", + "id_xmss", + "id_dsa", + ] + .into_iter() + .map(|name| base.join(name)) + .collect() +} + +fn format_ipqos(policy: IpQosPolicy) -> String { + format!( + "{} {}", + 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) -> String { + 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, + }; + format!("{data} {time}") +} diff --git a/src/ssh/ssh_config/dump/tokens.rs b/src/ssh/ssh_config/dump/tokens.rs new file mode 100644 index 00000000..7a9fb711 --- /dev/null +++ b/src/ssh/ssh_config/dump/tokens.rs @@ -0,0 +1,141 @@ +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, + 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()), + connection_hash: String::new(), + } + } + + pub(super) fn refresh_hash(&mut self, jump: &str) { + 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, + '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) + } +} + +#[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..d69a43ff --- /dev/null +++ b/src/ssh/ssh_config/dump_tests.rs @@ -0,0 +1,46 @@ +use super::{SshConfig, SshHostConfig, render_resolved_config}; + +#[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 + ForwardAgent /tmp/%h-agent + 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("ciphers +")); + assert!(!first.contains("kexalgorithms -")); +} + +#[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")); +} diff --git a/src/ssh/ssh_config/include/mod.rs b/src/ssh/ssh_config/include/mod.rs index e62b9610..3cf711e6 100644 --- a/src/ssh/ssh_config/include/mod.rs +++ b/src/ssh/ssh_config/include/mod.rs @@ -87,7 +87,7 @@ impl IncludeContext { } /// Enter a new include level - fn enter_include(&mut self, path: &Path) -> Result<()> { + fn enter_include(&mut self, path: &Path) -> Result<(String, PathBuf)> { self.can_include()?; // Check cache first to avoid repeated canonicalization @@ -121,7 +121,8 @@ impl IncludeContext { ); } - self.visited.insert(canonical_str); + self.visited.insert(canonical_str.clone()); + let previous_base_dir = self.base_dir.clone(); self.depth += 1; self.file_count += 1; @@ -135,14 +136,16 @@ impl IncludeContext { self.canonical_cache.clear(); } - Ok(()) + Ok((canonical_str, previous_base_dir)) } /// Exit an include level - fn exit_include(&mut self) { + fn exit_include(&mut self, canonical: &str, previous_base_dir: PathBuf) { if self.depth > 0 { self.depth -= 1; } + self.visited.remove(canonical); + self.base_dir = previous_base_dir; } } @@ -156,11 +159,22 @@ pub struct IncludedFile { pub content: String, /// One-based line number of the first content line in the source file. pub source_line_start: usize, + /// 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> { + 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 mut context = IncludeContext::new(config_path); // Mark the main file as visited to prevent cycles @@ -179,7 +193,7 @@ pub async fn resolve_includes(config_path: &Path, content: &str) -> Result, + inherited_scope: &str, + scope_guards: &[String], ) -> Result> { let mut result = Vec::new(); let mut current_content = String::new(); let mut current_source_line = 1; + let mut active_scope = inherited_scope.to_string(); + 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 @@ -204,6 +224,7 @@ async fn process_file_with_includes( path: file_path.to_path_buf(), content: current_content.clone(), source_line_start: current_source_line, + scope_guards: scope_guards.to_vec(), }); current_content.clear(); } @@ -211,7 +232,17 @@ async fn process_file_with_includes( // 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 = hostname + .map_or_else(|| pattern.to_string(), |host| pattern.replace("%h", host)); + let expanded_pattern = if expanded_environment == pattern { + expanded_pattern + } else { + hostname.map_or(expanded_environment.clone(), |host| { + expanded_environment.replace("%h", host) + }) + }; + let resolved_files = resolve_include_pattern(&expanded_pattern, context) .await .with_context(|| { format!( @@ -224,9 +255,10 @@ async fn process_file_with_includes( // Process each resolved file recursively for include_path in resolved_files { - context.enter_include(&include_path).with_context(|| { - format!("Failed to include file: {}", escape_path(&include_path)) - })?; + let (canonical, previous_base_dir) = + context.enter_include(&include_path).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( @@ -247,24 +279,62 @@ async fn process_file_with_includes( ) })?; + 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, + hostname, + &active_scope, + &child_guards, )) .await?; // Add all files from the included file to result result.append(&mut included_files); - context.exit_include(); + context.exit_include(&canonical, previous_base_dir); } } + 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'); + } + 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'); + } + pending_scope_restore = false; + } // Regular line - add to current content current_content.push_str(line); current_content.push('\n'); + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("host ") + || lower.starts_with("host=") + || lower.starts_with("match ") + || lower.starts_with("match=") + { + active_scope = trimmed.to_string(); + } } } @@ -274,6 +344,7 @@ async fn process_file_with_includes( path: file_path.to_path_buf(), content: current_content, source_line_start: current_source_line, + scope_guards: scope_guards.to_vec(), }); } @@ -283,12 +354,42 @@ async fn process_file_with_includes( path: file_path.to_path_buf(), content: content.to_string(), source_line_start: 1, + scope_guards: scope_guards.to_vec(), }); } Ok(result) } +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 { diff --git a/src/ssh/ssh_config/match_directive/mod.rs b/src/ssh/ssh_config/match_directive/mod.rs index f8ad34a6..05ebc28b 100644 --- a/src/ssh/ssh_config/match_directive/mod.rs +++ b/src/ssh/ssh_config/match_directive/mod.rs @@ -20,7 +20,7 @@ use anyhow::Result; use std::collections::HashMap; -use super::pattern::matches_pattern; +use super::pattern::matches_host_pattern; mod exec; @@ -33,6 +33,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 +43,8 @@ pub enum MatchCondition { Exec(String), /// Match all connections (always true) All, + /// Negation of one Match attribute. + Negated(Box), } /// A Match block with its conditions and configuration @@ -82,6 +86,8 @@ impl MatchBlock { 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 @@ -93,6 +99,15 @@ pub struct MatchContext { impl MatchContext { /// Create a new match context pub fn new(hostname: String, remote_user: Option) -> Result { + 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 { // Get local username let local_user = whoami::username().unwrap_or_else(|_| "user".to_string()); @@ -109,6 +124,7 @@ impl MatchContext { Ok(Self { hostname, + original_hostname, remote_user, local_user, variables, @@ -138,35 +154,59 @@ impl MatchCondition { if conditions_str.is_empty() { anyhow::bail!("Match directive requires conditions at line {line_number}"); } + let conditions_str = conditions_str + .find(" #") + .map_or(conditions_str, |comment| &conditions_str[..comment]); // Parse conditions let mut conditions = Vec::new(); let mut parts = conditions_str.split_whitespace(); - while let Some(keyword) = parts.next() { + while let Some(token) = parts.next() { + 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, &mut parts)?; 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, &mut parts)?; + 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, &mut parts)?; 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, &mut parts)?; 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 @@ -186,11 +226,11 @@ impl MatchCondition { remaining.join(" ") }; - conditions.push(MatchCondition::Exec(command)); + push_condition(&mut conditions, MatchCondition::Exec(command), negated); break; // Exec consumes the rest of the line } "all" => { - conditions.push(MatchCondition::All); + push_condition(&mut conditions, MatchCondition::All, negated); } _ => { anyhow::bail!("Unknown Match condition '{keyword}' at line {line_number}"); @@ -208,34 +248,20 @@ 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) + Ok(matches_host_pattern(&context.local_user, patterns)) } MatchCondition::Exec(command) => { // Execute the command and check exit status @@ -245,28 +271,57 @@ impl MatchCondition { // Always matches Ok(true) } + MatchCondition::Negated(condition) => Ok(!condition.matches(context)?), } } } +fn push_condition(conditions: &mut Vec, condition: MatchCondition, negated: bool) { + conditions.push(if negated { + MatchCondition::Negated(Box::new(condition)) + } else { + condition + }); +} + /// Collect patterns until the next keyword -fn collect_patterns(parts: &mut std::str::SplitWhitespace) -> Result> { - let mut patterns = Vec::new(); +fn collect_patterns( + attached: Option<&str>, + parts: &mut std::str::SplitWhitespace, +) -> Result> { + 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(); // Peek at upcoming parts to collect patterns let remaining: Vec<&str> = parts.clone().collect(); for part in remaining { // Stop if we hit another Match keyword - let lower = part.to_lowercase(); + let lower = part + .split_once('=') + .map_or(part, |(keyword, _)| keyword) + .trim_start_matches('!') + .to_lowercase(); if matches!( lower.as_str(), - "host" | "user" | "localuser" | "exec" | "all" + "host" | "originalhost" | "user" | "localuser" | "exec" | "all" ) { break; } - patterns.push(part.to_string()); + patterns.extend( + part.split(',') + .filter(|pattern| !pattern.is_empty()) + .map(str::to_string), + ); // Consume the part from the iterator parts.next(); } @@ -387,20 +442,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 +463,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 +499,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()); } diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index 1132d8d9..0d028a93 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)] @@ -44,6 +47,7 @@ mod security_fix_tests; mod types; // 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 +95,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; + 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; + 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 +150,64 @@ impl SshConfig { }) } + /// Load a file with host-dependent Include tokens resolved for `hostname`. + pub async fn load_from_file_for_host>(path: P, hostname: &str) -> Result { + let path = path.as_ref(); + let content = tokio::fs::read_to_string(path).await.with_context(|| { + format!( + "Failed to read SSH config file: {}", + diagnostic::escape_path(path) + ) + })?; + let mut reported_diagnostics = HashSet::new(); + let hosts = parser::parse_from_file_for_host_with_diagnostics( + path, + &content, + hostname, + &mut reported_diagnostics, + ) + .await?; + Ok(Self { + hosts, + reported_diagnostics, + }) + } + + /// Load user and system configuration, in OpenSSH precedence order. + pub async fn load_default_for_host(hostname: &str) -> Result { + 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) { + config.append(Self::load_from_file_for_host(&user_config, hostname).await?); + } + } + let system_config = Path::new("/etc/ssh/ssh_config"); + if tokio::fs::try_exists(system_config).await.unwrap_or(false) { + config.append(Self::load_from_file_for_host(system_config, hostname).await?); + } + Ok(config) + } + + 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) diff --git a/src/ssh/ssh_config/parser/core.rs b/src/ssh/ssh_config/parser/core.rs index 6c385701..071e250e 100644 --- a/src/ssh/ssh_config/parser/core.rs +++ b/src/ssh/ssh_config/parser/core.rs @@ -17,7 +17,7 @@ //! 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}; 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}; @@ -58,6 +58,19 @@ pub(crate) async fn parse_from_file_with_diagnostics( parse_included_files(&included_files, reported_diagnostics) } +/// Parse a config file while resolving host-dependent Include paths. +pub(crate) async fn parse_from_file_for_host_with_diagnostics( + path: &Path, + content: &str, + hostname: &str, + reported_diagnostics: &mut HashSet, +) -> Result> { + let included_files = resolve_includes_for_host(path, content, Some(hostname)) + .await + .with_context(|| format!("Failed to resolve includes for {}", escape_path(path)))?; + parse_included_files(&included_files, reported_diagnostics) +} + /// Parse SSH configuration content without Include resolution pub(super) fn parse_without_includes( content: &str, @@ -67,7 +80,7 @@ pub(super) fn parse_without_includes( content .lines() .enumerate() - .map(|(index, line)| (None, index + 1, line)), + .map(|(index, line)| (None, index + 1, line, &[][..])), reported_diagnostics, ) } @@ -131,6 +144,7 @@ fn parse_included_files( Some(file.path.as_path()), file.source_line_start + index, line, + file.scope_guards.as_slice(), ) }) }), @@ -139,7 +153,7 @@ fn parse_included_files( } fn parse_lines<'a>( - lines: impl IntoIterator, usize, &'a str)>, + lines: impl IntoIterator, usize, &'a str, &'a [String])>, reported_diagnostics: &mut HashSet, ) -> Result> { // Security: Set reasonable limits to prevent DoS attacks @@ -150,7 +164,7 @@ 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) 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"); @@ -200,6 +214,7 @@ fn parse_lines<'a>( // Create config for this Match block let config = SshHostConfig { block_type: Some(ConfigBlock::Match(conditions)), + scope_guards: parse_scope_guards(scope_guards, line_number)?, ..Default::default() }; match_block.config = config; @@ -231,6 +246,7 @@ fn parse_lines<'a>( let config = SshHostConfig { host_patterns: patterns.clone(), block_type: Some(ConfigBlock::Host(patterns)), + scope_guards: parse_scope_guards(scope_guards, line_number)?, ..Default::default() }; @@ -279,11 +295,15 @@ 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()])), + 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 +329,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 diff --git a/src/ssh/ssh_config/parser/mod.rs b/src/ssh/ssh_config/parser/mod.rs index 8a5937ab..13dc3012 100644 --- a/src/ssh/ssh_config/parser/mod.rs +++ b/src/ssh/ssh_config/parser/mod.rs @@ -32,7 +32,8 @@ mod tests; #[cfg(test)] pub(super) use core::parse; pub(super) use core::{ - parse_cli_options, parse_from_file_with_diagnostics, parse_with_diagnostics, + parse_cli_options, parse_from_file_for_host_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/forwarding.rs b/src/ssh/ssh_config/parser/options/forwarding.rs index 73211014..df308894 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() { diff --git a/src/ssh/ssh_config/parser/options/mod.rs b/src/ssh/ssh_config/parser/options/mod.rs index 05b6a09f..564a1166 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,18 @@ 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 { + 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 +186,8 @@ pub fn parse_option( | "userknownhostsfile2" | "useroaming" | "usersh" - | "useprivilegedport" => Ok(()), + | "useprivilegedport" + | "tunneldevice" => Ok(()), _ => unreachable!("accepted keyword is missing a parser: {keyword}"), } } diff --git a/src/ssh/ssh_config/parser/options/support.rs b/src/ssh/ssh_config/parser/options/support.rs index 39eef257..129e8de9 100644 --- a/src/ssh/ssh_config/parser/options/support.rs +++ b/src/ssh/ssh_config/parser/options/support.rs @@ -240,6 +240,7 @@ pub(super) const ACCEPTED_KEYWORDS: &[(&str, &str, KeywordSupport)] = &[ ("useroaming", "useroaming", Unimplemented), ("usersh", "usersh", Unimplemented), ("useprivilegedport", "useprivilegedport", Unimplemented), + ("tunneldevice", "tunneldevice", Unimplemented), ]; pub(super) fn keyword_spec(keyword: &str) -> Option { @@ -258,9 +259,9 @@ mod tests { use super::*; use std::collections::HashSet; - const ACCEPTED_SPELLING_COUNT: usize = 91; + const ACCEPTED_SPELLING_COUNT: usize = 92; const RUNTIME_SPELLING_COUNT: usize = 51; - const UNIMPLEMENTED_SPELLING_COUNT: usize = 40; + const UNIMPLEMENTED_SPELLING_COUNT: usize = 41; #[test] fn accepted_keywords_and_aliases_have_one_consistent_classification() { @@ -410,6 +411,7 @@ mod tests { "useroaming", "usersh", "useprivilegedport", + "tunneldevice", ]; 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..3b8786ca 100644 --- a/src/ssh/ssh_config/parser/tests.rs +++ b/src/ssh/ssh_config/parser/tests.rs @@ -1894,42 +1894,23 @@ async fn test_includes_preserve_global_and_host_first_obtained_context() { 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] 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..d8e29bb9 100644 --- a/src/ssh/ssh_config/resolver.rs +++ b/src/ssh/ssh_config/resolver.rs @@ -33,17 +33,57 @@ pub(super) fn find_host_config_with_user( hostname: &str, remote_user: Option<&str>, ) -> SshHostConfig { + // Host blocks match the destination as written. Resolve those first so + // Match host/user sees the effective HostName and remote user, as OpenSSH + // does during its final configuration pass. + let mut preliminary = SshHostConfig::default(); + for host_config in hosts { + let current_hostname = preliminary + .hostname + .clone() + .unwrap_or_else(|| hostname.to_string()); + let current_user = remote_user + .map(str::to_string) + .or_else(|| preliminary.user.clone()) + .or_else(|| whoami::username().ok()); + let preliminary_context = MatchContext::with_original_hostname( + current_hostname, + hostname.to_string(), + current_user, + ) + .ok(); + let is_host_match = match &host_config.block_type { + Some(ConfigBlock::Host(patterns)) => matches_host_pattern(hostname, patterns), + Some(ConfigBlock::Match(_)) => false, + None => matches_host_pattern(hostname, &host_config.host_patterns), + } && scopes_match(host_config, hostname, preliminary_context.as_ref()); + if is_host_match { + merge_host_config(&mut preliminary, host_config); + } + } + + let effective_hostname = preliminary + .hostname + .clone() + .unwrap_or_else(|| hostname.to_string()); + let effective_user = remote_user + .map(str::to_string) + .or_else(|| preliminary.user.clone()) + .or_else(|| whoami::username().ok()); let mut merged_config = SshHostConfig::default(); // 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 - } - }; + let match_context = match MatchContext::with_original_hostname( + effective_hostname, + hostname.to_string(), + effective_user, + ) { + Ok(ctx) => Some(ctx), + Err(e) => { + tracing::warn!("Failed to create match context: {}", e); + None + } + }; for host_config in hosts { let should_apply = match &host_config.block_type { @@ -77,7 +117,7 @@ pub(super) fn find_host_config_with_user( } }; - if should_apply { + if should_apply && scopes_match(host_config, hostname, match_context.as_ref()) { merge_host_config(&mut merged_config, host_config); } } @@ -85,8 +125,36 @@ pub(super) fn find_host_config_with_user( merged_config } +fn scopes_match( + config: &SshHostConfig, + original_hostname: &str, + context: Option<&MatchContext>, +) -> bool { + 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(); 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..3645bffd 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; @@ -34,6 +34,8 @@ pub enum ConfigBlock { /// SSH configuration for a specific host or match block #[derive(Debug, Clone, PartialEq, Default)] pub struct SshHostConfig { + /// 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) @@ -146,6 +148,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/tests/ssh_config_dump_test.rs b/tests/ssh_config_dump_test.rs new file mode 100644 index 00000000..ed96d2ec --- /dev/null +++ b/tests/ssh_config_dump_test.rs @@ -0,0 +1,228 @@ +// 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::fs; +use std::path::Path; +use std::process::{Command, Output}; + +use tempfile::tempdir; + +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, + "Host target\n Include %h.conf\n Port 2200\nMatch user=included originalhost=target # comment\n IPQoS cs1\n", + ) + .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, "Include invalid-child.conf\n").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")); +} + +#[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) + ); + } +} From 189de701ce1ba74af82196085fe51acc44e59dfd Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Sun, 30 Aug 2026 22:49:20 +0900 Subject: [PATCH 2/9] fix(cli): harden resolved SSH config dumping Make raw -G dispatch fail closed before normal CLI initialization, preserve OpenSSH argv and Include semantics, and emit deterministic reparse-safe resolved configuration. Add final-pass matching, retained keyword defaults, permission-aware Include traversal, and focused differential regressions. Refs: #282 --- src/app/config_dump.rs | 39 +- src/cli/bssh.rs | 14 - src/cli/pdsh.rs | 2 - src/cli/ssh_args.rs | 518 ++++++++++++++++-- src/main.rs | 30 +- src/ssh/ssh_config/dump.rs | 248 +++++++-- src/ssh/ssh_config/dump_tests.rs | 109 ++++ src/ssh/ssh_config/include/mod.rs | 409 ++++++++++---- src/ssh/ssh_config/include/resolver.rs | 162 +++--- src/ssh/ssh_config/include/validation.rs | 104 +--- ...certificate_forwarding_integration_test.rs | 42 +- src/ssh/ssh_config/match_directive/mod.rs | 122 +++-- src/ssh/ssh_config/mod.rs | 264 ++++++++- src/ssh/ssh_config/parser/core.rs | 206 +++---- src/ssh/ssh_config/parser/mod.rs | 4 +- src/ssh/ssh_config/parser/options/mod.rs | 181 +++++- src/ssh/ssh_config/parser/options/support.rs | 58 +- src/ssh/ssh_config/parser/tests.rs | 61 +-- src/ssh/ssh_config/resolver.rs | 131 ++++- src/ssh/ssh_config/types.rs | 9 + src/ssh/ssh_config/value.rs | 108 ++++ tests/ssh_config_dump_test.rs | 239 +++++++- 22 files changed, 2402 insertions(+), 658 deletions(-) create mode 100644 src/ssh/ssh_config/value.rs diff --git a/src/app/config_dump.rs b/src/app/config_dump.rs index 49ba4594..caf9562e 100644 --- a/src/app/config_dump.rs +++ b/src/app/config_dump.rs @@ -8,26 +8,37 @@ use std::io::Write as _; use anyhow::{Context, Result}; -use bssh::cli::{Cli, SshDumpInvocation}; +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(cli: &Cli, args: &[String]) -> Result<()> { - let destination = cli - .destination - .as_deref() - .context("-G requires a destination")?; - let invocation = SshDumpInvocation::from_argv(args, destination)?; +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_from_file_for_host(path, &invocation.destination) - .await - .with_context(|| format!("Failed to load SSH config from {path:?}"))?, - None => SshConfig::load_default_for_host(&invocation.destination).await?, + Some(path) => SshConfig::load_from_file_for_host_with_options( + path, + &invocation.destination, + &invocation.overrides, + ) + .await + .with_context(|| format!("Failed to load SSH config from {path:?}"))?, + None => { + SshConfig::load_default_for_host_with_options( + &invocation.destination, + &invocation.overrides, + ) + .await? + } }; - config - .apply_cli_options(&invocation.overrides) - .context("Failed to apply command-line SSH options")?; + 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() diff --git a/src/cli/bssh.rs b/src/cli/bssh.rs index 6f28c258..f3fbd272 100644 --- a/src/cli/bssh.rs +++ b/src/cli/bssh.rs @@ -291,20 +291,6 @@ pub struct Cli { )] pub ssh_config: Option, - #[arg( - short = 'G', - long = "print-config", - help = "Print the resolved SSH configuration and exit" - )] - pub print_config: bool, - - #[arg( - short = 'W', - value_name = "host:port", - help = "Forward standard input/output to a host and port" - )] - pub stdio_forward: Option, - #[arg( short = 'q', long = "quiet", diff --git a/src/cli/pdsh.rs b/src/cli/pdsh.rs index 6ccef9aa..cbb73020 100644 --- a/src/cli/pdsh.rs +++ b/src/cli/pdsh.rs @@ -320,8 +320,6 @@ impl PdshCli { cipher: None, macs: None, ssh_config: None, - print_config: false, - stdio_forward: None, quiet: false, force_tty: false, no_tty: false, diff --git a/src/cli/ssh_args.rs b/src/cli/ssh_args.rs index 16737a41..cf62acd8 100644 --- a/src/cli/ssh_args.rs +++ b/src/cli/ssh_args.rs @@ -17,6 +17,7 @@ use anyhow::{Context, Result}; pub struct SshDumpInvocation { pub destination: String, pub config_file: Option, + pub log_file: Option, pub overrides: Vec, } @@ -24,34 +25,47 @@ 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 from_argv(args: &[String], destination: &str) -> Result { + 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 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 captured_destination = false; + let mut destination = None; let mut options_terminated = false; let mut stdio_forward = false; + let mut saw_dump = false; while index < args.len() { let argument = &args[index]; if argument == "--" { - if captured_destination { + if destination.is_some() { break; } options_terminated = true; index += 1; continue; } - if argument == destination && !captured_destination { - add_destination_overrides(destination, &mut overrides)?; - captured_destination = true; + if !argument.starts_with('-') && destination.is_none() { + add_destination_overrides(argument, &mut overrides)?; + destination = Some(argument.clone()); index += 1; if options_terminated { break; } continue; } - if captured_destination && !argument.starts_with('-') { + if destination.is_some() && !argument.starts_with('-') { break; } @@ -59,20 +73,42 @@ impl SshDumpInvocation { let (name, attached) = long .split_once('=') .map_or((long, None), |(name, value)| (name, Some(value))); - match name { - "option" | "login" | "port" | "identity" | "jump-host" | "cipher" | "macs" - | "ssh-config" | "local-forward" | "remote-forward" | "dynamic-forward" => { - let (value, consumed) = value_for(args, index, attached, name)?; - apply_value(name, value, &mut config_file, &mut overrides)?; - stdio_forward |= name == "stdio-forward"; - index += consumed; + 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, + )?; + 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}'"), } - "ipv4" => overrides.push("AddressFamily=inet".to_string()), - "ipv6" => overrides.push("AddressFamily=inet6".to_string()), - "tty" => overrides.push("RequestTTY=yes".to_string()), - "no-tty" => overrides.push("RequestTTY=no".to_string()), - "no-x11" => overrides.push("ForwardX11=no".to_string()), - _ => {} } index += 1; continue; @@ -95,6 +131,14 @@ impl SshDumpInvocation { '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 { @@ -103,26 +147,94 @@ impl SshDumpInvocation { .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 overrides)?; + apply_value( + name, + value, + &mut config_file, + &mut log_file, + &mut overrides, + &mut priority_overrides, + )?; stdio_forward |= name == "stdio-forward"; index += consumed; break; } match short { - '4' => overrides.push("AddressFamily=inet".to_string()), - '6' => overrides.push("AddressFamily=inet6".to_string()), - 'A' => overrides.push("ForwardAgent=yes".to_string()), - 'x' => overrides.push("ForwardX11=no".to_string()), - 't' => overrides.push("RequestTTY=yes".to_string()), - 'T' => overrides.push("RequestTTY=no".to_string()), - _ => {} + '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", + ); + } + 'q' | 'v' | 'V' | 'y' => {} + _ => anyhow::bail!("Unknown option '-{short}'"), } } } index += 1; } - let destination = destination.strip_prefix("ssh://").unwrap_or(destination); + if !saw_dump { + anyhow::bail!("Resolved configuration invocation is missing -G"); + } + let destination = destination.context("-G requires a destination")?; + let destination = destination.strip_prefix("ssh://").unwrap_or(&destination); let parsed = crate::node::parse_node_spec(destination) .context("Invalid destination for resolved configuration")?; if stdio_forward { @@ -140,10 +252,17 @@ impl SshDumpInvocation { } } + let mut all_overrides = priority_overrides + .into_iter() + .map(|(_, value)| value) + .collect::>(); + all_overrides.extend(overrides); + Ok(Self { destination: parsed.host.to_string(), config_file, - overrides, + log_file, + overrides: all_overrides, }) } } @@ -179,7 +298,9 @@ 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)>, ) -> Result<()> { let option = match name { "option" => value.to_string(), @@ -192,13 +313,34 @@ fn apply_value( } "identity" => format!("IdentityFile={value}"), "jump-host" => format!("ProxyJump={value}"), - "cipher" => format!("Ciphers={value}"), - "macs" => format!("MACs={value}"), + "cipher" => { + set_priority(priority_overrides, "ciphers", format!("Ciphers={value}")); + return Ok(()); + } + "macs" => { + set_priority(priority_overrides, "macs", format!("MACs={value}")); + return Ok(()); + } "local-forward" => format!("LocalForward={value}"), "remote-forward" => format!("RemoteForward={value}"), "dynamic-forward" => format!("DynamicForward={value}"), - "stdio-forward" => return Ok(()), - "diagnostic-file" | "query" => return Ok(()), + "stdio-forward" => { + validate_stdio_forward(value)?; + return Ok(()); + } + "diagnostic-file" => { + *log_file = Some(PathBuf::from(value)); + return Ok(()); + } + "query" => return Ok(()), + "bind-interface" => format!("BindInterface={value}"), + "bind-address" => format!("BindAddress={value}"), + "escape-char" => format!("EscapeChar={value}"), + "control-path" => format!("ControlPath={value}"), + "tunnel-device" => format!("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(()); @@ -209,6 +351,239 @@ fn apply_value( Ok(()) } +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; + while index < args.len() { + let argument = &args[index]; + if argument == "--" { + if destination_seen { + break; + } + index += 1; + if index < args.len() { + destination_seen = true; + } + index += 1; + continue; + } + 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" { + return 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' { + return true; + } + if short_takes_value(short) { + if position + short.len_utf8() == shorts.len() { + index += 1; + } + break; + } + } + } + index += 1; + } + false +} + +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 == "--" { + if destination_seen { + break; + } + index += 2; + destination_seen = true; + continue; + } + 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 !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 = "'`\";&<>|(){}"; @@ -245,7 +620,7 @@ mod tests { "-o", "Port=9", ]); - let parsed = SshDumpInvocation::from_argv(&argv, "host").unwrap(); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); assert_eq!( parsed.overrides, ["AddressFamily=inet6", "User=first", "User=second", "Port=9"] @@ -255,7 +630,7 @@ mod tests { #[test] fn destination_values_are_last_and_ipv6_is_unwrapped() { let argv = args(&["bssh", "-Gp2200", "user@[::1]:2300"]); - let parsed = SshDumpInvocation::from_argv(&argv, "user@[::1]:2300").unwrap(); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); assert_eq!(parsed.destination, "::1"); assert_eq!(parsed.overrides, ["Port=2200", "User=user", "Port=2300"]); } @@ -263,7 +638,7 @@ mod tests { #[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, "host").unwrap(); + let parsed = SshDumpInvocation::from_argv(&implicit).unwrap(); assert!( parsed .overrides @@ -285,7 +660,7 @@ mod tests { "ClearAllForwardings=no", "host", ]); - let parsed = SshDumpInvocation::from_argv(&explicit, "host").unwrap(); + let parsed = SshDumpInvocation::from_argv(&explicit).unwrap(); assert!( !parsed .overrides @@ -313,7 +688,7 @@ mod tests { ]; for (values, expected) in cases { let argv = args(&values); - let parsed = SshDumpInvocation::from_argv(&argv, "baz@host").unwrap(); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); let first_user = parsed.overrides.iter().find_map(|option| { option .to_ascii_lowercase() @@ -327,7 +702,7 @@ mod tests { #[test] fn double_dash_prevents_a_second_option_pass() { let argv = args(&["bssh", "-GF", "none", "--", "host", "-l", "late"]); - let parsed = SshDumpInvocation::from_argv(&argv, "host").unwrap(); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); assert!(!parsed.overrides.iter().any(|option| option == "User=late")); } @@ -342,7 +717,7 @@ mod tests { "host", ]); for argv in [attached, separate] { - let parsed = SshDumpInvocation::from_argv(&argv, "host").unwrap(); + let parsed = SshDumpInvocation::from_argv(&argv).unwrap(); assert!(parsed.overrides.is_empty()); } } @@ -350,12 +725,69 @@ mod tests { #[test] fn validates_remote_user_without_expanding_percent_tokens() { let literal = args(&["bssh", "-G", "-l%u", "host"]); - let parsed = SshDumpInvocation::from_argv(&literal, "host").unwrap(); + 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, "host").is_err()); + assert!(SshDumpInvocation::from_argv(&argv).is_err()); + } + } + + #[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" + ]))); + } } diff --git a/src/main.rs b/src/main.rs index 67945dca..af259caa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,30 @@ 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 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 } @@ -284,12 +308,6 @@ async fn run_bssh_mode(args: &[String]) -> Result<()> { bssh::utils::diagnostics::set_log_file(path)?; } - // `-G` uses the minimal diagnostic sink above, but remains before - // Backend.AI discovery, DNS, agents, prompts, proxies, and all networking. - if cli.print_config { - return handle_config_dump(&cli, args).await; - } - // Handle SSH query option (-Q) if let Some(ref query) = cli.query { handle_query(query); diff --git a/src/ssh/ssh_config/dump.rs b/src/ssh/ssh_config/dump.rs index 0537a0b8..bdc8a2fb 100644 --- a/src/ssh/ssh_config/dump.rs +++ b/src/ssh/ssh_config/dump.rs @@ -4,10 +4,8 @@ // you may not use this file except in compliance with the License. //! OpenSSH-shaped resolved configuration rendering. -use std::fmt::Write as _; -use std::path::PathBuf; - use anyhow::{Context, Result}; +use std::fmt::Write as _; use super::{IpQosPolicy, IpQosValue, RekeyDataLimit, RekeyLimit, RekeyTimeLimit, SshHostConfig}; @@ -120,6 +118,30 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re "updatehostkeys", config.update_host_keys.as_deref().unwrap_or("yes"), )?; + output.line("canonicalizefallbacklocal", "yes")?; + output.line("canonicalizehostname", "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", @@ -143,6 +165,13 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re config.server_alive_interval.unwrap_or(0), )?; output.line("requiredrsasize", config.required_rsa_size.unwrap_or(1024))?; + output.line("canonicalizemaxdots", 1)?; + output.line( + "obscurekeystroketiming", + raw_option(config, "obscurekeystroketiming") + .as_deref() + .unwrap_or("yes"), + )?; output.expanded("bindaddress", config.bind_address.as_deref(), &tokens)?; output.expanded("bindinterface", config.bind_interface.as_deref(), &tokens)?; @@ -157,6 +186,18 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re if let Some(value) = config.identity_agent.as_deref() { output.line("identityagent", tokens.expand_path(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.expanded("localcommand", config.local_command.as_deref(), &tokens)?; output.expanded("remotecommand", config.remote_command.as_deref(), &tokens)?; output.expanded( @@ -184,7 +225,11 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re output.line("escapechar", config.escape_char.as_deref().unwrap_or("~"))?; output.line( "fingerprinthash", - config.fingerprint_hash.as_deref().unwrap_or("sha256"), + config + .fingerprint_hash + .as_deref() + .filter(|value| !value.eq_ignore_ascii_case("sha256")) + .unwrap_or("SHA256"), )?; output.line( "preferredauthentications", @@ -203,11 +248,41 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re )?; output.line( "casignaturealgorithms", - list_or(&config.ca_signature_algorithms, &pubkey_names(config)), + list_or( + &config.ca_signature_algorithms, + default_ca_signature_algorithms(), + ), + )?; + output.line("canonicaldomains", "none")?; + let revoked_host_keys = raw_option(config, "revokedhostkeys") + .map(|value| tokens.expand_path(&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.line("canonicalizepermittedcnames", "none")?; + output.line( + "streamlocalbindmask", + raw_option(config, "streamlocalbindmask") + .as_deref() + .unwrap_or("0177"), )?; for identity in identity_files(config) { - output.line("identityfile", identity.to_string_lossy())?; + output.line("identityfile", identity)?; } for certificate in &config.certificate_files { output.line("certificatefile", certificate.to_string_lossy())?; @@ -218,17 +293,22 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re "~/.ssh/known_hosts2".to_string(), ] }); - output.line("userknownhostsfile", tokens.expand(&user_hosts.join(" "))?)?; + let user_hosts = user_hosts + .iter() + .map(|value| tokens.expand(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.line( - "globalknownhostsfile", - tokens.expand(&global_hosts.join(" "))?, - )?; + let global_hosts = global_hosts + .iter() + .map(|value| tokens.expand(value)) + .collect::>>()?; + output.args("globalknownhostsfile", &global_hosts)?; for value in &config.send_env { output.line("sendenv", value)?; } @@ -237,23 +317,23 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re for (name, value) in set_env { output.line("setenv", format!("{name}={}", tokens.expand(value)?))?; } - for value in &config.local_forward { - output.line("localforward", tokens.expand(value)?)?; - } - for value in &config.remote_forward { - output.line("remoteforward", tokens.expand(value)?)?; - } - for value in &config.dynamic_forward { - output.line("dynamicforward", tokens.expand(value)?)?; + if !config.clear_all_forwardings.unwrap_or(false) { + for value in &config.local_forward { + output.args("localforward", &expanded_args(value, &tokens)?)?; + } + for value in &config.remote_forward { + output.args("remoteforward", &expanded_args(value, &tokens)?)?; + } + for value in &config.dynamic_forward { + output.args("dynamicforward", &expanded_args(value, &tokens)?)?; + } } - output.line( - "permitremoteopen", - if config.permit_remote_open.is_empty() { - "any".to_string() - } else { - config.permit_remote_open.join(" ") - }, - )?; + 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"), @@ -262,15 +342,19 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re "controlpersist", config.control_persist.as_deref().unwrap_or("no"), )?; - output.line("ipqos", format_ipqos(config.ipqos.unwrap_or_default()))?; - output.line( + output.args("ipqos", &format_ipqos(config.ipqos.unwrap_or_default()))?; + output.args( "rekeylimit", - format_rekey(config.rekey_limit.unwrap_or_default()), + &format_rekey(config.rekey_limit.unwrap_or_default()), )?; for (keyword, args) in &config.unimplemented_options { if !EMITTED_UNIMPLEMENTED.contains(&keyword.as_str()) { - output.line(keyword, tokens.expand(&args.join(" "))?)?; + let args = args + .iter() + .map(|value| tokens.expand(value)) + .collect::>>()?; + output.args(keyword, &args)?; } } Ok(output.value) @@ -305,6 +389,23 @@ const EMITTED_UNIMPLEMENTED: &[&str] = &[ "stdinnull", "syslogfacility", "visualhostkey", + "canonicalizefallbacklocal", + "canonicalizehostname", + "canonicalizemaxdots", + "canonicaldomains", + "canonicalizepermittedcnames", + "channeltimeout", + "enableescapecommandline", + "logverbose", + "obscurekeystroketiming", + "revokedhostkeys", + "securitykeyprovider", + "streamlocalbindunlink", + "streamlocalbindmask", + "tunnel", + "tunneldevice", + "warnweakcrypto", + "xauthlocation", ]; #[derive(Default)] @@ -321,10 +422,37 @@ impl DumpWriter { { 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 bool(&mut self, keyword: &str, value: bool) -> Result<()> { self.line(keyword, yes_no(value)) } @@ -360,6 +488,14 @@ fn raw_option(config: &SshHostConfig, keyword: &str) -> Option { .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(value)?, 0) +} + fn list_or(values: &[String], default: &str) -> String { if values.is_empty() { default.to_string() @@ -429,31 +565,37 @@ fn pubkey_names(config: &SshHostConfig) -> String { .join(",") } -fn identity_files(config: &SshHostConfig) -> Vec { - if !config.identity_files.is_empty() { - return config.identity_files.clone(); - } - let base = dirs::home_dir().unwrap_or_default().join(".ssh"); - [ +fn identity_files(config: &SshHostConfig) -> Vec { + let default_names = [ "id_rsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519", "id_ed25519_sk", - "id_xmss", - "id_dsa", - ] - .into_iter() - .map(|name| base.join(name)) - .collect() + ]; + 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_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 format_ipqos(policy: IpQosPolicy) -> String { - format!( - "{} {}", - qos_value(policy.interactive), - qos_value(policy.bulk) - ) +fn format_ipqos(policy: IpQosPolicy) -> Vec { + vec![qos_value(policy.interactive), qos_value(policy.bulk)] } fn qos_value(value: IpQosValue) -> String { @@ -486,7 +628,7 @@ fn qos_value(value: IpQosValue) -> String { } } -fn format_rekey(limit: RekeyLimit) -> String { +fn format_rekey(limit: RekeyLimit) -> Vec { let data = match limit.data { RekeyDataLimit::Default => 0, RekeyDataLimit::Bytes(value) => value, @@ -495,5 +637,9 @@ fn format_rekey(limit: RekeyLimit) -> String { RekeyTimeLimit::Default | RekeyTimeLimit::None => 0, RekeyTimeLimit::Seconds(value) => value, }; - format!("{data} {time}") + vec![data.to_string(), time.to_string()] +} + +fn default_ca_signature_algorithms() -> &'static 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" } diff --git a/src/ssh/ssh_config/dump_tests.rs b/src/ssh/ssh_config/dump_tests.rs index d69a43ff..7571c386 100644 --- a/src/ssh/ssh_config/dump_tests.rs +++ b/src/ssh/ssh_config/dump_tests.rs @@ -1,4 +1,5 @@ use super::{SshConfig, SshHostConfig, render_resolved_config}; +use std::collections::HashSet; #[test] fn resolved_dump_round_trips_with_typed_and_retained_values() { @@ -44,3 +45,111 @@ fn renderer_rejects_line_injection() { let error = render_resolved_config("target", &config).expect_err("control must be rejected"); assert!(error.to_string().contains("unsafe value")); } + +#[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 /tmp/destination + 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 == "localforward /tmp/local /tmp/destination") + ); + 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 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-")); +} diff --git a/src/ssh/ssh_config/include/mod.rs b/src/ssh/ssh_config/include/mod.rs index 3cf711e6..0b0faf40 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}; @@ -32,7 +31,7 @@ pub use resolver::{parse_include_line, resolve_include_pattern}; pub use validation::{validate_glob_pattern, validate_include_path}; /// 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 +41,33 @@ 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, } 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, + } + } + + pub fn with_anchor(anchor: PathBuf) -> Self { + Self { + depth: 0, + file_count: 0, + anchor, } } @@ -87,65 +89,18 @@ impl IncludeContext { } /// Enter a new include level - fn enter_include(&mut self, path: &Path) -> Result<(String, PathBuf)> { + 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.clone()); - let previous_base_dir = self.base_dir.clone(); 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((canonical_str, previous_base_dir)) + Ok(()) } /// Exit an include level - fn exit_include(&mut self, canonical: &str, previous_base_dir: PathBuf) { + fn exit_include(&mut self) { if self.depth > 0 { self.depth -= 1; } - self.visited.remove(canonical); - self.base_dir = previous_base_dir; } } @@ -175,25 +130,74 @@ pub async fn resolve_includes_for_host( content: &str, hostname: Option<&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()); + 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> { + resolve_includes_for_host_at_pass(config_path, content, hostname, anchor, None, None, false) + .await +} + +pub(crate) async fn resolve_includes_for_host_at_pass( + config_path: &Path, + content: &str, + hostname: Option<&str>, + anchor: PathBuf, + effective_hostname: Option<&str>, + remote_user: Option<&str>, + final_pass: bool, +) -> Result> { + let mut context = IncludeContext::with_anchor(anchor); + let mut expansion = + IncludeExpansionState::new(hostname, effective_hostname, remote_user, final_pass); // Process the main file with includes - process_file_with_includes(config_path, content, &mut context, hostname, "Host *", &[]).await + process_file_with_includes( + config_path, + content, + &mut context, + &mut expansion, + "Host *", + &[], + true, + ) + .await +} + +#[derive(Debug)] +struct IncludeExpansionState { + original_hostname: Option, + effective_hostname: Option, + hostname_obtained: bool, + remote_user: Option, + final_pass: bool, +} + +impl IncludeExpansionState { + fn new( + hostname: Option<&str>, + effective_hostname: Option<&str>, + remote_user: Option<&str>, + final_pass: bool, + ) -> Self { + Self { + original_hostname: hostname.map(str::to_string), + effective_hostname: effective_hostname.or(hostname).map(str::to_string), + hostname_obtained: effective_hostname.is_some(), + remote_user: remote_user.map(str::to_string), + final_pass, + } + } } /// Process a file with Include directives, inserting included files at the correct positions @@ -201,14 +205,16 @@ async fn process_file_with_includes( file_path: &Path, content: &str, context: &mut IncludeContext, - hostname: Option<&str>, + expansion: &mut IncludeExpansionState, inherited_scope: &str, scope_guards: &[String], + inherited_active: bool, ) -> Result> { let mut result = Vec::new(); let mut current_content = String::new(); let mut current_source_line = 1; let mut active_scope = inherited_scope.to_string(); + let mut scope_active = inherited_active; let mut pending_scope_restore = false; let mut pending_initial_scope = context.depth > 0; @@ -217,7 +223,7 @@ async fn process_file_with_includes( 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 { @@ -232,22 +238,27 @@ async fn process_file_with_includes( // Process each Include pattern for pattern in patterns { - let expanded_environment = expand_include_environment(pattern)?; - let expanded_pattern = hostname + let expanded_environment = expand_include_environment(&pattern)?; + let expanded_pattern = expansion + .effective_hostname + .as_deref() .map_or_else(|| pattern.to_string(), |host| pattern.replace("%h", host)); let expanded_pattern = if expanded_environment == pattern { expanded_pattern } else { - hostname.map_or(expanded_environment.clone(), |host| { - expanded_environment.replace("%h", host) - }) + expansion + .effective_hostname + .as_deref() + .map_or(expanded_environment.clone(), |host| { + expanded_environment.replace("%h", host) + }) }; 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) ) @@ -255,10 +266,9 @@ async fn process_file_with_includes( // Process each resolved file recursively for include_path in resolved_files { - let (canonical, previous_base_dir) = - context.enter_include(&include_path).with_context(|| { - format!("Failed to include file: {}", escape_path(&include_path)) - })?; + 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( @@ -286,16 +296,17 @@ async fn process_file_with_includes( &include_path, &include_content, context, - hostname, + expansion, &active_scope, &child_guards, + scope_active, )) .await?; // Add all files from the included file to result result.append(&mut included_files); - context.exit_include(&canonical, previous_base_dir); + context.exit_include(); } } pending_scope_restore = true; @@ -334,6 +345,9 @@ async fn process_file_with_includes( || lower.starts_with("match=") { active_scope = trimmed.to_string(); + scope_active = inherited_active && scope_matches(trimmed, expansion)?; + } else if scope_active { + update_expansion_state(trimmed, expansion)?; } } } @@ -361,6 +375,76 @@ async fn process_file_with_includes( Ok(result) } +fn scope_matches(line: &str, state: &IncludeExpansionState) -> Result { + let Some(original_hostname) = state.original_hostname.as_deref() else { + return Ok(true); + }; + 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(super::pattern::matches_host_pattern( + original_hostname, + &patterns, + )); + } + let conditions = super::match_directive::MatchCondition::parse_match_line(line, 0)?; + if conditions.iter().any(match_contains_exec) { + anyhow::bail!("Match exec cannot be evaluated in side-effect-free -G mode"); + } + 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_final_pass(state.final_pass); + let block = super::match_directive::MatchBlock { + conditions, + config: super::types::SshHostConfig::default(), + line_number: 0, + }; + block.matches(&context) +} + +fn match_contains_exec(condition: &super::match_directive::MatchCondition) -> bool { + match condition { + super::match_directive::MatchCondition::Exec(_) => true, + super::match_directive::MatchCondition::Negated(inner) => match_contains_exec(inner), + _ => false, + } +} + +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(value.clone()); + state.hostname_obtained = true; + } else if keyword == "user" && state.remote_user.is_none() { + state.remote_user = args.first().cloned(); + } + Ok(()) +} + +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; @@ -411,9 +495,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(); @@ -498,7 +598,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}" ); } @@ -713,4 +814,122 @@ 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"))); + } + + 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..4301f243 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 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 @@ -75,7 +58,7 @@ pub async fn resolve_include_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 }; @@ -111,59 +94,29 @@ 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) { + // Follow symlinks, then validate the target like OpenSSH's fstat path. + match std::fs::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); - } + if metadata.is_file() { + validate_include_path(&path)?; + files.push(path); } } - Err(e) => { - tracing::debug!( - "Failed to get metadata for {}: {}", - escape_path(&path), - escape_field(&e.to_string()) - ); + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).with_context(|| { + format!("Failed to get metadata for {}", escape_path(&path)) + }); } } } - Err(e) => { - // Log glob errors but continue - tracing::warn!( - "Error processing glob pattern '{}': {}", - escape_field(pattern_str), - escape_field(&e.to_string()) - ); + Err(error) => { + return Err(error).with_context(|| { + format!( + "Error processing glob pattern '{}'", + escape_field(pattern_str) + ) + }); } } } @@ -186,37 +139,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 +198,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"); @@ -273,10 +242,9 @@ mod tests { #[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..0ad2921a 100644 --- a/src/ssh/ssh_config/include/validation.rs +++ b/src/ssh/ssh_config/include/validation.rs @@ -15,7 +15,7 @@ //! Security validation for Include directive use anyhow::{Context, Result}; -use std::path::{Path, PathBuf}; +use std::path::Path; use super::super::diagnostic::{escape_field, escape_path}; @@ -52,102 +52,32 @@ 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) - ); - } + // Follow symlinks and validate the opened target, as OpenSSH does via fstat. + let metadata = match std::fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to get metadata for {}", escape_path(path))); + } + }; // Check if it's a regular file 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) - ); - } - - // 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)))] + #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - - let permissions = metadata.permissions(); - let mode = permissions.mode(); - - // 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) - ); + 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)); } - - // 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) - ); + if metadata.mode() & 0o22 != 0 { + anyhow::bail!("Bad permissions for SSH config file {}", escape_path(path)); } } 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/mod.rs b/src/ssh/ssh_config/match_directive/mod.rs index 05ebc28b..8925d114 100644 --- a/src/ssh/ssh_config/match_directive/mod.rs +++ b/src/ssh/ssh_config/match_directive/mod.rs @@ -17,7 +17,7 @@ //! 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 std::collections::HashMap; use super::pattern::matches_host_pattern; @@ -43,6 +43,10 @@ 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), } @@ -94,6 +98,8 @@ pub struct MatchContext { 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, } impl MatchContext { @@ -128,41 +134,39 @@ impl MatchContext { remote_user, local_user, variables, + final_pass: false, }) } + + pub fn with_final_pass(mut self, final_pass: bool) -> Self { + self.final_pass = final_pass; + self + } } 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}"); } - let conditions_str = conditions_str - .find(" #") - .map_or(conditions_str, |comment| &conditions_str[..comment]); - - // Parse conditions let mut conditions = Vec::new(); - let mut parts = conditions_str.split_whitespace(); - - while let Some(token) = 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))); @@ -173,14 +177,14 @@ impl MatchCondition { match keyword_lower.as_str() { "host" => { - let patterns = collect_patterns(attached_pattern, &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}"); } push_condition(&mut conditions, MatchCondition::Host(patterns), negated); } "originalhost" => { - let patterns = collect_patterns(attached_pattern, &mut parts)?; + let patterns = collect_patterns(attached_pattern, &tokens, &mut position); if patterns.is_empty() { anyhow::bail!("Match originalhost requires patterns at line {line_number}"); } @@ -191,14 +195,14 @@ impl MatchCondition { ); } "user" => { - let patterns = collect_patterns(attached_pattern, &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}"); } push_condition(&mut conditions, MatchCondition::User(patterns), negated); } "localuser" => { - let patterns = collect_patterns(attached_pattern, &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}"); } @@ -209,29 +213,28 @@ impl MatchCondition { ); } "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(" ") - }; - + 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); - break; // Exec consumes the rest of the line } "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}"); } @@ -271,6 +274,7 @@ impl MatchCondition { // Always matches Ok(true) } + MatchCondition::Final | MatchCondition::Canonical => Ok(context.final_pass), MatchCondition::Negated(condition) => Ok(!condition.matches(context)?), } } @@ -287,8 +291,9 @@ fn push_condition(conditions: &mut Vec, condition: MatchConditio /// Collect patterns until the next keyword fn collect_patterns( attached: Option<&str>, - parts: &mut std::str::SplitWhitespace, -) -> Result> { + tokens: &[String], + position: &mut usize, +) -> Vec { let mut patterns: Vec = attached .filter(|value| !value.is_empty()) .map(|value| { @@ -300,19 +305,16 @@ fn collect_patterns( }) .unwrap_or_default(); - // Peek at upcoming parts to collect patterns - let remaining: Vec<&str> = parts.clone().collect(); - - for part in remaining { - // Stop if we hit another Match keyword + while let Some(part) = tokens.get(*position) { let lower = part + .as_str() .split_once('=') - .map_or(part, |(keyword, _)| keyword) + .map_or(part.as_str(), |(keyword, _)| keyword) .trim_start_matches('!') .to_lowercase(); if matches!( lower.as_str(), - "host" | "originalhost" | "user" | "localuser" | "exec" | "all" + "host" | "originalhost" | "user" | "localuser" | "exec" | "all" | "final" | "canonical" ) { break; } @@ -322,11 +324,9 @@ fn collect_patterns( .filter(|pattern| !pattern.is_empty()) .map(str::to_string), ); - // Consume the part from the iterator - parts.next(); + *position += 1; } - - Ok(patterns) + patterns } #[cfg(test)] @@ -353,6 +353,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(); diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index 0d028a93..a4c5676e 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -45,6 +45,7 @@ mod security; #[cfg(test)] mod security_fix_tests; mod types; +mod value; // Re-export public types pub use dump::render_resolved_config; @@ -98,13 +99,13 @@ impl SshConfig { 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) { + if path_exists(&user_config).await? { config.append(Self::load_from_file(&user_config).await?); } } let system_config = Path::new("/etc/ssh/ssh_config"); - if tokio::fs::try_exists(system_config).await.unwrap_or(false) { + if path_exists(system_config).await? { config.append(Self::load_from_file(system_config).await?); } Ok(config) @@ -152,43 +153,115 @@ impl SshConfig { /// Load a file with host-dependent Include tokens resolved for `hostname`. pub async fn load_from_file_for_host>(path: P, hostname: &str) -> Result { + Self::load_from_file_for_host_with_options(path, hostname, &[]).await + } + + pub async fn load_from_file_for_host_with_options>( + path: P, + hostname: &str, + options: &[String], + ) -> Result { let path = path.as_ref(); - let content = tokio::fs::read_to_string(path).await.with_context(|| { - format!( - "Failed to read SSH config file: {}", - diagnostic::escape_path(path) + 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 = config.find_host_config(hostname); + config + .append_file_for_host( + path, + hostname, + initial.hostname.as_deref(), + initial.user.as_deref(), + anchor, + false, ) - })?; - let mut reported_diagnostics = HashSet::new(); - let hosts = parser::parse_from_file_for_host_with_diagnostics( - path, - &content, - hostname, - &mut reported_diagnostics, - ) - .await?; - Ok(Self { - hosts, - reported_diagnostics, - }) + .await?; + Ok(config) } /// Load user and system configuration, in OpenSSH precedence order. pub async fn load_default_for_host(hostname: &str) -> Result { + Self::load_default_for_host_with_options(hostname, &[]).await + } + + pub 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 = config.find_host_config(hostname); + let initial_hostname = initial.hostname; + let initial_user = initial.user; 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) { - config.append(Self::load_from_file_for_host(&user_config, hostname).await?); + if path_exists(&user_config).await? { + config + .append_file_for_host( + &user_config, + hostname, + initial_hostname.as_deref(), + initial_user.as_deref(), + home_dir.join(".ssh"), + true, + ) + .await?; } } let system_config = Path::new("/etc/ssh/ssh_config"); - if tokio::fs::try_exists(system_config).await.unwrap_or(false) { - config.append(Self::load_from_file_for_host(system_config, hostname).await?); + if path_exists(system_config).await? { + let accumulated = config.find_host_config(hostname); + let accumulated_hostname = accumulated.hostname; + let accumulated_user = accumulated.user; + config + .append_file_for_host( + system_config, + hostname, + accumulated_hostname.as_deref(), + accumulated_user.as_deref(), + PathBuf::from("/etc/ssh"), + false, + ) + .await?; } Ok(config) } + async fn append_file_for_host( + &mut self, + path: &Path, + hostname: &str, + initial_hostname: Option<&str>, + initial_user: Option<&str>, + anchor: PathBuf, + check_top_permissions: bool, + ) -> Result<()> { + if check_top_permissions { + include::validate_include_path(path)?; + } + let content = tokio::fs::read_to_string(path).await.with_context(|| { + format!( + "Failed to read SSH config file: {}", + diagnostic::escape_path(path) + ) + })?; + let hosts = parser::parse_from_file_for_host_at_with_diagnostics( + path, + &content, + hostname, + initial_hostname, + initial_user, + anchor, + &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); @@ -378,11 +451,156 @@ 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(); + config + .append_file_for_host(&user, "alias", None, None, user_anchor, false) + .await + .unwrap(); + let accumulated = config.find_host_config("alias"); + config + .append_file_for_host( + &system, + "alias", + accumulated.hostname.as_deref(), + accumulated.user.as_deref(), + 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)); + } + + #[tokio::test] + async fn final_pass_reprocesses_percent_h_includes_and_preserves_first_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("final.example")); + assert_eq!(resolved.port, Some(2202), "{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")); + } + + #[test] + fn final_pass_refreshes_user_and_hostname_before_each_match() { + 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("final.example")); + assert_eq!(resolved.port, Some(2202)); + } + + #[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#" diff --git a/src/ssh/ssh_config/parser/core.rs b/src/ssh/ssh_config/parser/core.rs index 071e250e..90b6528c 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, resolve_includes_for_host}; +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,20 +57,73 @@ 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 { + if !super::super::resolver::requests_final_pass(&configs) { + 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. -pub(crate) async fn parse_from_file_for_host_with_diagnostics( +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 included_files = resolve_includes_for_host(path, content, Some(hostname)) - .await - .with_context(|| format!("Failed to resolve includes for {}", escape_path(path)))?; - parse_included_files(&included_files, reported_diagnostics) + let included_files = resolve_includes_for_host_at_pass( + path, + content, + Some(hostname), + anchor.clone(), + initial_hostname, + initial_user, + false, + ) + .await + .with_context(|| format!("Failed to resolve includes for {}", escape_path(path)))?; + let first_pass = parse_included_files(&included_files, reported_diagnostics)?; + if !super::super::resolver::requests_final_pass(&first_pass) { + return Ok(first_pass); + } + let preliminary_source = add_final_pass_configs(first_pass.clone()); + let preliminary = super::super::resolver::find_host_config(&preliminary_source, hostname); + let effective_hostname = initial_hostname.or(preliminary.hostname.as_deref()); + let effective_user = initial_user.or(preliminary.user.as_deref()); + let final_files = resolve_includes_for_host_at_pass( + path, + content, + Some(hostname), + anchor, + effective_hostname, + effective_user, + true, + ) + .await + .with_context(|| { + format!( + "Failed to resolve final-pass includes for {}", + escape_path(path) + ) + })?; + let mut final_pass = parse_included_files(&final_files, reported_diagnostics)?; + for config in &mut final_pass { + config.pass = ConfigPass::FinalOnly; + } + let mut combined = first_pass; + combined.extend(final_pass); + Ok(combined) } /// Parse SSH configuration content without Include resolution @@ -180,8 +235,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)", @@ -362,38 +417,13 @@ fn parse_option_first( /// Parse a Host directive line pub(super) fn parse_host_line(line: &str, line_number: usize) -> Result> { - let line = line.trim(); - - // 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() - }; - - if patterns_str.is_empty() { + 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}"); } - - // Split into individual patterns - let patterns: Vec = patterns_str - .split_whitespace() - .map(|s| s.to_string()) - .collect(); - Ok(patterns) } @@ -404,66 +434,48 @@ 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)) } diff --git a/src/ssh/ssh_config/parser/mod.rs b/src/ssh/ssh_config/parser/mod.rs index 13dc3012..ef4cb38b 100644 --- a/src/ssh/ssh_config/parser/mod.rs +++ b/src/ssh/ssh_config/parser/mod.rs @@ -32,8 +32,8 @@ mod tests; #[cfg(test)] pub(super) use core::parse; pub(super) use core::{ - parse_cli_options, parse_from_file_for_host_with_diagnostics, parse_from_file_with_diagnostics, - parse_with_diagnostics, + parse_cli_options, parse_from_file_for_host_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/mod.rs b/src/ssh/ssh_config/parser/options/mod.rs index 564a1166..8456bdcd 100644 --- a/src/ssh/ssh_config/parser/options/mod.rs +++ b/src/ssh/ssh_config/parser/options/mod.rs @@ -61,6 +61,7 @@ pub fn parse_option( let keyword = spec.canonical; if spec.support == support::KeywordSupport::Unimplemented { + validate_retained_option(keyword, args, line_number)?; if !args.is_empty() { host.unimplemented_options .entry(keyword.to_string()) @@ -187,7 +188,185 @@ pub fn parse_option( | "useroaming" | "usersh" | "useprivilegedport" - | "tunneldevice" => Ok(()), + | "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()?; + if !matches!(args[0].to_ascii_lowercase().as_str(), "yes" | "true") { + anyhow::bail!( + "{keyword} non-default behavior is unavailable in no-network -G mode" + ); + } + } + "canonicalizehostname" => { + one()?; + if !matches!(args[0].to_ascii_lowercase().as_str(), "no" | "false") { + anyhow::bail!("{keyword} requires DNS and is unavailable in no-network -G mode"); + } + } + "canonicalizemaxdots" => { + one()?; + if args[0] != "1" { + anyhow::bail!("{keyword} is only accepted at its default in no-network -G mode"); + } + } + "canonicaldomains" | "canonicalizepermittedcnames" => { + one()?; + if !args[0].eq_ignore_ascii_case("none") { + anyhow::bail!( + "{keyword} requires canonicalization and is unavailable in no-network -G mode" + ); + } + } + "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()); + } + + #[test] + fn rejects_invalid_or_network_dependent_retained_values() { + for option in [ + "CanonicalizeFallbackLocal no", + "CanonicalizeHostname yes", + "CanonicalizeMaxDots 2", + "CanonicalDomains example.com", + "CanonicalizePermittedCNAMEs *.a:*.b", + "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/support.rs b/src/ssh/ssh_config/parser/options/support.rs index 129e8de9..de57c331 100644 --- a/src/ssh/ssh_config/parser/options/support.rs +++ b/src/ssh/ssh_config/parser/options/support.rs @@ -241,6 +241,45 @@ pub(super) const ACCEPTED_KEYWORDS: &[(&str, &str, KeywordSupport)] = &[ ("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 { @@ -259,9 +298,9 @@ mod tests { use super::*; use std::collections::HashSet; - const ACCEPTED_SPELLING_COUNT: usize = 92; + const ACCEPTED_SPELLING_COUNT: usize = 107; const RUNTIME_SPELLING_COUNT: usize = 51; - const UNIMPLEMENTED_SPELLING_COUNT: usize = 41; + const UNIMPLEMENTED_SPELLING_COUNT: usize = 56; #[test] fn accepted_keywords_and_aliases_have_one_consistent_classification() { @@ -412,6 +451,21 @@ mod tests { "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 3b8786ca..30e1aa49 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()); @@ -133,7 +143,7 @@ Host example.com assert_eq!( hosts[0].proxy_command, - Some("env SSH_SK_HELPER=\"/tmp/ssh-sk-helper\" nc %h %p".to_string()) + Some("env SSH_SK_HELPER=/tmp/ssh-sk-helper nc %h %p".to_string()) ); } @@ -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,40 +1861,36 @@ 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 @@ -1915,18 +1916,16 @@ async fn test_includes_preserve_global_and_host_first_obtained_context() { #[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/resolver.rs b/src/ssh/ssh_config/resolver.rs index d8e29bb9..3c6542ca 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 @@ -37,7 +37,7 @@ pub(super) fn find_host_config_with_user( // Match host/user sees the effective HostName and remote user, as OpenSSH // does during its final configuration pass. let mut preliminary = SshHostConfig::default(); - for host_config in hosts { + for host_config in hosts.iter().filter(|config| config.pass == ConfigPass::Any) { let current_hostname = preliminary .hostname .clone() @@ -85,7 +85,7 @@ pub(super) fn find_host_config_with_user( } }; - for host_config in hosts { + for host_config in hosts.iter().filter(|config| config.pass == ConfigPass::Any) { let should_apply = match &host_config.block_type { Some(ConfigBlock::Host(patterns)) => { // For Host blocks, check pattern matching @@ -122,9 +122,83 @@ pub(super) fn find_host_config_with_user( } } + if requests_final_pass(hosts) { + let final_hostname = merged_config + .hostname + .clone() + .unwrap_or_else(|| hostname.to_string()); + let final_user = remote_user + .map(str::to_string) + .or_else(|| merged_config.user.clone()) + .or_else(|| whoami::username().ok()); + if MatchContext::with_original_hostname(final_hostname, hostname.to_string(), final_user) + .is_ok() + { + for host_config in hosts + .iter() + .filter(|config| config.pass == ConfigPass::FinalOnly) + { + let current_hostname = merged_config + .hostname + .clone() + .unwrap_or_else(|| hostname.to_string()); + let current_user = remote_user + .map(str::to_string) + .or_else(|| merged_config.user.clone()) + .or_else(|| whoami::username().ok()); + let Ok(final_context) = MatchContext::with_original_hostname( + current_hostname, + hostname.to_string(), + current_user, + ) else { + break; + }; + let final_context = final_context.with_final_pass(true); + let should_apply = match &host_config.block_type { + Some(ConfigBlock::Host(patterns)) => matches_host_pattern(hostname, patterns), + Some(ConfigBlock::Match(conditions)) => { + conditions_match(conditions, &final_context) + } + None => matches_host_pattern(hostname, &host_config.host_patterns), + }; + if should_apply && scopes_match(host_config, hostname, Some(&final_context)) { + merge_host_config(&mut merged_config, host_config); + } + } + } + } + merged_config } +fn conditions_match( + conditions: &[super::match_directive::MatchCondition], + context: &MatchContext, +) -> bool { + let block = super::match_directive::MatchBlock { + conditions: conditions.to_vec(), + config: SshHostConfig::default(), + line_number: 0, + }; + block.matches(context).unwrap_or(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, + }) + }) +} + +fn requests_final(condition: &super::match_directive::MatchCondition) -> bool { + matches!(condition, super::match_directive::MatchCondition::Final) +} + fn scopes_match( config: &SshHostConfig, original_hostname: &str, @@ -169,9 +243,11 @@ 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_for_pass( + &mut base.identity_files, + &overlay.identity_files, + overlay.pass, + ); } // OpenSSH keeps the first obtained proxy directive. ProxyCommand and // ProxyJump compete for the same slot, so either one suppresses all later @@ -256,7 +332,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 @@ -264,20 +340,32 @@ 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_for_pass( + &mut base.local_forward, + &overlay.local_forward, + overlay.pass, + ); } if !overlay.remote_forward.is_empty() { - base.remote_forward - .extend(overlay.remote_forward.iter().cloned()); + extend_for_pass( + &mut base.remote_forward, + &overlay.remote_forward, + overlay.pass, + ); } if !overlay.dynamic_forward.is_empty() { - base.dynamic_forward - .extend(overlay.dynamic_forward.iter().cloned()); + extend_for_pass( + &mut base.dynamic_forward, + &overlay.dynamic_forward, + 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(); @@ -467,6 +555,19 @@ 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()); + } +} + /// 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); diff --git a/src/ssh/ssh_config/types.rs b/src/ssh/ssh_config/types.rs index 3645bffd..32996fc9 100644 --- a/src/ssh/ssh_config/types.rs +++ b/src/ssh/ssh_config/types.rs @@ -31,9 +31,18 @@ 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, /// Parent Host/Match scopes active at an Include directive. pub(crate) scope_guards: Vec, /// Block type (Host patterns or Match conditions) diff --git a/src/ssh/ssh_config/value.rs b/src/ssh/ssh_config/value.rs new file mode 100644 index 00000000..20c9f7b6 --- /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(super) 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_config_dump_test.rs b/tests/ssh_config_dump_test.rs index ed96d2ec..96c50849 100644 --- a/tests/ssh_config_dump_test.rs +++ b/tests/ssh_config_dump_test.rs @@ -3,12 +3,28 @@ // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. -use std::fs; 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") @@ -57,7 +73,10 @@ fn match_and_include_restore_parent_scope_for_destination() { .expect("include should be written"); fs::write( &config, - "Host target\n Include %h.conf\n Port 2200\nMatch user=included originalhost=target # comment\n IPQoS cs1\n", + 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"); @@ -100,7 +119,11 @@ fn explicit_log_receives_success_warnings_and_fatal_errors() { 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, "Include invalid-child.conf\n").expect("root config should be written"); + 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", @@ -226,3 +249,213 @@ fn inactive_include_scopes_validate_but_do_not_apply_values() { ); } } + +#[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] +fn match_exec_is_rejected_without_executing_its_command() { + let directory = tempdir().unwrap(); + let config = directory.path().join("config"); + let marker = directory.path().join("match-ran"); + fs::write( + &config, + format!( + "Match exec=\"touch {}\"\n User unsafe\n", + marker.display() + ), + ) + .unwrap(); + + let output = run(&["-GF", path(&config), "host"]); + assert_eq!(output.status.code(), Some(255)); + assert!(!marker.exists()); + assert!(String::from_utf8_lossy(&output.stderr).contains("side-effect-free -G")); +} + +#[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 ")); +} From 0fab54dd52de832917a9625a19fbbc0e1d37e19f Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Mon, 31 Aug 2026 22:35:46 +0900 Subject: [PATCH 3/9] fix(cli): finalize SSH config dump compatibility --- src/app/config_dump.rs | 6 +- src/app/query.rs | 16 + src/cli/ssh_args.rs | 238 ++++++--- src/main.rs | 14 +- src/ssh/ssh_config/dump.rs | 252 +++++++-- src/ssh/ssh_config/dump/tokens.rs | 45 ++ src/ssh/ssh_config/dump_tests.rs | 154 +++++- src/ssh/ssh_config/include/mod.rs | 244 +++++++-- src/ssh/ssh_config/include/resolver.rs | 42 +- src/ssh/ssh_config/include/validation.rs | 90 +++- src/ssh/ssh_config/match_directive/exec.rs | 497 ++++++++---------- src/ssh/ssh_config/match_directive/mod.rs | 178 ++++++- src/ssh/ssh_config/mod.rs | 405 ++++++++++++-- src/ssh/ssh_config/parser/core.rs | 132 +++-- src/ssh/ssh_config/parser/mod.rs | 4 +- .../parser/options/authentication.rs | 8 +- .../ssh_config/parser/options/connection.rs | 41 -- .../ssh_config/parser/options/environment.rs | 18 +- .../ssh_config/parser/options/forwarding.rs | 3 + src/ssh/ssh_config/parser/options/mod.rs | 42 +- src/ssh/ssh_config/parser/options/security.rs | 7 +- src/ssh/ssh_config/resolver.rs | 285 +++++----- src/ssh/ssh_config/resolver_tests.rs | 32 ++ src/ssh/ssh_config/types.rs | 21 + src/ssh/ssh_config/value.rs | 2 +- tests/ssh_compat_output_test.rs | 6 + tests/ssh_config_dump_test.rs | 90 +++- 27 files changed, 2100 insertions(+), 772 deletions(-) diff --git a/src/app/config_dump.rs b/src/app/config_dump.rs index caf9562e..477fb3ce 100644 --- a/src/app/config_dump.rs +++ b/src/app/config_dump.rs @@ -47,7 +47,11 @@ pub async fn handle_config_dump(invocation: &SshDumpInvocation) -> Result<()> { { anyhow::bail!("Unknown SSH config option '{keyword}'"); } - let resolved = config.find_host_config(&invocation.destination); + 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()) 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/ssh_args.rs b/src/cli/ssh_args.rs index cf62acd8..fe0565b4 100644 --- a/src/cli/ssh_args.rs +++ b/src/cli/ssh_args.rs @@ -19,6 +19,11 @@ pub struct SshDumpInvocation { 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 { @@ -36,6 +41,7 @@ impl SshDumpInvocation { } 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; @@ -45,8 +51,10 @@ impl SshDumpInvocation { let mut options_terminated = false; let mut stdio_forward = false; let mut saw_dump = false; + let mut version = false; + let mut query = None; - while index < args.len() { + 'arguments: while index < args.len() { let argument = &args[index]; if argument == "--" { if destination.is_some() { @@ -56,7 +64,9 @@ impl SshDumpInvocation { index += 1; continue; } - if !argument.starts_with('-') && destination.is_none() { + if destination.is_none() + && (options_terminated || !argument.starts_with('-') || argument == "-") + { add_destination_overrides(argument, &mut overrides)?; destination = Some(argument.clone()); index += 1; @@ -82,6 +92,7 @@ impl SshDumpInvocation { &mut log_file, &mut overrides, &mut priority_overrides, + &mut query, )?; stdio_forward |= value_name == "stdio-forward"; index += consumed; @@ -154,8 +165,12 @@ impl SshDumpInvocation { &mut log_file, &mut overrides, &mut priority_overrides, + &mut query, )?; stdio_forward |= name == "stdio-forward"; + if name == "query" { + break 'arguments; + } index += consumed; break; } @@ -222,7 +237,11 @@ impl SshDumpInvocation { "ForwardX11Trusted=yes", ); } - 'q' | 'v' | 'V' | 'y' => {} + 'V' => { + version = true; + break 'arguments; + } + 'q' | 'v' | 'y' => {} _ => anyhow::bail!("Unknown option '-{short}'"), } } @@ -230,28 +249,20 @@ impl SshDumpInvocation { index += 1; } - if !saw_dump { + if !saw_dump && !dump_requested { anyhow::bail!("Resolved configuration invocation is missing -G"); } - let destination = destination.context("-G requires a destination")?; + 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 = crate::node::parse_node_spec(destination) + let parsed = (!destination.is_empty()) + .then(|| parse_dump_destination(destination)) + .transpose() .context("Invalid destination for resolved configuration")?; - if stdio_forward { - for (keyword, implicit) in [ - ("clearallforwardings", "ClearAllForwardings=yes"), - ("exitonforwardfailure", "ExitOnForwardFailure=yes"), - ] { - if !overrides.iter().any(|option| { - option - .split_once('=') - .is_some_and(|(key, _)| key.eq_ignore_ascii_case(keyword)) - }) { - overrides.push(implicit.to_string()); - } - } - } - let mut all_overrides = priority_overrides .into_iter() .map(|(_, value)| value) @@ -259,20 +270,24 @@ impl SshDumpInvocation { all_overrides.extend(overrides); Ok(Self { - destination: parsed.host.to_string(), + 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 = crate::node::parse_node_spec(destination) + let parsed = parse_dump_destination(destination) .context("Invalid destination for resolved configuration")?; if let Some(user) = parsed.user { - overrides.push(format!("User={}", literal_user(user)?)); + overrides.push(config_option("User", &literal_user(user)?)?); } if let Some(port) = parsed.port { overrides.push(format!("Port={port}")); @@ -280,6 +295,20 @@ fn add_destination_overrides(destination: &str, overrides: &mut Vec) -> 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, @@ -301,29 +330,34 @@ fn apply_value( 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" => format!("User={}", literal_user(value)?), + "login" => config_option("User", &literal_user(value)?)?, "port" => { value .parse::() .with_context(|| format!("Invalid port '{value}'"))?; format!("Port={value}") } - "identity" => format!("IdentityFile={value}"), - "jump-host" => format!("ProxyJump={value}"), + "identity" => config_option("IdentityFile", value)?, + "jump-host" => config_option("ProxyJump", value)?, "cipher" => { - set_priority(priority_overrides, "ciphers", format!("Ciphers={value}")); + set_priority( + priority_overrides, + "ciphers", + config_option("Ciphers", value)?, + ); return Ok(()); } "macs" => { - set_priority(priority_overrides, "macs", format!("MACs={value}")); + set_priority(priority_overrides, "macs", config_option("MACs", value)?); return Ok(()); } - "local-forward" => format!("LocalForward={value}"), - "remote-forward" => format!("RemoteForward={value}"), - "dynamic-forward" => format!("DynamicForward={value}"), + "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(()); @@ -332,12 +366,15 @@ fn apply_value( *log_file = Some(PathBuf::from(value)); return Ok(()); } - "query" => return Ok(()), - "bind-interface" => format!("BindInterface={value}"), - "bind-address" => format!("BindAddress={value}"), - "escape-char" => format!("EscapeChar={value}"), - "control-path" => format!("ControlPath={value}"), - "tunnel-device" => format!("TunnelDevice={value}"), + "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") } @@ -351,6 +388,13 @@ fn apply_value( 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, @@ -417,18 +461,11 @@ fn service_exists(_name: &str) -> bool { 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 == "--" { - if destination_seen { - break; - } - index += 1; - if index < args.len() { - destination_seen = true; - } - index += 1; - continue; + break; } if !argument.starts_with('-') || argument == "-" { if destination_seen { @@ -443,7 +480,7 @@ fn scan_for_dump_flag(args: &[String]) -> bool { .split_once('=') .map_or((long, false), |(name, _)| (name, true)); if name == "print-config" { - return true; + saw_dump = true; } if long_takes_value(name) && !attached { index += 1; @@ -451,7 +488,7 @@ fn scan_for_dump_flag(args: &[String]) -> bool { } else if let Some(shorts) = argument.strip_prefix('-') { for (position, short) in shorts.char_indices() { if short == 'G' { - return true; + saw_dump = true; } if short_takes_value(short) { if position + short.len_utf8() == shorts.len() { @@ -463,7 +500,7 @@ fn scan_for_dump_flag(args: &[String]) -> bool { } index += 1; } - false + saw_dump } fn scan_diagnostic_file(args: &[String]) -> Option { @@ -473,12 +510,7 @@ fn scan_diagnostic_file(args: &[String]) -> Option { while index < args.len() { let argument = &args[index]; if argument == "--" { - if destination_seen { - break; - } - index += 2; - destination_seen = true; - continue; + break; } if !argument.starts_with('-') || argument == "-" { if destination_seen { @@ -503,6 +535,9 @@ fn scan_diagnostic_file(args: &[String]) -> Option { } } 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; } @@ -639,16 +674,17 @@ mod tests { 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 - .overrides - .contains(&"ClearAllForwardings=yes".to_string()) - ); - assert!( - parsed - .overrides - .contains(&"ExitOnForwardFailure=yes".to_string()) - ); + 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", @@ -661,10 +697,11 @@ mod tests { "host", ]); let parsed = SshDumpInvocation::from_argv(&explicit).unwrap(); + assert!(parsed.stdio_forward); assert!( - !parsed + parsed .overrides - .contains(&"ClearAllForwardings=yes".to_string()) + .contains(&"ClearAllForwardings=no".to_string()) ); } @@ -734,6 +771,41 @@ mod tests { } } + #[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 [ @@ -789,5 +861,35 @@ mod tests { "-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 af259caa..0705765f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ use app::{ config_dump::handle_config_dump, dispatcher::dispatch_command, initialization::{AppContext, initialize_app}, - query::handle_query, + query::{handle_query, is_supported_query}, utils::show_usage, }; @@ -79,6 +79,18 @@ async fn run() -> Result<()> { 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); diff --git a/src/ssh/ssh_config/dump.rs b/src/ssh/ssh_config/dump.rs index bdc8a2fb..ed9eadad 100644 --- a/src/ssh/ssh_config/dump.rs +++ b/src/ssh/ssh_config/dump.rs @@ -15,12 +15,13 @@ 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 = tokens.expand(&tokens.effective_host)?; + tokens.effective_host = + super::resolver::expand_hostname_value(&tokens.effective_host, original_host); tokens.remote_user = tokens.expand(&tokens.remote_user)?; tokens.refresh_hash(config.proxy_jump.as_deref().unwrap_or("")); output.line("host", original_host)?; - output.line("user", &tokens.remote_user)?; + output.line("user", TokenContext::escape_for_dump(&tokens.remote_user))?; output.line("hostname", &tokens.effective_host)?; output.line("port", &tokens.port)?; output.line( @@ -52,7 +53,7 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re config.forward_x11_trusted.unwrap_or(false), )?; let forward_agent = raw_option(config, "forwardagent") - .map(|value| tokens.expand_path(&value)) + .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)?; @@ -118,8 +119,18 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re "updatehostkeys", config.update_host_keys.as_deref().unwrap_or("yes"), )?; - output.line("canonicalizefallbacklocal", "yes")?; - output.line("canonicalizehostname", "false")?; + 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") @@ -165,7 +176,12 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re config.server_alive_interval.unwrap_or(0), )?; output.line("requiredrsasize", config.required_rsa_size.unwrap_or(1024))?; - output.line("canonicalizemaxdots", 1)?; + output.line( + "canonicalizemaxdots", + raw_option(config, "canonicalizemaxdots") + .as_deref() + .unwrap_or("1"), + )?; output.line( "obscurekeystroketiming", raw_option(config, "obscurekeystroketiming") @@ -173,18 +189,18 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re .unwrap_or("yes"), )?; - output.expanded("bindaddress", config.bind_address.as_deref(), &tokens)?; - output.expanded("bindinterface", config.bind_interface.as_deref(), &tokens)?; + 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(value)?)?; + 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(value)?)?; + output.line("identityagent", tokens.expand_path_for_dump(value)?)?; } output.line( "securitykeyprovider", @@ -198,21 +214,15 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re .as_deref() .unwrap_or("/usr/bin/xauth"), )?; - output.expanded("localcommand", config.local_command.as_deref(), &tokens)?; - output.expanded("remotecommand", config.remote_command.as_deref(), &tokens)?; - output.expanded( - "knownhostscommand", - config.known_hosts_command.as_deref(), - &tokens, - )?; + output.optional("localcommand", config.local_command.as_deref())?; + output.percent_expanded("remotecommand", config.remote_command.as_deref(), &tokens)?; + output.optional("knownhostscommand", config.known_hosts_command.as_deref())?; if let Some(proxy_jump) = config.proxy_jump.as_deref() { - output.line("proxyjump", tokens.expand(proxy_jump)?)?; + output.line("proxyjump", proxy_jump)?; } else { let proxy_command = config .proxy_command - .as_deref() - .map(|value| tokens.expand(value)) - .transpose()? + .clone() .unwrap_or_else(|| "none".to_string()); output.line("proxycommand", proxy_command)?; } @@ -241,21 +251,24 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re output.line("pubkeyacceptedalgorithms", pubkey_names(config))?; output.line( "hostbasedacceptedalgorithms", - list_or( + resolve_signature_policy( &config.hostbased_accepted_algorithms, - &host_key_names(config), - ), + DEFAULT_HOSTBASED_ACCEPTED_ALGORITHMS, + )?, )?; output.line( "casignaturealgorithms", - list_or( + resolve_signature_policy( &config.ca_signature_algorithms, - default_ca_signature_algorithms(), - ), + DEFAULT_CA_SIGNATURE_ALGORITHMS, + )?, + )?; + output.args( + "canonicaldomains", + &raw_option_args(config, "canonicaldomains").unwrap_or_else(|| vec!["none".to_string()]), )?; - output.line("canonicaldomains", "none")?; let revoked_host_keys = raw_option(config, "revokedhostkeys") - .map(|value| tokens.expand_path(&value)) + .map(|value| tokens.expand_path_for_dump(&value)) .transpose()? .unwrap_or_else(|| "none".to_string()); output.line("revokedhostkeys", revoked_host_keys)?; @@ -273,7 +286,11 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re .as_deref() .unwrap_or("any:any"), )?; - output.line("canonicalizepermittedcnames", "none")?; + output.args( + "canonicalizepermittedcnames", + &raw_option_args(config, "canonicalizepermittedcnames") + .unwrap_or_else(|| vec!["none".to_string()]), + )?; output.line( "streamlocalbindmask", raw_option(config, "streamlocalbindmask") @@ -295,7 +312,7 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re }); let user_hosts = user_hosts .iter() - .map(|value| tokens.expand(value)) + .map(|value| tokens.expand_for_dump(value)) .collect::>>()?; output.args("userknownhostsfile", &user_hosts)?; let global_hosts = config.global_known_hosts_file.clone().unwrap_or_else(|| { @@ -304,10 +321,6 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re "/etc/ssh/ssh_known_hosts2".to_string(), ] }); - let global_hosts = global_hosts - .iter() - .map(|value| tokens.expand(value)) - .collect::>>()?; output.args("globalknownhostsfile", &global_hosts)?; for value in &config.send_env { output.line("sendenv", value)?; @@ -315,18 +328,30 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re 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(value)?))?; + output.line("setenv", format!("{name}={value}"))?; } if !config.clear_all_forwardings.unwrap_or(false) { - for value in &config.local_forward { - output.args("localforward", &expanded_args(value, &tokens)?)?; - } - for value in &config.remote_forward { - output.args("remoteforward", &expanded_args(value, &tokens)?)?; - } - for value in &config.dynamic_forward { - output.args("dynamicforward", &expanded_args(value, &tokens)?)?; - } + 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()] @@ -350,11 +375,7 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re for (keyword, args) in &config.unimplemented_options { if !EMITTED_UNIMPLEMENTED.contains(&keyword.as_str()) { - let args = args - .iter() - .map(|value| tokens.expand(value)) - .collect::>>()?; - output.args(keyword, &args)?; + output.args(keyword, args)?; } } Ok(output.value) @@ -464,14 +485,14 @@ impl DumpWriter { Ok(()) } - fn expanded( + fn percent_expanded( &mut self, keyword: &str, value: Option<&str>, tokens: &TokenContext, ) -> Result<()> { if let Some(value) = value { - self.line(keyword, tokens.expand(value)?)?; + self.line(keyword, tokens.expand_percent(value)?)?; } Ok(()) } @@ -493,7 +514,32 @@ fn raw_option_args(config: &SshHostConfig, keyword: &str) -> Option> } fn expanded_args(value: &str, tokens: &TokenContext) -> Result> { - super::value::tokenize(&tokens.expand(value)?, 0) + 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 { @@ -640,6 +686,102 @@ fn format_rekey(limit: RekeyLimit) -> Vec { vec![data.to_string(), time.to_string()] } -fn default_ca_signature_algorithms() -> &'static 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 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 index 7a9fb711..75df0390 100644 --- a/src/ssh/ssh_config/dump/tokens.rs +++ b/src/ssh/ssh_config/dump/tokens.rs @@ -117,6 +117,37 @@ impl TokenContext { 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, + '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) @@ -127,6 +158,20 @@ impl TokenContext { }; 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)] diff --git a/src/ssh/ssh_config/dump_tests.rs b/src/ssh/ssh_config/dump_tests.rs index 7571c386..49718852 100644 --- a/src/ssh/ssh_config/dump_tests.rs +++ b/src/ssh/ssh_config/dump_tests.rs @@ -13,8 +13,14 @@ Host target KexAlgorithms -*sha1 IPQoS af21 cs1 RekeyLimit 16M 2h - SetEnv ZETA=%h ALPHA=value + SetEnv ZETA=%h ALPHA=value 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"); @@ -28,6 +34,14 @@ Host target 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=%h\n")); assert!(!first.contains("ciphers +")); assert!(!first.contains("kexalgorithms -")); } @@ -46,6 +60,23 @@ fn renderer_rejects_line_injection() { assert!(error.to_string().contains("unsafe value")); } +#[test] +fn command_tokens_expand_only_at_the_openssh_dump_stages() { + let config = SshConfig::parse( + "LocalCommand echo %h\nRemoteCommand echo %h ${HOME}\nKnownHostsCommand echo %h\nProxyCommand echo %h ${HOME}\n", + ) + .unwrap(); + let first = render_resolved_config("target", &config.find_host_config("target")).unwrap(); + assert!(first.contains(r#"localcommand "echo %h""#)); + assert!(first.contains(r#"remotecommand "echo target ${HOME}""#)); + assert!(first.contains(r#"knownhostscommand "echo %h""#)); + assert!(first.contains(r#"proxycommand "echo %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#" @@ -84,17 +115,15 @@ Host target fn forwarding_arguments_are_serialized_individually() { let source = r#" Host target - LocalForward /tmp/local /tmp/destination + 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 == "localforward /tmp/local /tmp/destination") - ); + assert!(first.lines().any( + |line| line == r#"localforward "/tmp/local socket#one" "/tmp/destination \'quoted\'""# + )); assert!( first .lines() @@ -111,6 +140,40 @@ Host target 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(); @@ -152,4 +215,81 @@ fn default_dump_has_the_audited_full_keyword_shape() { .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 0b0faf40..c9f24c64 100644 --- a/src/ssh/ssh_config/include/mod.rs +++ b/src/ssh/ssh_config/include/mod.rs @@ -27,8 +27,9 @@ 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 = 16; @@ -45,6 +46,8 @@ pub struct IncludeContext { file_count: usize, /// 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 { @@ -60,14 +63,16 @@ impl IncludeContext { depth: 0, file_count: 0, anchor, + allow_tilde: true, } } - pub fn with_anchor(anchor: PathBuf) -> Self { + pub fn with_anchor(anchor: PathBuf, allow_tilde: bool) -> Self { Self { depth: 0, file_count: 0, anchor, + allow_tilde, } } @@ -112,8 +117,18 @@ 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, } @@ -144,10 +159,20 @@ pub(crate) async fn resolve_includes_for_host_at( hostname: Option<&str>, anchor: PathBuf, ) -> Result> { - resolve_includes_for_host_at_pass(config_path, content, hostname, anchor, None, None, false) - .await + resolve_includes_for_host_at_pass( + config_path, + content, + hostname, + anchor, + None, + None, + false, + true, + ) + .await } +#[allow(clippy::too_many_arguments)] pub(crate) async fn resolve_includes_for_host_at_pass( config_path: &Path, content: &str, @@ -156,8 +181,9 @@ pub(crate) async fn resolve_includes_for_host_at_pass( effective_hostname: Option<&str>, remote_user: Option<&str>, final_pass: bool, + allow_tilde: bool, ) -> Result> { - let mut context = IncludeContext::with_anchor(anchor); + let mut context = IncludeContext::with_anchor(anchor, allow_tilde); let mut expansion = IncludeExpansionState::new(hostname, effective_hostname, remote_user, final_pass); @@ -170,6 +196,8 @@ pub(crate) async fn resolve_includes_for_host_at_pass( "Host *", &[], true, + None, + None, ) .await } @@ -180,6 +208,7 @@ struct IncludeExpansionState { effective_hostname: Option, hostname_obtained: bool, remote_user: Option, + config: super::types::SshHostConfig, final_pass: bool, } @@ -195,12 +224,14 @@ impl IncludeExpansionState { effective_hostname: effective_hostname.or(hostname).map(str::to_string), hostname_obtained: effective_hostname.is_some(), remote_user: remote_user.map(str::to_string), + config: super::types::SshHostConfig::default(), 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, @@ -209,12 +240,18 @@ async fn process_file_with_includes( 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; @@ -229,30 +266,25 @@ async fn process_file_with_includes( 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 expanded_environment = expand_include_environment(&pattern)?; - let expanded_pattern = expansion - .effective_hostname - .as_deref() - .map_or_else(|| pattern.to_string(), |host| pattern.replace("%h", host)); - let expanded_pattern = if expanded_environment == pattern { - expanded_pattern - } else { - expansion - .effective_hostname - .as_deref() - .map_or(expanded_environment.clone(), |host| { - expanded_environment.replace("%h", host) - }) - }; + let expanded_pattern = expand_include_percent(&expanded_environment, expansion)?; let resolved_files = resolve_include_pattern(&expanded_pattern, context) .await .with_context(|| { @@ -273,7 +305,7 @@ async fn process_file_with_includes( // 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(|_| { @@ -288,6 +320,10 @@ 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()); @@ -300,6 +336,8 @@ async fn process_file_with_includes( &active_scope, &child_guards, scope_active, + active_match_result, + active_final_request, )) .await?; @@ -320,6 +358,9 @@ async fn process_file_with_includes( 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; } @@ -332,23 +373,44 @@ async fn process_file_with_includes( 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(); - scope_active = inherited_active && scope_matches(trimmed, expansion)?; + 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); } } @@ -357,7 +419,13 @@ 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(), }); } @@ -367,7 +435,13 @@ 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(), }); } @@ -375,22 +449,28 @@ async fn process_file_with_includes( Ok(result) } -fn scope_matches(line: &str, state: &IncludeExpansionState) -> 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(true); + 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(super::pattern::matches_host_pattern( - original_hostname, - &patterns, - )); + 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)?; - if conditions.iter().any(match_contains_exec) { - anyhow::bail!("Match exec cannot be evaluated in side-effect-free -G mode"); - } let context = super::match_directive::MatchContext::with_original_hostname( state .effective_hostname @@ -399,35 +479,84 @@ fn scope_matches(line: &str, state: &IncludeExpansionState) -> Result { original_hostname.to_string(), state.remote_user.clone(), )? - .with_final_pass(state.final_pass); + .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, }; - block.matches(&context) -} - -fn match_contains_exec(condition: &super::match_directive::MatchCondition) -> bool { - match condition { - super::match_directive::MatchCondition::Exec(_) => true, - super::match_directive::MatchCondition::Negated(inner) => match_contains_exec(inner), - _ => false, - } + 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(value.clone()); + 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 @@ -883,6 +1012,25 @@ mod tests { assert!(!files.iter().any(|file| file.path.ends_with("alias.conf"))); } + #[test] + fn include_percent_tokens_use_current_streaming_context() { + let mut state = IncludeExpansionState::new( + Some("alias"), + Some("effective.example"), + Some("deploy"), + false, + ); + state.config.port = Some(2200); + state.config.host_key_alias = Some("key-alias".to_string()); + state.config.proxy_jump = Some("jump".to_string()); + 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"); diff --git a/src/ssh/ssh_config/include/resolver.rs b/src/ssh/ssh_config/include/resolver.rs index 4301f243..4acd53d8 100644 --- a/src/ssh/ssh_config/include/resolver.rs +++ b/src/ssh/ssh_config/include/resolver.rs @@ -19,7 +19,7 @@ use std::path::PathBuf; use super::super::diagnostic::{escape_field, escape_path}; use super::super::path::expand_path_internal; -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 @@ -53,6 +53,10 @@ 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)?; @@ -93,23 +97,7 @@ pub async fn resolve_include_pattern( } match entry { - Ok(path) => { - // Follow symlinks, then validate the target like OpenSSH's fstat path. - match std::fs::metadata(&path) { - Ok(metadata) => { - if metadata.is_file() { - validate_include_path(&path)?; - files.push(path); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(error).with_context(|| { - format!("Failed to get metadata for {}", escape_path(&path)) - }); - } - } - } + Ok(path) => files.push(path), Err(error) => { return Err(error).with_context(|| { format!( @@ -239,6 +227,24 @@ 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 diff --git a/src/ssh/ssh_config/include/validation.rs b/src/ssh/ssh_config/include/validation.rs index 0ad2921a..a46c58eb 100644 --- a/src/ssh/ssh_config/include/validation.rs +++ b/src/ssh/ssh_config/include/validation.rs @@ -16,6 +16,7 @@ use anyhow::{Context, Result}; use std::path::Path; +use tokio::io::AsyncReadExt as _; use super::super::diagnostic::{escape_field, escape_path}; @@ -52,19 +53,7 @@ pub fn validate_glob_pattern(pattern: &str) -> Result<()> { Ok(()) } -/// Validate an include file path for security -pub fn validate_include_path(path: &Path) -> Result<()> { - // Follow symlinks and validate the opened target, as OpenSSH does via fstat. - let metadata = match std::fs::metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(error) - .with_context(|| format!("Failed to get metadata for {}", 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)); } @@ -84,6 +73,56 @@ pub fn validate_include_path(path: &Path) -> Result<()> { Ok(()) } +/// 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 +} + +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); + } + 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) + ); + } + 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)] mod tests { use super::*; @@ -123,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/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 8925d114..367323ab 100644 --- a/src/ssh/ssh_config/match_directive/mod.rs +++ b/src/ssh/ssh_config/match_directive/mod.rs @@ -18,6 +18,7 @@ //! based on various criteria like hostname, username, and command execution results. use anyhow::{Context, Result}; +use sha1::{Digest, Sha1}; use std::collections::HashMap; use super::pattern::matches_host_pattern; @@ -75,16 +76,40 @@ 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 { @@ -100,6 +125,16 @@ pub struct MatchContext { 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 { @@ -114,17 +149,35 @@ impl MatchContext { original_hostname: String, remote_user: Option, ) -> Result { - // Get local username 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()); } @@ -135,6 +188,7 @@ impl MatchContext { local_user, variables, final_pass: false, + exec_policy: ExecPolicy::Direct, }) } @@ -142,6 +196,50 @@ impl MatchContext { 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 { @@ -244,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) } @@ -266,10 +371,10 @@ impl MatchCondition { // Check if local username matches any of the patterns Ok(matches_host_pattern(&context.local_user, patterns)) } - MatchCondition::Exec(command) => { - // Execute the command and check exit status - execute_match_command(command, context) - } + 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) @@ -278,6 +383,10 @@ impl MatchCondition { MatchCondition::Negated(condition) => Ok(!condition.matches(context)?), } } + + pub(crate) fn requests_final_pass(&self) -> bool { + matches!(self, MatchCondition::Final) + } } fn push_condition(conditions: &mut Vec, condition: MatchCondition, negated: bool) { @@ -420,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); @@ -559,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 a4c5676e..3a242748 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -47,6 +47,8 @@ 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}; @@ -196,37 +198,84 @@ impl SshConfig { let initial = config.find_host_config(hostname); let initial_hostname = initial.hostname; let initial_user = initial.user; - if let Some(home_dir) = dirs::home_dir() { - let user_config = home_dir.join(".ssh").join("config"); - if path_exists(&user_config).await? { - config - .append_file_for_host( - &user_config, - hostname, - initial_hostname.as_deref(), - initial_user.as_deref(), - home_dir.join(".ssh"), - true, - ) - .await?; - } + 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_hostname.as_deref(), + initial_user.as_deref(), + anchor.clone(), + true, + false, + true, + ) + .await?; } let system_config = Path::new("/etc/ssh/ssh_config"); - if path_exists(system_config).await? { + let has_system_config = path_exists(system_config).await?; + if has_system_config { let accumulated = config.find_host_config(hostname); let accumulated_hostname = accumulated.hostname; let accumulated_user = accumulated.user; config - .append_file_for_host( + .append_file_for_host_pass( system_config, hostname, accumulated_hostname.as_deref(), accumulated_user.as_deref(), 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); + let final_hostname = first_pass.hostname; + let final_user = first_pass.user; + if let Some((path, anchor)) = &user_source { + config + .append_file_for_host_pass( + path, + hostname, + final_hostname.as_deref(), + final_user.as_deref(), + anchor.clone(), + true, + true, + true, + ) + .await?; + } + if has_system_config { + let accumulated = config.find_host_config(hostname); + let accumulated_hostname = accumulated.hostname; + let accumulated_user = accumulated.user; + config + .append_file_for_host_pass( + system_config, + hostname, + accumulated_hostname.as_deref(), + accumulated_user.as_deref(), + PathBuf::from("/etc/ssh"), + false, + true, + false, + ) + .await?; + } + } Ok(config) } @@ -239,22 +288,62 @@ impl SshConfig { anchor: PathBuf, check_top_permissions: bool, ) -> Result<()> { - if check_top_permissions { - include::validate_include_path(path)?; - } - let content = tokio::fs::read_to_string(path).await.with_context(|| { - format!( - "Failed to read SSH config file: {}", - diagnostic::escape_path(path) + self.append_file_for_host_pass( + path, + hostname, + initial_hostname, + initial_user, + 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); + let effective_hostname = preliminary.hostname; + let effective_user = preliminary.user; + self.append_file_for_host_pass( + path, + hostname, + effective_hostname.as_deref(), + effective_user.as_deref(), + anchor, + check_top_permissions, + true, + true, ) - })?; - let hosts = parser::parse_from_file_for_host_at_with_diagnostics( + .await?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn append_file_for_host_pass( + &mut self, + path: &Path, + hostname: &str, + initial_hostname: Option<&str>, + initial_user: Option<&str>, + 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 expanded_initial_hostname = + initial_hostname.map(|value| resolver::expand_hostname_value(value, hostname)); + let hosts = parser::parse_from_file_for_host_pass_at_with_diagnostics( path, &content, hostname, - initial_hostname, + expanded_initial_hostname.as_deref(), initial_user, anchor, + final_pass, + allow_tilde, &mut self.reported_diagnostics, ) .await?; @@ -527,8 +616,148 @@ mod tests { 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 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 final_pass_reprocesses_percent_h_includes_and_preserves_first_values() { + 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(); + config + .append_file_for_host(&user, "alias", None, None, user_anchor, false) + .await + .unwrap(); + let accumulated = config.find_host_config("alias"); + config + .append_file_for_host( + &system, + "alias", + accumulated.hostname.as_deref(), + accumulated.user.as_deref(), + 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; @@ -560,8 +789,8 @@ mod tests { let resolved = resolver::find_host_config(&hosts, "alias"); assert_eq!(resolved.user.as_deref(), Some("first")); - assert_eq!(resolved.hostname.as_deref(), Some("final.example")); - assert_eq!(resolved.port, Some(2202), "{hosts:#?}"); + assert_eq!(resolved.hostname.as_deref(), Some("alias")); + assert_eq!(resolved.port, None, "{hosts:#?}"); } #[test] @@ -577,16 +806,110 @@ mod tests { 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(); + config + .append_file_for_host_pass( + &user, + "alias", + None, + None, + directory.path().to_path_buf(), + false, + false, + true, + ) + .await + .unwrap(); + config + .append_file_for_host_pass( + &system, + "alias", + None, + None, + 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.hostname.as_deref(), + first.user.as_deref(), + directory.path().to_path_buf(), + false, + true, + true, + ) + .await + .unwrap(); + + assert_eq!(config.find_host_config("alias").port, Some(2202)); + } + #[test] - fn final_pass_refreshes_user_and_hostname_before_each_match() { + 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("final.example")); - assert_eq!(resolved.port, Some(2202)); + assert_eq!(resolved.hostname.as_deref(), Some("alias")); + assert_eq!(resolved.port, None); } #[test] @@ -1148,24 +1471,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 90b6528c..7e55af00 100644 --- a/src/ssh/ssh_config/parser/core.rs +++ b/src/ssh/ssh_config/parser/core.rs @@ -61,7 +61,19 @@ pub(crate) async fn parse_from_file_with_diagnostics( } fn add_final_pass_configs(mut configs: Vec) -> Vec { - if !super::super::resolver::requests_final_pass(&configs) { + 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(); @@ -73,6 +85,7 @@ fn add_final_pass_configs(mut configs: Vec) -> Vec } /// 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, @@ -82,48 +95,77 @@ pub(crate) async fn parse_from_file_for_host_at_with_diagnostics( anchor: std::path::PathBuf, reported_diagnostics: &mut HashSet, ) -> Result> { - let included_files = resolve_includes_for_host_at_pass( + let first_pass = parse_from_file_for_host_pass_at_with_diagnostics( path, content, - Some(hostname), - anchor.clone(), + hostname, initial_hostname, initial_user, + anchor.clone(), false, + true, + reported_diagnostics, ) - .await - .with_context(|| format!("Failed to resolve includes for {}", escape_path(path)))?; - let first_pass = parse_included_files(&included_files, reported_diagnostics)?; - if !super::super::resolver::requests_final_pass(&first_pass) { + .await?; + if !super::super::resolver::requests_final_pass_for_host(&first_pass, hostname) { return Ok(first_pass); } - let preliminary_source = add_final_pass_configs(first_pass.clone()); - let preliminary = super::super::resolver::find_host_config(&preliminary_source, hostname); - let effective_hostname = initial_hostname.or(preliminary.hostname.as_deref()); - let effective_user = initial_user.or(preliminary.user.as_deref()); - let final_files = resolve_includes_for_host_at_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, - Some(hostname), + hostname, + preliminary.hostname.as_deref(), + preliminary.user.as_deref(), anchor, - effective_hostname, - effective_user, 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_hostname: Option<&str>, + initial_user: Option<&str>, + 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_hostname, + initial_user, + final_pass, + allow_tilde, ) .await .with_context(|| { - format!( - "Failed to resolve final-pass includes for {}", - escape_path(path) - ) + let pass = if final_pass { "final-pass " } else { "" }; + format!("Failed to resolve {pass}includes for {}", escape_path(path)) })?; - let mut final_pass = parse_included_files(&final_files, reported_diagnostics)?; - for config in &mut final_pass { - config.pass = ConfigPass::FinalOnly; + let mut configs = parse_included_files(&included_files, reported_diagnostics)?; + if final_pass { + for config in &mut configs { + config.pass = ConfigPass::FinalOnly; + } } - let mut combined = first_pass; - combined.extend(final_pass); - Ok(combined) + Ok(configs) } /// Parse SSH configuration content without Include resolution @@ -135,7 +177,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, ) } @@ -197,9 +239,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(), ) }) }), @@ -208,7 +256,17 @@ fn parse_included_files( } fn parse_lines<'a>( - lines: impl IntoIterator, usize, &'a str, &'a [String])>, + 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 @@ -219,7 +277,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, scope_guards) 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"); @@ -269,6 +336,9 @@ 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() }; @@ -301,6 +371,7 @@ 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() }; @@ -354,6 +425,7 @@ fn parse_lines<'a>( 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() }); diff --git a/src/ssh/ssh_config/parser/mod.rs b/src/ssh/ssh_config/parser/mod.rs index ef4cb38b..a28e36cc 100644 --- a/src/ssh/ssh_config/parser/mod.rs +++ b/src/ssh/ssh_config/parser/mod.rs @@ -30,9 +30,9 @@ 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_for_host_at_with_diagnostics, + parse_cli_options, parse_from_file_for_host_pass_at_with_diagnostics, parse_from_file_with_diagnostics, parse_with_diagnostics, }; diff --git a/src/ssh/ssh_config/parser/options/authentication.rs b/src/ssh/ssh_config/parser/options/authentication.rs index 63defcd9..f6f7d657 100644 --- a/src/ssh/ssh_config/parser/options/authentication.rs +++ b/src/ssh/ssh_config/parser/options/authentication.rs @@ -280,7 +280,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 +291,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 df308894..b8a07daa 100644 --- a/src/ssh/ssh_config/parser/options/forwarding.rs +++ b/src/ssh/ssh_config/parser/options/forwarding.rs @@ -52,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)); } @@ -61,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)); } @@ -70,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 8456bdcd..14c9761b 100644 --- a/src/ssh/ssh_config/parser/options/mod.rs +++ b/src/ssh/ssh_config/parser/options/mod.rs @@ -228,30 +228,25 @@ fn validate_retained_option(keyword: &str, args: &[String], line_number: usize) match keyword { "canonicalizefallbacklocal" => { boolean()?; - if !matches!(args[0].to_ascii_lowercase().as_str(), "yes" | "true") { - anyhow::bail!( - "{keyword} non-default behavior is unavailable in no-network -G mode" - ); - } } "canonicalizehostname" => { one()?; - if !matches!(args[0].to_ascii_lowercase().as_str(), "no" | "false") { - anyhow::bail!("{keyword} requires DNS and is unavailable in no-network -G mode"); + if !matches!( + args[0].to_ascii_lowercase().as_str(), + "yes" | "no" | "always" | "true" | "false" + ) { + anyhow::bail!("Invalid canonicalizehostname at line {line_number}"); } } "canonicalizemaxdots" => { one()?; - if args[0] != "1" { - anyhow::bail!("{keyword} is only accepted at its default in no-network -G mode"); - } + args[0].parse::().map_err(|_| { + anyhow::anyhow!("Invalid canonicalizemaxdots at line {line_number}") + })?; } "canonicaldomains" | "canonicalizepermittedcnames" => { - one()?; - if !args[0].eq_ignore_ascii_case("none") { - anyhow::bail!( - "{keyword} requires canonicalization and is unavailable in no-network -G mode" - ); + if args.is_empty() { + anyhow::bail!("{keyword} requires at least one value at line {line_number}"); } } "enableescapecommandline" | "streamlocalbindunlink" | "warnweakcrypto" => boolean()?, @@ -335,16 +330,21 @@ Host * 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_or_network_dependent_retained_values() { + fn rejects_invalid_retained_values() { for option in [ - "CanonicalizeFallbackLocal no", - "CanonicalizeHostname yes", - "CanonicalizeMaxDots 2", - "CanonicalDomains example.com", - "CanonicalizePermittedCNAMEs *.a:*.b", + "CanonicalizeFallbackLocal maybe", + "CanonicalizeHostname maybe", + "CanonicalizeMaxDots nope", + "CanonicalDomains", + "CanonicalizePermittedCNAMEs", "ChannelTimeout", "EnableEscapeCommandline maybe", "LogVerbose", 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/resolver.rs b/src/ssh/ssh_config/resolver.rs index 3c6542ca..9ae7a967 100644 --- a/src/ssh/ssh_config/resolver.rs +++ b/src/ssh/ssh_config/resolver.rs @@ -33,154 +33,127 @@ pub(super) fn find_host_config_with_user( hostname: &str, remote_user: Option<&str>, ) -> SshHostConfig { - // Host blocks match the destination as written. Resolve those first so - // Match host/user sees the effective HostName and remote user, as OpenSSH - // does during its final configuration pass. - let mut preliminary = SshHostConfig::default(); + let mut merged_config = SshHostConfig::default(); + let mut requests_final = false; for host_config in hosts.iter().filter(|config| config.pass == ConfigPass::Any) { - let current_hostname = preliminary - .hostname - .clone() - .unwrap_or_else(|| hostname.to_string()); - let current_user = remote_user - .map(str::to_string) - .or_else(|| preliminary.user.clone()) - .or_else(|| whoami::username().ok()); - let preliminary_context = MatchContext::with_original_hostname( - current_hostname, - hostname.to_string(), - current_user, - ) - .ok(); - let is_host_match = match &host_config.block_type { - Some(ConfigBlock::Host(patterns)) => matches_host_pattern(hostname, patterns), - Some(ConfigBlock::Match(_)) => false, - None => matches_host_pattern(hostname, &host_config.host_patterns), - } && scopes_match(host_config, hostname, preliminary_context.as_ref()); - if is_host_match { - merge_host_config(&mut preliminary, host_config); + requests_final |= apply_source_block( + &mut merged_config, + host_config, + hostname, + remote_user, + false, + ); + } + + 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); } } - let effective_hostname = preliminary - .hostname - .clone() - .unwrap_or_else(|| hostname.to_string()); - let effective_user = remote_user + merged_config +} + +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(|| preliminary.user.clone()) + .or_else(|| merged.user.clone()) .or_else(|| whoami::username().ok()); - let mut merged_config = SshHostConfig::default(); - - // Create match context for evaluating Match blocks - let match_context = match MatchContext::with_original_hostname( - effective_hostname, - hostname.to_string(), - effective_user, - ) { - Ok(ctx) => Some(ctx), - Err(e) => { - tracing::warn!("Failed to create match context: {}", e); - None - } + 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; }; - - for host_config in hosts.iter().filter(|config| config.pass == ConfigPass::Any) { - 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 + 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) - } - }; - - if should_apply && scopes_match(host_config, hostname, match_context.as_ref()) { - merge_host_config(&mut merged_config, host_config); } + 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 +} - if requests_final_pass(hosts) { - let final_hostname = merged_config - .hostname - .clone() - .unwrap_or_else(|| hostname.to_string()); - let final_user = remote_user - .map(str::to_string) - .or_else(|| merged_config.user.clone()) - .or_else(|| whoami::username().ok()); - if MatchContext::with_original_hostname(final_hostname, hostname.to_string(), final_user) - .is_ok() - { - for host_config in hosts - .iter() - .filter(|config| config.pass == ConfigPass::FinalOnly) - { - let current_hostname = merged_config - .hostname - .clone() - .unwrap_or_else(|| hostname.to_string()); - let current_user = remote_user - .map(str::to_string) - .or_else(|| merged_config.user.clone()) - .or_else(|| whoami::username().ok()); - let Ok(final_context) = MatchContext::with_original_hostname( - current_hostname, - hostname.to_string(), - current_user, - ) else { - break; - }; - let final_context = final_context.with_final_pass(true); - let should_apply = match &host_config.block_type { - Some(ConfigBlock::Host(patterns)) => matches_host_pattern(hostname, patterns), - Some(ConfigBlock::Match(conditions)) => { - conditions_match(conditions, &final_context) - } - None => matches_host_pattern(hostname, &host_config.host_patterns), - }; - if should_apply && scopes_match(host_config, hostname, Some(&final_context)) { - merge_host_config(&mut merged_config, host_config); - } +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), + ) +} + +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('%'), } } - - merged_config + output } fn conditions_match( conditions: &[super::match_directive::MatchCondition], context: &MatchContext, -) -> bool { +) -> super::match_directive::MatchEvaluation { let block = super::match_directive::MatchBlock { conditions: conditions.to_vec(), config: SshHostConfig::default(), line_number: 0, }; - block.matches(context).unwrap_or(false) + block + .evaluate(context) + .unwrap_or(super::match_directive::MatchEvaluation { + matched: false, + requests_final: false, + }) } pub(super) fn requests_final_pass(hosts: &[SshHostConfig]) -> bool { @@ -195,8 +168,30 @@ pub(super) fn requests_final_pass(hosts: &[SshHostConfig]) -> bool { }) } +pub(super) fn requests_final_pass_for_host(hosts: &[SshHostConfig], hostname: &str) -> bool { + let mut merged = SshHostConfig::default(); + let mut requests_final = false; + for source in hosts.iter().filter(|config| config.pass == ConfigPass::Any) { + requests_final |= apply_source_block(&mut merged, source, hostname, None, false); + } + 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 { - matches!(condition, super::match_directive::MatchCondition::Final) + condition.requests_final_pass() } fn scopes_match( @@ -204,6 +199,9 @@ fn scopes_match( 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| { @@ -340,23 +338,29 @@ pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfi .or_insert_with(|| value.clone()); } if !overlay.local_forward.is_empty() { - extend_for_pass( + 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() { - extend_for_pass( + 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() { - extend_for_pass( + extend_forwardings_for_pass( &mut base.dynamic_forward, + &mut base.dynamic_forward_args, &overlay.dynamic_forward, + &overlay.dynamic_forward_args, overlay.pass, ); } @@ -568,10 +572,35 @@ fn extend_for_pass(base: &mut Vec, values: &[T], pass: } } +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/types.rs b/src/ssh/ssh_config/types.rs index 32996fc9..15f31a77 100644 --- a/src/ssh/ssh_config/types.rs +++ b/src/ssh/ssh_config/types.rs @@ -43,6 +43,23 @@ pub(crate) enum ConfigPass { 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) @@ -93,6 +110,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, diff --git a/src/ssh/ssh_config/value.rs b/src/ssh/ssh_config/value.rs index 20c9f7b6..70152acc 100644 --- a/src/ssh/ssh_config/value.rs +++ b/src/ssh/ssh_config/value.rs @@ -50,7 +50,7 @@ pub(super) fn tokenize(input: &str, line_number: usize) -> Result> { Ok(result) } -pub(super) fn encode(value: &str) -> 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"); } 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 index 96c50849..4e43f062 100644 --- a/tests/ssh_config_dump_test.rs +++ b/tests/ssh_config_dump_test.rs @@ -190,6 +190,74 @@ fn stdio_forward_sets_clear_all_forwardings_unless_explicitly_overridden() { ]); 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] @@ -359,23 +427,29 @@ fn include_requires_an_exact_keyword_and_a_path() { } #[test] -fn match_exec_is_rejected_without_executing_its_command() { +#[cfg(unix)] +fn match_exec_uses_trusted_shell_without_leaking_output() { let directory = tempdir().unwrap(); let config = directory.path().join("config"); - let marker = directory.path().join("match-ran"); fs::write( &config, - format!( - "Match exec=\"touch {}\"\n User unsafe\n", - marker.display() + 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_eq!(output.status.code(), Some(255)); - assert!(!marker.exists()); - assert!(String::from_utf8_lossy(&output.stderr).contains("side-effect-free -G")); + 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] From b3a5eaf772f90041b6cd9e1ce5b604bdd8400516 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Mon, 31 Aug 2026 23:14:52 +0900 Subject: [PATCH 4/9] fix(ssh): preserve resolved context across config passes Carry the complete resolved CLI/source state into host-aware Include parsing so Match exec and Include tokens see the effective host, user, port, key alias, jump host, and connection hash across source and final-pass boundaries. Restrict trusted-shell loaders behind explicitly named config-dump APIs and keep lower-level entry points crate-private. Validate CLI, cross-source, final-pass, cache, visibility, and config-dump behavior with focused regressions. Refs #282 --- src/app/config_dump.rs | 4 +- src/ssh/ssh_config/include/mod.rs | 48 ++--- src/ssh/ssh_config/mod.rs | 289 +++++++++++++++++++++++------- src/ssh/ssh_config/parser/core.rs | 17 +- 4 files changed, 258 insertions(+), 100 deletions(-) diff --git a/src/app/config_dump.rs b/src/app/config_dump.rs index 477fb3ce..217b595e 100644 --- a/src/app/config_dump.rs +++ b/src/app/config_dump.rs @@ -15,7 +15,7 @@ use bssh::ssh::ssh_config::{SshConfig, render_resolved_config}; 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_from_file_for_host_with_options( + Some(path) => SshConfig::load_explicit_for_config_dump_with_options( path, &invocation.destination, &invocation.overrides, @@ -23,7 +23,7 @@ pub async fn handle_config_dump(invocation: &SshDumpInvocation) -> Result<()> { .await .with_context(|| format!("Failed to load SSH config from {path:?}"))?, None => { - SshConfig::load_default_for_host_with_options( + SshConfig::load_default_for_config_dump_with_options( &invocation.destination, &invocation.overrides, ) diff --git a/src/ssh/ssh_config/include/mod.rs b/src/ssh/ssh_config/include/mod.rs index c9f24c64..25218873 100644 --- a/src/ssh/ssh_config/include/mod.rs +++ b/src/ssh/ssh_config/include/mod.rs @@ -159,13 +159,13 @@ pub(crate) async fn resolve_includes_for_host_at( hostname: Option<&str>, anchor: PathBuf, ) -> Result> { + let initial_config = super::types::SshHostConfig::default(); resolve_includes_for_host_at_pass( config_path, content, hostname, anchor, - None, - None, + &initial_config, false, true, ) @@ -178,14 +178,12 @@ pub(crate) async fn resolve_includes_for_host_at_pass( content: &str, hostname: Option<&str>, anchor: PathBuf, - effective_hostname: Option<&str>, - remote_user: Option<&str>, + 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, effective_hostname, remote_user, final_pass); + let mut expansion = IncludeExpansionState::new(hostname, initial_config, final_pass); // Process the main file with includes process_file_with_includes( @@ -215,16 +213,24 @@ struct IncludeExpansionState { impl IncludeExpansionState { fn new( hostname: Option<&str>, - effective_hostname: Option<&str>, - remote_user: 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: effective_hostname.or(hostname).map(str::to_string), - hostname_obtained: effective_hostname.is_some(), - remote_user: remote_user.map(str::to_string), - config: super::types::SshHostConfig::default(), + effective_hostname, + hostname_obtained: initial_config.hostname.is_some(), + remote_user: initial_config.user.clone(), + config: initial_config.clone(), final_pass, } } @@ -1014,15 +1020,15 @@ mod tests { #[test] fn include_percent_tokens_use_current_streaming_context() { - let mut state = IncludeExpansionState::new( - Some("alias"), - Some("effective.example"), - Some("deploy"), - false, - ); - state.config.port = Some(2200); - state.config.host_key_alias = Some("key-alias".to_string()); - state.config.proxy_jump = Some("jump".to_string()); + 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, diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index 3a242748..433cf88b 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -153,12 +153,51 @@ 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`. - pub async fn load_from_file_for_host>(path: P, hostname: &str) -> Result { + #[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 async fn load_from_file_for_host_with_options>( + pub(crate) async fn load_from_file_for_host_with_options>( path: P, hostname: &str, options: &[String], @@ -172,32 +211,19 @@ impl SshConfig { config.apply_cli_options(options)?; let initial = config.find_host_config(hostname); config - .append_file_for_host( - path, - hostname, - initial.hostname.as_deref(), - initial.user.as_deref(), - anchor, - false, - ) + .append_file_for_host(path, hostname, &initial, anchor, false) .await?; Ok(config) } /// Load user and system configuration, in OpenSSH precedence order. - pub async fn load_default_for_host(hostname: &str) -> Result { - Self::load_default_for_host_with_options(hostname, &[]).await - } - - pub async fn load_default_for_host_with_options( + 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 = config.find_host_config(hostname); - let initial_hostname = initial.hostname; - let initial_user = initial.user; let user_source = if let Some(home_dir) = dirs::home_dir() { let path = home_dir.join(".ssh").join("config"); path_exists(&path) @@ -211,8 +237,7 @@ impl SshConfig { .append_file_for_host_pass( path, hostname, - initial_hostname.as_deref(), - initial_user.as_deref(), + &initial, anchor.clone(), true, false, @@ -224,14 +249,11 @@ impl SshConfig { let has_system_config = path_exists(system_config).await?; if has_system_config { let accumulated = config.find_host_config(hostname); - let accumulated_hostname = accumulated.hostname; - let accumulated_user = accumulated.user; config .append_file_for_host_pass( system_config, hostname, - accumulated_hostname.as_deref(), - accumulated_user.as_deref(), + &accumulated, PathBuf::from("/etc/ssh"), false, false, @@ -242,15 +264,12 @@ impl SshConfig { if resolver::requests_final_pass_for_host(&config.hosts, hostname) { let first_pass = config.find_host_config(hostname); - let final_hostname = first_pass.hostname; - let final_user = first_pass.user; if let Some((path, anchor)) = &user_source { config .append_file_for_host_pass( path, hostname, - final_hostname.as_deref(), - final_user.as_deref(), + &first_pass, anchor.clone(), true, true, @@ -260,14 +279,11 @@ impl SshConfig { } if has_system_config { let accumulated = config.find_host_config(hostname); - let accumulated_hostname = accumulated.hostname; - let accumulated_user = accumulated.user; config .append_file_for_host_pass( system_config, hostname, - accumulated_hostname.as_deref(), - accumulated_user.as_deref(), + &accumulated, PathBuf::from("/etc/ssh"), false, true, @@ -283,16 +299,14 @@ impl SshConfig { &mut self, path: &Path, hostname: &str, - initial_hostname: Option<&str>, - initial_user: Option<&str>, + initial_config: &SshHostConfig, anchor: PathBuf, check_top_permissions: bool, ) -> Result<()> { self.append_file_for_host_pass( path, hostname, - initial_hostname, - initial_user, + initial_config, anchor.clone(), check_top_permissions, false, @@ -301,13 +315,10 @@ impl SshConfig { .await?; if resolver::requests_final_pass_for_host(&self.hosts, hostname) { let preliminary = self.find_host_config(hostname); - let effective_hostname = preliminary.hostname; - let effective_user = preliminary.user; self.append_file_for_host_pass( path, hostname, - effective_hostname.as_deref(), - effective_user.as_deref(), + &preliminary, anchor, check_top_permissions, true, @@ -323,8 +334,7 @@ impl SshConfig { &mut self, path: &Path, hostname: &str, - initial_hostname: Option<&str>, - initial_user: Option<&str>, + initial_config: &SshHostConfig, anchor: PathBuf, check_top_permissions: bool, final_pass: bool, @@ -333,14 +343,11 @@ impl SshConfig { let content = include::read_config_file(path, check_top_permissions, false) .await? .context("Top-level SSH config disappeared after selection")?; - let expanded_initial_hostname = - initial_hostname.map(|value| resolver::expand_hostname_value(value, hostname)); let hosts = parser::parse_from_file_for_host_pass_at_with_diagnostics( path, &content, hostname, - expanded_initial_hostname.as_deref(), - initial_user, + initial_config, anchor, final_pass, allow_tilde, @@ -592,20 +599,14 @@ mod tests { ); let mut config = SshConfig::new(); + let initial = SshHostConfig::default(); config - .append_file_for_host(&user, "alias", None, None, user_anchor, false) + .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.hostname.as_deref(), - accumulated.user.as_deref(), - system_anchor, - false, - ) + .append_file_for_host(&system, "alias", &accumulated, system_anchor, false) .await .unwrap(); @@ -638,6 +639,163 @@ mod tests { 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 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() { @@ -732,20 +890,14 @@ mod tests { 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", None, None, user_anchor, false) + .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.hostname.as_deref(), - accumulated.user.as_deref(), - system_anchor, - false, - ) + .append_file_for_host(&system, "alias", &accumulated, system_anchor, false) .await .unwrap(); @@ -856,12 +1008,12 @@ mod tests { 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", - None, - None, + &initial, directory.path().to_path_buf(), false, false, @@ -869,12 +1021,12 @@ mod tests { ) .await .unwrap(); + let after_user = config.find_host_config("alias"); config .append_file_for_host_pass( &system, "alias", - None, - None, + &after_user, directory.path().to_path_buf(), false, false, @@ -887,8 +1039,7 @@ mod tests { .append_file_for_host_pass( &user, "alias", - first.hostname.as_deref(), - first.user.as_deref(), + &first, directory.path().to_path_buf(), false, true, diff --git a/src/ssh/ssh_config/parser/core.rs b/src/ssh/ssh_config/parser/core.rs index 7e55af00..6f5ef59a 100644 --- a/src/ssh/ssh_config/parser/core.rs +++ b/src/ssh/ssh_config/parser/core.rs @@ -95,12 +95,16 @@ pub(crate) async fn parse_from_file_for_host_at_with_diagnostics( 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_hostname, - initial_user, + &initial_config, anchor.clone(), false, true, @@ -115,8 +119,7 @@ pub(crate) async fn parse_from_file_for_host_at_with_diagnostics( path, content, hostname, - preliminary.hostname.as_deref(), - preliminary.user.as_deref(), + &preliminary, anchor, true, true, @@ -137,8 +140,7 @@ pub(crate) async fn parse_from_file_for_host_pass_at_with_diagnostics( path: &Path, content: &str, hostname: &str, - initial_hostname: Option<&str>, - initial_user: Option<&str>, + initial_config: &SshHostConfig, anchor: std::path::PathBuf, final_pass: bool, allow_tilde: bool, @@ -149,8 +151,7 @@ pub(crate) async fn parse_from_file_for_host_pass_at_with_diagnostics( content, Some(hostname), anchor, - initial_hostname, - initial_user, + initial_config, final_pass, allow_tilde, ) From 74bb099954dca6edf1afb2f384f01121b60c007f Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Mon, 31 Aug 2026 23:29:12 +0900 Subject: [PATCH 5/9] fix(cli): expand effective config dump values --- src/ssh/ssh_config/dump.rs | 32 +++++++++-- src/ssh/ssh_config/dump/tokens.rs | 6 +++ src/ssh/ssh_config/dump_tests.rs | 34 +++++++++++- .../parser/options/authentication.rs | 2 + src/ssh/ssh_config/resolver.rs | 41 +++++++++++++- src/ssh/ssh_config/types.rs | 4 ++ tests/ssh_config_dump_test.rs | 54 +++++++++++++++++++ 7 files changed, 164 insertions(+), 9 deletions(-) diff --git a/src/ssh/ssh_config/dump.rs b/src/ssh/ssh_config/dump.rs index ed9eadad..c4043d40 100644 --- a/src/ssh/ssh_config/dump.rs +++ b/src/ssh/ssh_config/dump.rs @@ -18,7 +18,8 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re tokens.effective_host = super::resolver::expand_hostname_value(&tokens.effective_host, original_host); tokens.remote_user = tokens.expand(&tokens.remote_user)?; - tokens.refresh_hash(config.proxy_jump.as_deref().unwrap_or("")); + 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))?; @@ -301,8 +302,8 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re for identity in identity_files(config) { output.line("identityfile", identity)?; } - for certificate in &config.certificate_files { - output.line("certificatefile", certificate.to_string_lossy())?; + for certificate in certificate_files(config) { + output.line("certificatefile", certificate)?; } let user_hosts = config.user_known_hosts_file.clone().unwrap_or_else(|| { vec![ @@ -312,7 +313,7 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re }); let user_hosts = user_hosts .iter() - .map(|value| tokens.expand_for_dump(value)) + .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(|| { @@ -328,7 +329,10 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re 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}={value}"))?; + output.line( + "setenv", + format!("{name}={}", tokens.expand_for_dump(value)?), + )?; } if !config.clear_all_forwardings.unwrap_or(false) { output_forwardings( @@ -627,6 +631,11 @@ fn identity_files(config: &SshHostConfig) -> Vec { .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 @@ -640,6 +649,19 @@ fn identity_files(config: &SshHostConfig) -> Vec { .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)] } diff --git a/src/ssh/ssh_config/dump/tokens.rs b/src/ssh/ssh_config/dump/tokens.rs index 75df0390..37d27d3e 100644 --- a/src/ssh/ssh_config/dump/tokens.rs +++ b/src/ssh/ssh_config/dump/tokens.rs @@ -16,6 +16,7 @@ pub(super) struct TokenContext { local_uid: String, pub(super) port: String, host_key_alias: String, + jump_host: String, connection_hash: String, } @@ -49,11 +50,14 @@ impl TokenContext { .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()); @@ -103,6 +107,7 @@ impl TokenContext { '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, @@ -134,6 +139,7 @@ impl TokenContext { '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, diff --git a/src/ssh/ssh_config/dump_tests.rs b/src/ssh/ssh_config/dump_tests.rs index 49718852..306aec6c 100644 --- a/src/ssh/ssh_config/dump_tests.rs +++ b/src/ssh/ssh_config/dump_tests.rs @@ -13,7 +13,7 @@ Host target KexAlgorithms -*sha1 IPQoS af21 cs1 RekeyLimit 16M 2h - SetEnv ZETA=%h ALPHA=value LITERAL=$${NOT_EXPANDED} + SetEnv ZETA=%h ALPHA=value JUMP=%j ENV_HOME=${HOME} LITERAL=$${NOT_EXPANDED} ForwardAgent /tmp/%h-agent IdentityAgent /tmp/$${LITERAL}-agent BindAddress %h @@ -41,11 +41,41 @@ Host target 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=%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 { diff --git a/src/ssh/ssh_config/parser/options/authentication.rs b/src/ssh/ssh_config/parser/options/authentication.rs index f6f7d657..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() { diff --git a/src/ssh/ssh_config/resolver.rs b/src/ssh/ssh_config/resolver.rs index 9ae7a967..f1a95a45 100644 --- a/src/ssh/ssh_config/resolver.rs +++ b/src/ssh/ssh_config/resolver.rs @@ -241,9 +241,11 @@ pub(super) fn merge_host_config(base: &mut SshHostConfig, overlay: &SshHostConfi base.port = overlay.port; } if !overlay.identity_files.is_empty() { - extend_for_pass( + extend_paths_for_pass( &mut base.identity_files, + &mut base.identity_file_args, &overlay.identity_files, + &overlay.identity_file_args, overlay.pass, ); } @@ -409,7 +411,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 { @@ -420,6 +428,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()); + } } } } @@ -572,6 +584,31 @@ fn extend_for_pass(base: &mut Vec, values: &[T], pass: } } +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>, diff --git a/src/ssh/ssh_config/types.rs b/src/ssh/ssh_config/types.rs index 15f31a77..29ad2634 100644 --- a/src/ssh/ssh_config/types.rs +++ b/src/ssh/ssh_config/types.rs @@ -70,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 @@ -129,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, diff --git a/tests/ssh_config_dump_test.rs b/tests/ssh_config_dump_test.rs index 4e43f062..194393c1 100644 --- a/tests/ssh_config_dump_test.rs +++ b/tests/ssh_config_dump_test.rs @@ -452,6 +452,60 @@ fn match_exec_uses_trusted_shell_without_leaking_output() { 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(); From eca3c34a39ea9033805ea6f0c885edf498e77434 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Mon, 31 Aug 2026 23:43:09 +0900 Subject: [PATCH 6/9] fix(ssh): defer hostname fixation until final config pass Resolve source-boundary context from pass-one blocks only so a user Match final request cannot fix an unset HostName before system config is parsed. Keep the complete first-pass config available to system Include and Match exec token expansion, then retain the existing final-pass bootstrap after all sources have run. Add a user/system regression that proves system HostName, %h Include, and Match exec behavior across both passes. Refs #282 --- src/ssh/ssh_config/mod.rs | 95 +++++++++++++++++++++++++++++++++- src/ssh/ssh_config/resolver.rs | 48 +++++++++++------ 2 files changed, 126 insertions(+), 17 deletions(-) diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index 433cf88b..61a4acc6 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -248,7 +248,7 @@ impl SshConfig { let system_config = Path::new("/etc/ssh/ssh_config"); let has_system_config = path_exists(system_config).await?; if has_system_config { - let accumulated = config.find_host_config(hostname); + let accumulated = resolver::find_host_config_first_pass(&config.hosts, hostname); config .append_file_for_host_pass( system_config, @@ -1051,6 +1051,99 @@ mod tests { 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( diff --git a/src/ssh/ssh_config/resolver.rs b/src/ssh/ssh_config/resolver.rs index f1a95a45..be41f9de 100644 --- a/src/ssh/ssh_config/resolver.rs +++ b/src/ssh/ssh_config/resolver.rs @@ -33,17 +33,8 @@ pub(super) fn find_host_config_with_user( hostname: &str, remote_user: Option<&str>, ) -> SshHostConfig { - 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, - ); - } + let (mut merged_config, requests_final) = + resolve_first_pass_with_user(hosts, hostname, remote_user); if requests_final || canonicalization_requested(&merged_config) { // OpenSSH fixes HostName to the first-pass effective destination before @@ -61,6 +52,35 @@ pub(super) fn find_host_config_with_user( 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, @@ -169,11 +189,7 @@ pub(super) fn requests_final_pass(hosts: &[SshHostConfig]) -> bool { } pub(super) fn requests_final_pass_for_host(hosts: &[SshHostConfig], hostname: &str) -> bool { - let mut merged = SshHostConfig::default(); - let mut requests_final = false; - for source in hosts.iter().filter(|config| config.pass == ConfigPass::Any) { - requests_final |= apply_source_block(&mut merged, source, hostname, None, false); - } + let (merged, requests_final) = resolve_first_pass_with_user(hosts, hostname, None); requests_final || canonicalization_requested(&merged) } From c126c92cf79616113a9b01c356d1ba1b14b33926 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Mon, 31 Aug 2026 23:54:11 +0900 Subject: [PATCH 7/9] fix(ssh): preserve first-pass hostname precedence --- src/ssh/ssh_config/mod.rs | 70 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index 61a4acc6..deda3c02 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -209,7 +209,7 @@ impl SshConfig { .unwrap_or_else(|| PathBuf::from("/")); let mut config = Self::new(); config.apply_cli_options(options)?; - let initial = config.find_host_config(hostname); + let initial = resolver::find_host_config_first_pass(&config.hosts, hostname); config .append_file_for_host(path, hostname, &initial, anchor, false) .await?; @@ -223,7 +223,7 @@ impl SshConfig { ) -> Result { let mut config = Self::new(); config.apply_cli_options(options)?; - let initial = config.find_host_config(hostname); + 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) @@ -665,6 +665,72 @@ mod tests { 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() { From f6b49f6b42c4e624da9729e8e2c127e2806a8ccc Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Tue, 1 Sep 2026 00:18:09 +0900 Subject: [PATCH 8/9] fix(ssh): preserve command shell quoting --- src/ssh/ssh_config/dump.rs | 31 +++++++++++++++++----- src/ssh/ssh_config/dump_tests.rs | 10 +++---- src/ssh/ssh_config/parser/core.rs | 42 ++++++++++++++++++++++++++++-- src/ssh/ssh_config/parser/tests.rs | 2 +- 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/ssh/ssh_config/dump.rs b/src/ssh/ssh_config/dump.rs index c4043d40..f4c918a3 100644 --- a/src/ssh/ssh_config/dump.rs +++ b/src/ssh/ssh_config/dump.rs @@ -215,9 +215,9 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re .as_deref() .unwrap_or("/usr/bin/xauth"), )?; - output.optional("localcommand", config.local_command.as_deref())?; - output.percent_expanded("remotecommand", config.remote_command.as_deref(), &tokens)?; - output.optional("knownhostscommand", config.known_hosts_command.as_deref())?; + 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 { @@ -225,7 +225,7 @@ pub fn render_resolved_config(original_host: &str, config: &SshHostConfig) -> Re .proxy_command .clone() .unwrap_or_else(|| "none".to_string()); - output.line("proxycommand", proxy_command)?; + 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"))?; @@ -478,6 +478,18 @@ impl DumpWriter { .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)) } @@ -489,14 +501,21 @@ impl DumpWriter { Ok(()) } - fn percent_expanded( + 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.line(keyword, tokens.expand_percent(value)?)?; + self.command(keyword, &tokens.expand_percent(value)?)?; } Ok(()) } diff --git a/src/ssh/ssh_config/dump_tests.rs b/src/ssh/ssh_config/dump_tests.rs index 306aec6c..c995320d 100644 --- a/src/ssh/ssh_config/dump_tests.rs +++ b/src/ssh/ssh_config/dump_tests.rs @@ -93,14 +93,14 @@ fn renderer_rejects_line_injection() { #[test] fn command_tokens_expand_only_at_the_openssh_dump_stages() { let config = SshConfig::parse( - "LocalCommand echo %h\nRemoteCommand echo %h ${HOME}\nKnownHostsCommand echo %h\nProxyCommand echo %h ${HOME}\n", + "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(r#"localcommand "echo %h""#)); - assert!(first.contains(r#"remotecommand "echo target ${HOME}""#)); - assert!(first.contains(r#"knownhostscommand "echo %h""#)); - assert!(first.contains(r#"proxycommand "echo %h ${HOME}""#)); + 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(); diff --git a/src/ssh/ssh_config/parser/core.rs b/src/ssh/ssh_config/parser/core.rs index 6f5ef59a..779ca89e 100644 --- a/src/ssh/ssh_config/parser/core.rs +++ b/src/ssh/ssh_config/parser/core.rs @@ -213,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"); @@ -384,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; @@ -488,6 +488,44 @@ fn parse_option_first( Ok(()) } +/// 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)); + } + + let remainder = config_value_remainder(line); + Ok((keyword, vec![remainder.to_string()])) +} + +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 +} + /// 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)?; diff --git a/src/ssh/ssh_config/parser/tests.rs b/src/ssh/ssh_config/parser/tests.rs index 30e1aa49..17394928 100644 --- a/src/ssh/ssh_config/parser/tests.rs +++ b/src/ssh/ssh_config/parser/tests.rs @@ -143,7 +143,7 @@ Host example.com assert_eq!( hosts[0].proxy_command, - Some("env SSH_SK_HELPER=/tmp/ssh-sk-helper nc %h %p".to_string()) + Some("env SSH_SK_HELPER=\"/tmp/ssh-sk-helper\" nc %h %p".to_string()) ); } From c3fb91f91ae34e0d6798dcca1e1d289f51a8a389 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Tue, 1 Sep 2026 00:46:44 +0900 Subject: [PATCH 9/9] fix(ssh): normalize unquoted command spacing --- src/ssh/ssh_config/parser/core.rs | 58 ++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/ssh/ssh_config/parser/core.rs b/src/ssh/ssh_config/parser/core.rs index 779ca89e..32317e5a 100644 --- a/src/ssh/ssh_config/parser/core.rs +++ b/src/ssh/ssh_config/parser/core.rs @@ -506,7 +506,7 @@ fn parse_option_line( } let remainder = config_value_remainder(line); - Ok((keyword, vec![remainder.to_string()])) + Ok((keyword, vec![normalize_command_remainder(remainder)])) } fn config_value_remainder(line: &str) -> &str { @@ -526,6 +526,49 @@ fn config_value_remainder(line: &str) -> &str { remainder } +/// 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); + } + } + + 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)?; @@ -590,3 +633,16 @@ pub(super) fn parse_config_line( } 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""# + ); + } +}