Skip to main content

ssh2_config/
parser.rs

1//! # parser
2//!
3//! Ssh config parser
4
5use std::fs::File;
6use std::io::{BufRead, BufReader, Error as IoError};
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9use std::time::Duration;
10
11use bitflags::bitflags;
12use glob::{Pattern, glob};
13use thiserror::Error;
14
15use super::{Host, HostClause, HostParams, HostScope, SshConfig};
16use crate::params::AlgorithmsRule;
17use crate::{DefaultAlgorithms, RemoteForward, RemoteForwardDestination, RemoteForwardListen};
18
19// modules
20mod field;
21use field::Field;
22
23pub type SshParserResult<T> = Result<T, SshParserError>;
24
25/// State shared by a parse and every file it includes.
26pub(crate) struct IncludeContext {
27    /// Resolved home directory used to expand `~` and relative `Include` paths.
28    home: Option<PathBuf>,
29    /// Canonical paths of the files currently being included.
30    stack: Vec<PathBuf>,
31}
32
33impl IncludeContext {
34    /// Creates a context with the given home directory and no file being included.
35    pub(crate) fn new(home: Option<PathBuf>) -> Self {
36        Self {
37            home,
38            stack: Vec::new(),
39        }
40    }
41}
42
43/// Ssh config parser error
44#[derive(Debug, Error)]
45pub enum SshParserError {
46    #[error("expected boolean value ('yes', 'no')")]
47    ExpectedBoolean,
48    #[error("expected port number")]
49    ExpectedPort,
50    #[error("expected unsigned value")]
51    ExpectedUnsigned,
52    #[error("expected algorithms")]
53    ExpectedAlgorithms,
54    #[error("expected path")]
55    ExpectedPath,
56    /// A `~` or relative path needs a home directory, and none is available.
57    ///
58    /// The string is the argument that needed the home directory.
59    #[error("home directory not found for argument: {0}")]
60    HomeNotFound(String),
61    /// An `Include` chain reached a file that is already being included.
62    ///
63    /// The path is the canonical path of the file that closes the loop.
64    #[error("include cycle detected: {path}", path = .0.display())]
65    IncludeCycle(PathBuf),
66    #[error("IO error: {0}")]
67    Io(#[from] IoError),
68    #[error("glob error: {0}")]
69    Glob(#[from] glob::GlobError),
70    #[error("invalid quotes")]
71    InvalidQuotes,
72    /// The `RemoteForward` arguments do not match an OpenSSH forwarding form.
73    #[error("invalid RemoteForward arguments: {0:?}")]
74    InvalidRemoteForward(Vec<String>),
75    #[error("missing argument")]
76    MissingArgument,
77    #[error("pattern error: {0}")]
78    PatternError(#[from] glob::PatternError),
79    #[error("unknown field: {0}")]
80    UnknownField(String, Vec<String>),
81    #[error("unknown field: {0}")]
82    UnsupportedField(String, Vec<String>),
83}
84
85bitflags! {
86    /// The parsing mode
87    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
88    pub struct ParseRule: u8 {
89        /// Don't allow any invalid field or value
90        const STRICT = 0b00000000;
91        /// Allow unknown field
92        const ALLOW_UNKNOWN_FIELDS = 0b00000001;
93        /// Allow unsupported fields
94        const ALLOW_UNSUPPORTED_FIELDS = 0b00000010;
95    }
96}
97
98// -- parser
99
100/// Ssh config parser
101pub(crate) struct SshConfigParser;
102
103impl SshConfigParser {
104    /// Parse reader lines and apply parameters to configuration
105    ///
106    /// `include` holds the home directory used to expand `~` and relative `Include` paths,
107    /// and the canonical paths of the files currently being included.
108    pub(crate) fn parse(
109        config: &mut SshConfig,
110        reader: &mut impl BufRead,
111        rules: ParseRule,
112        ignore_unknown: Option<Vec<String>>,
113        include: &mut IncludeContext,
114    ) -> SshParserResult<()> {
115        // Options preceding the first `Host` section
116        // are parsed as command line options;
117        // overriding all following host-specific options.
118        //
119        // See https://github.com/openssh/openssh-portable/blob/master/readconf.c#L1173-L1176
120        let mut default_params = HostParams::new(&config.default_algorithms);
121        default_params.ignore_unknown = ignore_unknown;
122        let mut current_host_index = config.append_host(
123            Host::new(
124                vec![HostClause::new(String::from("*"), false)],
125                default_params,
126            ),
127            Vec::new(),
128        );
129
130        let mut lines = reader.lines();
131        // iter lines
132        loop {
133            let line = match lines.next() {
134                None => break,
135                Some(Err(err)) => return Err(SshParserError::Io(err)),
136                Some(Ok(line)) => Self::strip_comments(line.trim()),
137            };
138            if line.is_empty() {
139                continue;
140            }
141            // tokenize
142            let (field, args) = match Self::tokenize_line(&line) {
143                Ok((field, args)) => (field, args),
144                Err(SshParserError::UnknownField(field, args))
145                    if rules.intersects(ParseRule::ALLOW_UNKNOWN_FIELDS)
146                        || config.hosts[current_host_index].params.ignored(&field) =>
147                {
148                    config.hosts[current_host_index]
149                        .params
150                        .ignored_fields
151                        .insert(field, args);
152                    continue;
153                }
154                Err(SshParserError::UnknownField(field, args)) => {
155                    return Err(SshParserError::UnknownField(field, args));
156                }
157                Err(err) => return Err(err),
158            };
159            // If field is block, init a new block
160            if field == Field::Host {
161                // Pass `ignore_unknown` from global overrides down into the tokenizer.
162                let mut params = HostParams::new(&config.default_algorithms);
163                params.ignore_unknown = config.hosts[0].params.ignore_unknown.clone();
164                let pattern = Self::parse_host(args)?;
165                trace!("Adding new host: {pattern:?}",);
166
167                // Add a new host
168                // Update current host index
169                current_host_index = config.append_host(Host::new(pattern, params), Vec::new());
170            } else if field == Field::Include {
171                let (pattern, inherited_scope, ignore_unknown) = {
172                    let host = &config.hosts[current_host_index];
173                    (
174                        host.pattern.clone(),
175                        config
176                            .host_scopes
177                            .get(current_host_index)
178                            .cloned()
179                            .unwrap_or_default(),
180                        host.params.ignore_unknown.clone(),
181                    )
182                };
183                let new_hosts = Self::include_files(
184                    args,
185                    &pattern,
186                    &inherited_scope,
187                    rules,
188                    &config.default_algorithms,
189                    ignore_unknown.clone(),
190                    include,
191                )?;
192                for (host, scope) in new_hosts {
193                    config.append_host(host, scope);
194                }
195
196                let mut params = HostParams::new(&config.default_algorithms);
197                params.ignore_unknown = ignore_unknown;
198                current_host_index =
199                    config.append_host(Host::new(pattern, params), inherited_scope);
200            } else {
201                // Update field
202                Self::update_host(
203                    field,
204                    args,
205                    &mut config.hosts[current_host_index],
206                    include.home.as_deref(),
207                )
208                .or_else(|err| match err {
209                    // If we're allowing unsupported fields to be parsed, add them to the map
210                    SshParserError::UnsupportedField(field, args)
211                        if rules.intersects(ParseRule::ALLOW_UNSUPPORTED_FIELDS) =>
212                    {
213                        config.hosts[current_host_index]
214                            .params
215                            .unsupported_fields
216                            .insert(field, args);
217                        Ok(())
218                    }
219                    // Eat the error here to not break the API with this change
220                    // Also it'd be weird to error on correct ssh_config's just because they're
221                    // not supported by this library
222                    SshParserError::UnsupportedField(_, _) => Ok(()),
223                    e => Err(e),
224                })?;
225            }
226        }
227
228        Ok(())
229    }
230
231    /// Strip comments from line (quote-aware)
232    fn strip_comments(s: &str) -> String {
233        let mut in_quotes = false;
234        let mut result = String::new();
235
236        for c in s.chars() {
237            match c {
238                '"' => {
239                    in_quotes = !in_quotes;
240                    result.push(c);
241                }
242                '#' if !in_quotes => {
243                    // Found a comment outside quotes, stop here
244                    break;
245                }
246                _ => {
247                    result.push(c);
248                }
249            }
250        }
251
252        result
253    }
254
255    /// Split an argument string by whitespace while keeping quoted spans (`"..."`) as part of
256    /// the same token. Backslash escapes inside quotes (`\"`, `\\`) are preserved verbatim so
257    /// the caller can decide whether to unescape. Tokens may mix quoted and unquoted parts
258    /// (e.g. `KEY="value with spaces"`).
259    fn split_args_respecting_quotes(s: &str) -> Vec<String> {
260        let mut result = Vec::new();
261        let mut current = String::new();
262        let mut has_token = false;
263        let mut in_quotes = false;
264        let mut chars = s.chars().peekable();
265        while let Some(c) = chars.next() {
266            if in_quotes {
267                current.push(c);
268                if c == '\\' {
269                    if let Some(&nc) = chars.peek() {
270                        current.push(nc);
271                        chars.next();
272                    }
273                } else if c == '"' {
274                    in_quotes = false;
275                }
276            } else if c.is_whitespace() {
277                if has_token {
278                    result.push(std::mem::take(&mut current));
279                    has_token = false;
280                }
281            } else if c == '"' {
282                current.push(c);
283                in_quotes = true;
284                has_token = true;
285            } else {
286                current.push(c);
287                has_token = true;
288            }
289        }
290        if has_token {
291            result.push(current);
292        }
293        result
294    }
295
296    /// Count unescaped double quotes in a string.
297    /// A quote is considered escaped if preceded by a backslash that is not itself escaped.
298    fn count_unescaped_quotes(s: &str) -> usize {
299        let mut count = 0;
300        let chars: Vec<char> = s.chars().collect();
301        let mut i = 0;
302        while i < chars.len() {
303            if chars[i] == '\\' && i + 1 < chars.len() {
304                // Skip the escaped character
305                i += 2;
306            } else if chars[i] == '"' {
307                count += 1;
308                i += 1;
309            } else {
310                i += 1;
311            }
312        }
313        count
314    }
315
316    /// Check if a string ends with an unescaped double quote.
317    fn ends_with_unescaped_quote(s: &str) -> bool {
318        if !s.ends_with('"') {
319            return false;
320        }
321        // Count trailing backslashes before the final quote
322        let chars: Vec<char> = s.chars().collect();
323        let mut backslash_count = 0;
324        for i in (0..chars.len() - 1).rev() {
325            if chars[i] == '\\' {
326                backslash_count += 1;
327            } else {
328                break;
329            }
330        }
331        // If even number of backslashes, the quote is unescaped
332        backslash_count % 2 == 0
333    }
334
335    /// Process escape sequences in a string.
336    /// Handles: \" -> ", \\ -> \, \' -> '
337    /// Unrecognized escapes preserve the backslash.
338    fn unescape_string(s: &str) -> String {
339        let mut result = String::with_capacity(s.len());
340        let chars: Vec<char> = s.chars().collect();
341        let mut i = 0;
342        while i < chars.len() {
343            if chars[i] == '\\' && i + 1 < chars.len() {
344                let next = chars[i + 1];
345                match next {
346                    '"' | '\\' | '\'' => {
347                        // Recognized escape sequence: skip backslash, add the character
348                        result.push(next);
349                        i += 2;
350                    }
351                    _ => {
352                        // Unrecognized escape: preserve the backslash
353                        result.push(chars[i]);
354                        i += 1;
355                    }
356                }
357            } else {
358                result.push(chars[i]);
359                i += 1;
360            }
361        }
362        result
363    }
364
365    /// Update current given host with field argument
366    fn update_host(
367        field: Field,
368        args: Vec<String>,
369        host: &mut Host,
370        home: Option<&Path>,
371    ) -> SshParserResult<()> {
372        trace!("parsing field {field:?} with args {args:?}",);
373        let params = &mut host.params;
374        match field {
375            Field::AddKeysToAgent => {
376                let value = Self::parse_boolean(args)?;
377                trace!("add_keys_to_agent: {value}",);
378                params.add_keys_to_agent = Some(value);
379            }
380            Field::BindAddress => {
381                let value = Self::parse_string(args)?;
382                trace!("bind_address: {value}",);
383                params.bind_address = Some(value);
384            }
385            Field::BindInterface => {
386                let value = Self::parse_string(args)?;
387                trace!("bind_interface: {value}",);
388                params.bind_interface = Some(value);
389            }
390            Field::CaSignatureAlgorithms => {
391                let rule = Self::parse_algos(args)?;
392                trace!("ca_signature_algorithms: {rule:?}",);
393                params.ca_signature_algorithms.apply(rule);
394            }
395            Field::CertificateFile => {
396                let value = Self::parse_path(args, home)?;
397                trace!("certificate_file: {value:?}",);
398                params.certificate_file = Some(value);
399            }
400            Field::Ciphers => {
401                let rule = Self::parse_algos(args)?;
402                trace!("ciphers: {rule:?}",);
403                params.ciphers.apply(rule);
404            }
405            Field::Compression => {
406                let value = Self::parse_boolean(args)?;
407                trace!("compression: {value}",);
408                params.compression = Some(value);
409            }
410            Field::ConnectTimeout => {
411                let value = Self::parse_duration(args)?;
412                trace!("connect_timeout: {value:?}",);
413                params.connect_timeout = Some(value);
414            }
415            Field::ConnectionAttempts => {
416                let value = Self::parse_unsigned(args)?;
417                trace!("connection_attempts: {value}",);
418                params.connection_attempts = Some(value);
419            }
420            Field::ForwardAgent => {
421                let value = Self::parse_boolean(args)?;
422                trace!("forward_agent: {value}",);
423                params.forward_agent = Some(value);
424            }
425            Field::Host => { /* already handled before */ }
426            Field::HostKeyAlgorithms => {
427                let rule = Self::parse_algos(args)?;
428                trace!("host_key_algorithm: {rule:?}",);
429                params.host_key_algorithms.apply(rule);
430            }
431            Field::HostName => {
432                let value = Self::parse_string(args)?;
433                trace!("host_name: {value}",);
434                params.host_name = Some(value);
435            }
436            Field::Include => { /* handled in `parse` before `update_host` is called */ }
437            Field::IdentityFile => {
438                let value = Self::parse_path_list(args, home)?;
439                trace!("identity_file: {value:?}",);
440                if let Some(existing) = &mut params.identity_file {
441                    existing.extend(value);
442                } else {
443                    params.identity_file = Some(value);
444                }
445            }
446            Field::IgnoreUnknown => {
447                let value = Self::parse_comma_separated_list(args)?;
448                trace!("ignore_unknown: {value:?}",);
449                params.ignore_unknown = Some(value);
450            }
451            Field::KexAlgorithms => {
452                let rule = Self::parse_algos(args)?;
453                trace!("kex_algorithms: {rule:?}",);
454                params.kex_algorithms.apply(rule);
455            }
456            Field::Mac => {
457                let rule = Self::parse_algos(args)?;
458                trace!("mac: {rule:?}",);
459                params.mac.apply(rule);
460            }
461            Field::Port => {
462                let value = Self::parse_port(args)?;
463                trace!("port: {value}",);
464                params.port = Some(value);
465            }
466            Field::ProxyJump => {
467                let rule = Self::parse_comma_separated_list(args)?;
468                trace!("proxy_jump: {rule:?}",);
469                params.proxy_jump = Some(rule);
470            }
471            Field::PubkeyAcceptedAlgorithms => {
472                let rule = Self::parse_algos(args)?;
473                trace!("pubkey_accepted_algorithms: {rule:?}",);
474                params.pubkey_accepted_algorithms.apply(rule);
475            }
476            Field::PubkeyAuthentication => {
477                let value = Self::parse_boolean(args)?;
478                trace!("pubkey_authentication: {value}",);
479                params.pubkey_authentication = Some(value);
480            }
481            Field::RemoteForward => {
482                let value = Self::parse_remote_forward(args)?;
483                trace!("remote_forward: {value}",);
484                params.remote_forward.push(value);
485            }
486            Field::ServerAliveInterval => {
487                let value = Self::parse_duration(args)?;
488                trace!("server_alive_interval: {value:?}",);
489                params.server_alive_interval = Some(value);
490            }
491            Field::TcpKeepAlive => {
492                let value = Self::parse_boolean(args)?;
493                trace!("tcp_keep_alive: {value}",);
494                params.tcp_keep_alive = Some(value);
495            }
496            #[cfg(target_os = "macos")]
497            Field::UseKeychain => {
498                let value = Self::parse_boolean(args)?;
499                trace!("use_keychain: {value}",);
500                params.use_keychain = Some(value);
501            }
502            Field::User => {
503                let value = Self::parse_string(args)?;
504                trace!("user: {value}",);
505                params.user = Some(value);
506            }
507            // -- unimplemented fields
508            Field::AddressFamily
509            | Field::BatchMode
510            | Field::CanonicalDomains
511            | Field::CanonicalizeFallbackLock
512            | Field::CanonicalizeHostname
513            | Field::CanonicalizeMaxDots
514            | Field::CanonicalizePermittedCNAMEs
515            | Field::CheckHostIP
516            | Field::ClearAllForwardings
517            | Field::ControlMaster
518            | Field::ControlPath
519            | Field::ControlPersist
520            | Field::DynamicForward
521            | Field::EnableSSHKeysign
522            | Field::EscapeChar
523            | Field::ExitOnForwardFailure
524            | Field::FingerprintHash
525            | Field::ForkAfterAuthentication
526            | Field::ForwardX11
527            | Field::ForwardX11Timeout
528            | Field::ForwardX11Trusted
529            | Field::GatewayPorts
530            | Field::GlobalKnownHostsFile
531            | Field::GSSAPIAuthentication
532            | Field::GSSAPIDelegateCredentials
533            | Field::HashKnownHosts
534            | Field::HostbasedAcceptedAlgorithms
535            | Field::HostbasedAuthentication
536            | Field::HostKeyAlias
537            | Field::HostbasedKeyTypes
538            | Field::IdentitiesOnly
539            | Field::IdentityAgent
540            | Field::IPQoS
541            | Field::KbdInteractiveAuthentication
542            | Field::KbdInteractiveDevices
543            | Field::KnownHostsCommand
544            | Field::LocalCommand
545            | Field::LocalForward
546            | Field::LogLevel
547            | Field::LogVerbose
548            | Field::NoHostAuthenticationForLocalhost
549            | Field::NumberOfPasswordPrompts
550            | Field::PasswordAuthentication
551            | Field::PermitLocalCommand
552            | Field::PermitRemoteOpen
553            | Field::PKCS11Provider
554            | Field::PreferredAuthentications
555            | Field::ProxyCommand
556            | Field::ProxyUseFdpass
557            | Field::PubkeyAcceptedKeyTypes
558            | Field::RekeyLimit
559            | Field::RequestTTY
560            | Field::RevokedHostKeys
561            | Field::SecruityKeyProvider
562            | Field::SendEnv
563            | Field::ServerAliveCountMax
564            | Field::SessionType
565            | Field::SetEnv
566            | Field::StdinNull
567            | Field::StreamLocalBindMask
568            | Field::StrictHostKeyChecking
569            | Field::SyslogFacility
570            | Field::UpdateHostKeys
571            | Field::UserKnownHostsFile
572            | Field::VerifyHostKeyDNS
573            | Field::VisualHostKey
574            | Field::XAuthLocation => {
575                return Err(SshParserError::UnsupportedField(field.to_string(), args));
576            }
577        }
578        Ok(())
579    }
580
581    /// Resolve the include path for a given path match.
582    ///
583    /// Absolute paths are returned as-is. `~` and `~` plus a path separator are expanded against `home`,
584    /// other `~`-prefixed paths, such as `~user/...`, are kept as-is, like [`Self::parse_path_arg`] does,
585    /// and any other relative path is resolved against `home/.ssh`.
586    ///
587    /// The result is a glob pattern: `home` is escaped, so only `path_match` carries pattern syntax.
588    fn resolve_include_path(path_match: &str, home: Option<&Path>) -> SshParserResult<String> {
589        #[cfg(windows)]
590        const PATH_SEPARATOR: &str = "\\";
591        #[cfg(unix)]
592        const PATH_SEPARATOR: &str = "/";
593
594        let escaped_home =
595            |home: &Path| PathBuf::from(Pattern::escape(&home.display().to_string()));
596
597        if path_match.starts_with(PATH_SEPARATOR) {
598            Ok(path_match.to_string())
599        } else if let Some((home, rest)) = Self::split_home_prefix(path_match, home)? {
600            Ok(Self::join_home(&escaped_home(home), rest)
601                .display()
602                .to_string())
603        } else if path_match.starts_with('~') {
604            Ok(path_match.to_string())
605        } else {
606            let home = home.ok_or_else(|| SshParserError::HomeNotFound(path_match.to_string()))?;
607            Ok(escaped_home(home)
608                .join(".ssh")
609                .join(path_match)
610                .display()
611                .to_string())
612        }
613    }
614
615    /// Include every file matched by each path argument, in order, and return its ordered host rules with inherited scopes.
616    ///
617    /// `include.stack` holds the canonical paths of the files currently being included,
618    /// so an include that reaches one of them fails with [`SshParserError::IncludeCycle`].
619    fn include_files(
620        args: Vec<String>,
621        pattern: &[HostClause],
622        inherited_scope: &[Vec<HostClause>],
623        rules: ParseRule,
624        default_algos: &DefaultAlgorithms,
625        ignore_unknown: Option<Vec<String>>,
626        include: &mut IncludeContext,
627    ) -> SshParserResult<Vec<(Host, HostScope)>> {
628        if args.is_empty() {
629            return Err(SshParserError::MissingArgument);
630        }
631
632        let mut new_hosts = vec![];
633
634        for arg in args {
635            let path_match = Self::resolve_include_path(&arg, include.home.as_deref())?;
636
637            trace!("include files: {path_match}",);
638            let files = glob(&path_match)?;
639
640            for file in files {
641                let file = file?;
642                let canonical = file.canonicalize()?;
643                if include.stack.contains(&canonical) {
644                    return Err(SshParserError::IncludeCycle(canonical));
645                }
646                trace!("including file: {}", file.display());
647                let mut reader = BufReader::new(File::open(file)?);
648                let mut sub_config = SshConfig::default().default_algorithms(default_algos.clone());
649                include.stack.push(canonical);
650                let parsed = Self::parse(
651                    &mut sub_config,
652                    &mut reader,
653                    rules,
654                    ignore_unknown.clone(),
655                    include,
656                );
657                include.stack.pop();
658                parsed?;
659
660                let SshConfig {
661                    hosts: sub_hosts,
662                    host_scopes: sub_scopes,
663                    ..
664                } = sub_config;
665                for (index, sub_host) in sub_hosts.into_iter().enumerate() {
666                    trace!("adding sub-host: {sub_host:?}",);
667                    let mut scope = inherited_scope.to_vec();
668                    scope.push(pattern.to_vec());
669                    if let Some(sub_scope) = sub_scopes.get(index) {
670                        scope.extend(sub_scope.iter().cloned());
671                    }
672                    new_hosts.push((sub_host, scope));
673                }
674            }
675        }
676
677        Ok(new_hosts)
678    }
679
680    /// Tokenize line if possible. Returns [`Field`] name and args as a [`Vec`] of [`String`].
681    ///
682    /// All of these lines are valid for tokenization
683    ///
684    /// ```txt
685    /// IgnoreUnknown=Pippo,Pluto
686    /// ConnectTimeout = 15
687    /// Ciphers "Pepperoni Pizza,Margherita Pizza,Hawaiian Pizza"
688    /// Macs="Pasta Carbonara,Pasta con tonno"
689    /// ```
690    ///
691    /// So lines have syntax `field args...`, `field=args...`, `field "args"`, `field="args"`
692    fn tokenize_line(line: &str) -> SshParserResult<(Field, Vec<String>)> {
693        // check what comes first, space or =?
694        let trimmed_line = line.trim();
695        // first token is the field, and it may be separated either by a space or by '='
696        let (field, other_tokens) = if trimmed_line.find('=').unwrap_or(usize::MAX)
697            < trimmed_line.find(char::is_whitespace).unwrap_or(usize::MAX)
698        {
699            trimmed_line
700                .split_once('=')
701                .ok_or(SshParserError::MissingArgument)?
702        } else {
703            trimmed_line
704                .split_once(char::is_whitespace)
705                .ok_or(SshParserError::MissingArgument)?
706        };
707
708        trace!("tokenized line '{line}' - field '{field}' with args '{other_tokens}'",);
709
710        // other tokens should trim = and whitespace
711        let other_tokens = other_tokens.trim().trim_start_matches('=').trim();
712        trace!("other tokens trimmed: '{other_tokens}'",);
713
714        // Validate quotes - count unescaped quotes (not preceded by backslash)
715        let unescaped_quote_count = Self::count_unescaped_quotes(other_tokens);
716        if unescaped_quote_count % 2 != 0 {
717            return Err(SshParserError::InvalidQuotes);
718        }
719
720        // split arguments while respecting quoted spans (whitespace inside quotes is preserved)
721        let raw_tokens = Self::split_args_respecting_quotes(other_tokens);
722
723        // if entire args is a single fully-quoted token, strip quotes and unescape
724        let args = if raw_tokens.len() == 1
725            && raw_tokens[0].starts_with('"')
726            && raw_tokens[0].len() >= 2
727            && Self::ends_with_unescaped_quote(&raw_tokens[0])
728        {
729            trace!("quoted args: '{}'", raw_tokens[0]);
730            let t = &raw_tokens[0];
731            let content = &t[1..t.len() - 1];
732            vec![Self::unescape_string(content)]
733        } else {
734            trace!("split args: {:?}", raw_tokens);
735            raw_tokens
736        };
737
738        match Field::from_str(field) {
739            Ok(field) => Ok((field, args)),
740            Err(_) => Err(SshParserError::UnknownField(field.to_string(), args)),
741        }
742    }
743
744    // -- value parsers
745
746    /// Parse boolean value.
747    ///
748    /// Like OpenSSH, accepts `yes`/`true` and `no`/`false`, case-insensitively.
749    fn parse_boolean(args: Vec<String>) -> SshParserResult<bool> {
750        let value = args.first().ok_or(SshParserError::MissingArgument)?;
751
752        if value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("true") {
753            Ok(true)
754        } else if value.eq_ignore_ascii_case("no") || value.eq_ignore_ascii_case("false") {
755            Ok(false)
756        } else {
757            Err(SshParserError::ExpectedBoolean)
758        }
759    }
760
761    /// Parse algorithms argument
762    fn parse_algos(args: Vec<String>) -> SshParserResult<AlgorithmsRule> {
763        let first = args.first().ok_or(SshParserError::MissingArgument)?;
764
765        AlgorithmsRule::from_str(first)
766    }
767
768    /// Parse comma separated list arguments
769    fn parse_comma_separated_list(args: Vec<String>) -> SshParserResult<Vec<String>> {
770        match args
771            .first()
772            .map(|x| x.split(',').map(|x| x.to_string()).collect())
773        {
774            Some(args) => Ok(args),
775            _ => Err(SshParserError::MissingArgument),
776        }
777    }
778
779    /// Parse duration argument
780    fn parse_duration(args: Vec<String>) -> SshParserResult<Duration> {
781        let value = Self::parse_unsigned(args)?;
782        Ok(Duration::from_secs(value as u64))
783    }
784
785    /// Parse host argument.
786    /// A leading `!` indicates a negated pattern. Any `!` characters after the first position
787    /// are treated as literal characters in the pattern.
788    fn parse_host(args: Vec<String>) -> SshParserResult<Vec<HostClause>> {
789        if args.is_empty() {
790            return Err(SshParserError::MissingArgument);
791        }
792        // Collect hosts
793        Ok(args
794            .into_iter()
795            .map(|x| {
796                if let Some(pattern) = x.strip_prefix('!') {
797                    HostClause::new(pattern.to_string(), true)
798                } else {
799                    HostClause::new(x, false)
800                }
801            })
802            .collect())
803    }
804
805    /// Parse a list of paths
806    fn parse_path_list(args: Vec<String>, home: Option<&Path>) -> SshParserResult<Vec<PathBuf>> {
807        if args.is_empty() {
808            return Err(SshParserError::MissingArgument);
809        }
810        args.iter()
811            .map(|x| Self::parse_path_arg(x.as_str(), home))
812            .collect()
813    }
814
815    /// Parse path argument
816    fn parse_path(args: Vec<String>, home: Option<&Path>) -> SshParserResult<PathBuf> {
817        if let Some(s) = args.first() {
818            Self::parse_path_arg(s, home)
819        } else {
820            Err(SshParserError::MissingArgument)
821        }
822    }
823
824    /// Parse path argument, expanding a leading `~` or `~` plus a path separator against `home`
825    ///
826    /// Other `~`-prefixed arguments, such as `~user/...`, are kept as-is.
827    fn parse_path_arg(s: &str, home: Option<&Path>) -> SshParserResult<PathBuf> {
828        Ok(match Self::split_home_prefix(s, home)? {
829            Some((home, rest)) => Self::join_home(home, rest),
830            None => PathBuf::from(s),
831        })
832    }
833
834    /// Split `s` into the home directory and the part after a leading `~` that refers to it.
835    ///
836    /// Returns `None` when `s` does not start with a bare `~` or `~` plus a path separator,
837    /// and fails with [`SshParserError::HomeNotFound`] when it does but `home` is `None`.
838    fn split_home_prefix<'a>(
839        s: &'a str,
840        home: Option<&'a Path>,
841    ) -> SshParserResult<Option<(&'a Path, &'a str)>> {
842        match Self::strip_home_prefix(s) {
843            Some(rest) => {
844                let home = home.ok_or_else(|| SshParserError::HomeNotFound(s.to_string()))?;
845                Ok(Some((home, rest)))
846            }
847            None => Ok(None),
848        }
849    }
850
851    /// Join `rest` to `home`, returning `home` itself when `rest` is empty.
852    fn join_home(home: &Path, rest: &str) -> PathBuf {
853        if rest.is_empty() {
854            home.to_path_buf()
855        } else {
856            home.join(rest)
857        }
858    }
859
860    /// Return the part of `s` after a leading `~` that refers to the home directory.
861    ///
862    /// Matches a bare `~` or `~` followed by a path separator, and returns `None`
863    /// for any other argument, including `~user/...`.
864    fn strip_home_prefix(s: &str) -> Option<&str> {
865        let rest = s.strip_prefix('~')?;
866        if rest.is_empty() {
867            Some(rest)
868        } else if rest.starts_with(std::path::is_separator) {
869            Some(rest.trim_start_matches(std::path::is_separator))
870        } else {
871            None
872        }
873    }
874
875    /// Parse port number argument
876    fn parse_port(args: Vec<String>) -> SshParserResult<u16> {
877        match args.first().map(|x| u16::from_str(x)) {
878            Some(Ok(val)) => Ok(val),
879            Some(Err(_)) => Err(SshParserError::ExpectedPort),
880            None => Err(SshParserError::MissingArgument),
881        }
882    }
883
884    /// Parse a complete remote forwarding specification.
885    fn parse_remote_forward(args: Vec<String>) -> SshParserResult<RemoteForward> {
886        if !(1..=2).contains(&args.len()) {
887            return Err(SshParserError::InvalidRemoteForward(args));
888        }
889
890        let listen_argument = Self::normalize_forward_argument(&args[0]);
891        let listen = if listen_argument.contains('/') {
892            RemoteForwardListen::UnixSocket(PathBuf::from(listen_argument))
893        } else if let Ok(port) = u16::from_str(&listen_argument) {
894            RemoteForwardListen::Port(port)
895        } else if let Some((host, port)) = Self::parse_forward_host(&listen_argument, true) {
896            RemoteForwardListen::Host { host, port }
897        } else {
898            return Err(SshParserError::InvalidRemoteForward(args));
899        };
900
901        let destination = if let Some(argument) = args.get(1) {
902            let argument = Self::normalize_forward_argument(argument);
903            if argument.contains('/') {
904                Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
905                    argument,
906                )))
907            } else if let Some((host, port)) = Self::parse_forward_host(&argument, false) {
908                Some(RemoteForwardDestination::Host { host, port })
909            } else {
910                return Err(SshParserError::InvalidRemoteForward(args));
911            }
912        } else {
913            None
914        };
915
916        Ok(RemoteForward::new(listen, destination))
917    }
918
919    /// Remove optional quotes and supported escapes from a forwarding argument.
920    fn normalize_forward_argument(argument: &str) -> String {
921        if argument.starts_with('"')
922            && argument.len() >= 2
923            && Self::ends_with_unescaped_quote(argument)
924        {
925            Self::unescape_string(&argument[1..argument.len() - 1])
926        } else {
927            argument.to_string()
928        }
929    }
930
931    /// Parse an OpenSSH host and port pair.
932    fn parse_forward_host(value: &str, allow_empty_host: bool) -> Option<(String, u16)> {
933        let (host, port) = if let Some(value) = value.strip_prefix('[') {
934            let (host, port) = value.split_once("]:")?;
935            if host.is_empty() || host.contains(['[', ']']) {
936                return None;
937            }
938            (host, port)
939        } else {
940            let (host, port) = value.rsplit_once(':')?;
941            if host.contains([':', '[', ']']) {
942                return None;
943            }
944            (host, port)
945        };
946
947        if !allow_empty_host && host.is_empty() {
948            return None;
949        }
950
951        Some((host.to_string(), u16::from_str(port).ok()?))
952    }
953
954    /// Parse string argument
955    fn parse_string(args: Vec<String>) -> SshParserResult<String> {
956        if let Some(s) = args.into_iter().next() {
957            Ok(s)
958        } else {
959            Err(SshParserError::MissingArgument)
960        }
961    }
962
963    /// Parse unsigned argument
964    fn parse_unsigned(args: Vec<String>) -> SshParserResult<usize> {
965        match args.first().map(|x| usize::from_str(x)) {
966            Some(Ok(val)) => Ok(val),
967            Some(Err(_)) => Err(SshParserError::ExpectedUnsigned),
968            None => Err(SshParserError::MissingArgument),
969        }
970    }
971}
972
973#[cfg(test)]
974mod tests {
975
976    use std::fs::File;
977    use std::io::{BufReader, Write};
978    use std::path::{Path, PathBuf};
979
980    use pretty_assertions::assert_eq;
981    use tempfile::NamedTempFile;
982
983    use super::*;
984    use crate::{DefaultAlgorithms, RemoteForward, RemoteForwardDestination, RemoteForwardListen};
985
986    #[test]
987    fn should_parse_remote_forward_listeners() -> Result<(), SshParserError> {
988        let cases = [
989            (
990                vec!["8080"],
991                RemoteForward::new(RemoteForwardListen::Port(8080), None),
992            ),
993            (
994                vec!["localhost:8080"],
995                RemoteForward::new(
996                    RemoteForwardListen::Host {
997                        host: "localhost".to_string(),
998                        port: 8080,
999                    },
1000                    None,
1001                ),
1002            ),
1003            (
1004                vec!["0.0.0.0:8080"],
1005                RemoteForward::new(
1006                    RemoteForwardListen::Host {
1007                        host: "0.0.0.0".to_string(),
1008                        port: 8080,
1009                    },
1010                    None,
1011                ),
1012            ),
1013            (
1014                vec!["*:8080"],
1015                RemoteForward::new(
1016                    RemoteForwardListen::Host {
1017                        host: "*".to_string(),
1018                        port: 8080,
1019                    },
1020                    None,
1021                ),
1022            ),
1023            (
1024                vec![":8080"],
1025                RemoteForward::new(
1026                    RemoteForwardListen::Host {
1027                        host: String::new(),
1028                        port: 8080,
1029                    },
1030                    None,
1031                ),
1032            ),
1033            (
1034                vec!["[::1]:8080"],
1035                RemoteForward::new(
1036                    RemoteForwardListen::Host {
1037                        host: "::1".to_string(),
1038                        port: 8080,
1039                    },
1040                    None,
1041                ),
1042            ),
1043            (
1044                vec!["/tmp/remote.sock"],
1045                RemoteForward::new(
1046                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
1047                    None,
1048                ),
1049            ),
1050        ];
1051
1052        for (args, expected) in cases {
1053            let args = args.into_iter().map(str::to_string).collect();
1054            assert_eq!(SshConfigParser::parse_remote_forward(args)?, expected);
1055        }
1056        Ok(())
1057    }
1058
1059    #[test]
1060    fn should_parse_remote_forward_destinations() -> Result<(), SshParserError> {
1061        let cases = [
1062            (
1063                vec!["8080", "localhost:80"],
1064                RemoteForward::new(
1065                    RemoteForwardListen::Port(8080),
1066                    Some(RemoteForwardDestination::Host {
1067                        host: "localhost".to_string(),
1068                        port: 80,
1069                    }),
1070                ),
1071            ),
1072            (
1073                vec!["/tmp/remote.sock", "/tmp/local.sock"],
1074                RemoteForward::new(
1075                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
1076                    Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
1077                        "/tmp/local.sock",
1078                    ))),
1079                ),
1080            ),
1081            (
1082                vec!["\"/tmp/remote socket\"", "[2001:db8::1]:443"],
1083                RemoteForward::new(
1084                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote socket")),
1085                    Some(RemoteForwardDestination::Host {
1086                        host: "2001:db8::1".to_string(),
1087                        port: 443,
1088                    }),
1089                ),
1090            ),
1091        ];
1092
1093        for (args, expected) in cases {
1094            let args = args.into_iter().map(str::to_string).collect();
1095            assert_eq!(SshConfigParser::parse_remote_forward(args)?, expected);
1096        }
1097        Ok(())
1098    }
1099
1100    #[test]
1101    fn should_reject_invalid_remote_forward() {
1102        for args in [
1103            vec![],
1104            vec!["localhost"],
1105            vec!["[::1:8080"],
1106            vec!["70000"],
1107            vec!["localhost:70000"],
1108            vec!["8080", "80"],
1109            vec!["8080", "localhost:80", "unexpected"],
1110        ] {
1111            let args = args.into_iter().map(str::to_string).collect();
1112            assert!(matches!(
1113                SshConfigParser::parse_remote_forward(args),
1114                Err(SshParserError::InvalidRemoteForward(_))
1115            ));
1116        }
1117    }
1118
1119    #[test]
1120    fn should_parse_configuration() -> Result<(), SshParserError> {
1121        crate::test_log();
1122        let temp = create_ssh_config();
1123        let file = File::open(temp.path()).expect("Failed to open tempfile");
1124        let mut reader = BufReader::new(file);
1125        let config = SshConfig::default()
1126            .default_algorithms(DefaultAlgorithms {
1127                ca_signature_algorithms: vec![],
1128                ciphers: vec![],
1129                host_key_algorithms: vec![],
1130                kex_algorithms: vec![],
1131                mac: vec![],
1132                pubkey_accepted_algorithms: vec!["omar-crypt".to_string()],
1133            })
1134            .parse(&mut reader, ParseRule::STRICT)?;
1135
1136        // Query openssh cmdline overrides (options preceding the first `Host` section,
1137        // overriding all following options)
1138        let params = config.query("*");
1139        assert_eq!(
1140            params.ignore_unknown.as_deref().unwrap(),
1141            &["Pippo", "Pluto"]
1142        );
1143        assert_eq!(params.compression.unwrap(), true);
1144        assert_eq!(params.connection_attempts.unwrap(), 10);
1145        assert_eq!(params.connect_timeout.unwrap(), Duration::from_secs(60));
1146        assert_eq!(
1147            params.server_alive_interval.unwrap(),
1148            Duration::from_secs(40)
1149        );
1150        assert_eq!(params.tcp_keep_alive.unwrap(), true);
1151        assert_eq!(params.ciphers.algorithms(), &["a-manella", "blowfish"]);
1152        assert_eq!(
1153            params.pubkey_accepted_algorithms.algorithms(),
1154            &["desu", "omar-crypt", "fast-omar-crypt"]
1155        );
1156
1157        // Query explicit all-hosts fallback options (`Host *`)
1158        assert_eq!(params.ca_signature_algorithms.algorithms(), &["random"]);
1159        assert_eq!(
1160            params.host_key_algorithms.algorithms(),
1161            &["luigi", "mario",]
1162        );
1163        assert_eq!(params.kex_algorithms.algorithms(), &["desu", "gigi",]);
1164        assert_eq!(params.mac.algorithms(), &["concorde"]);
1165        assert!(params.bind_address.is_none());
1166
1167        // Query 172.26.104.4, yielding cmdline overrides,
1168        // explicit `Host 192.168.*.* 172.26.*.* !192.168.1.30` options,
1169        // and all-hosts fallback options.
1170        let params_172_26_104_4 = config.query("172.26.104.4");
1171
1172        // cmdline overrides
1173        assert_eq!(params_172_26_104_4.add_keys_to_agent.unwrap(), true);
1174        assert_eq!(params_172_26_104_4.compression.unwrap(), true);
1175        assert_eq!(params_172_26_104_4.connection_attempts.unwrap(), 10);
1176        assert_eq!(
1177            params_172_26_104_4.connect_timeout.unwrap(),
1178            Duration::from_secs(60)
1179        );
1180        assert_eq!(params_172_26_104_4.tcp_keep_alive.unwrap(), true);
1181
1182        // all-hosts fallback options, merged with host-specific options
1183        assert_eq!(
1184            params_172_26_104_4.ca_signature_algorithms.algorithms(),
1185            &["random"]
1186        );
1187        assert_eq!(
1188            params_172_26_104_4.ciphers.algorithms(),
1189            &["a-manella", "blowfish",]
1190        );
1191        assert_eq!(params_172_26_104_4.mac.algorithms(), &["spyro", "deoxys"]); // use subconfig; defined before * macs
1192        assert_eq!(
1193            params_172_26_104_4.proxy_jump.unwrap(),
1194            &["jump.example.com"]
1195        ); // use subconfig; defined before * macs
1196        assert_eq!(
1197            params_172_26_104_4
1198                .pubkey_accepted_algorithms
1199                .algorithms()
1200                .is_empty(), // should have removed omar-crypt
1201            true
1202        );
1203        assert_eq!(
1204            params_172_26_104_4.bind_address.as_deref().unwrap(),
1205            "10.8.0.10"
1206        );
1207        assert_eq!(
1208            params_172_26_104_4.bind_interface.as_deref().unwrap(),
1209            "tun0"
1210        );
1211        assert_eq!(params_172_26_104_4.port.unwrap(), 2222);
1212        assert_eq!(
1213            params_172_26_104_4.identity_file.as_deref().unwrap(),
1214            vec![
1215                Path::new("/home/root/.ssh/pippo.key"),
1216                Path::new("/home/root/.ssh/pluto.key")
1217            ]
1218        );
1219        assert_eq!(params_172_26_104_4.user.as_deref().unwrap(), "omar");
1220
1221        // Query tostapane
1222        let params_tostapane = config.query("tostapane");
1223        assert_eq!(params_tostapane.compression.unwrap(), true); // it takes the first value defined, which is `yes`
1224        assert_eq!(params_tostapane.connection_attempts.unwrap(), 10);
1225        assert_eq!(
1226            params_tostapane.connect_timeout.unwrap(),
1227            Duration::from_secs(60)
1228        );
1229        assert_eq!(params_tostapane.tcp_keep_alive.unwrap(), true);
1230        assert_eq!(
1231            params_tostapane.remote_forward,
1232            vec![RemoteForward::new(RemoteForwardListen::Port(88), None)]
1233        );
1234        assert_eq!(params_tostapane.user.as_deref().unwrap(), "ciro-esposito");
1235
1236        // all-hosts fallback options
1237        assert_eq!(
1238            params_tostapane.ca_signature_algorithms.algorithms(),
1239            &["random"]
1240        );
1241        assert_eq!(
1242            params_tostapane.ciphers.algorithms(),
1243            &["a-manella", "blowfish",]
1244        );
1245        assert_eq!(
1246            params_tostapane.mac.algorithms(),
1247            vec!["spyro".to_string(), "deoxys".to_string(),]
1248        );
1249        assert_eq!(
1250            params_tostapane.proxy_jump.unwrap(),
1251            vec![
1252                "jump1.example.com".to_string(),
1253                "jump2.example.com".to_string(),
1254            ]
1255        );
1256        assert_eq!(
1257            params_tostapane.pubkey_accepted_algorithms.algorithms(),
1258            &["desu", "omar-crypt", "fast-omar-crypt"]
1259        );
1260
1261        // query 192.168.1.30
1262        let params_192_168_1_30 = config.query("192.168.1.30");
1263
1264        // host-specific options
1265        assert_eq!(params_192_168_1_30.user.as_deref().unwrap(), "nutellaro");
1266        assert_eq!(
1267            params_192_168_1_30.remote_forward,
1268            vec![RemoteForward::new(RemoteForwardListen::Port(123), None)]
1269        );
1270
1271        // cmdline overrides
1272        assert_eq!(params_192_168_1_30.compression.unwrap(), true);
1273        assert_eq!(params_192_168_1_30.connection_attempts.unwrap(), 10);
1274        assert_eq!(
1275            params_192_168_1_30.connect_timeout.unwrap(),
1276            Duration::from_secs(60)
1277        );
1278        assert_eq!(params_192_168_1_30.tcp_keep_alive.unwrap(), true);
1279
1280        // all-hosts fallback options
1281        assert_eq!(
1282            params_192_168_1_30.ca_signature_algorithms.algorithms(),
1283            &["random"]
1284        );
1285        assert_eq!(
1286            params_192_168_1_30.ciphers.algorithms(),
1287            &["a-manella", "blowfish"]
1288        );
1289        assert_eq!(params_192_168_1_30.mac.algorithms(), &["concorde"]);
1290        assert_eq!(
1291            params_192_168_1_30.pubkey_accepted_algorithms.algorithms(),
1292            &["desu", "omar-crypt", "fast-omar-crypt"]
1293        );
1294
1295        Ok(())
1296    }
1297
1298    #[test]
1299    fn should_allow_unknown_field() -> Result<(), SshParserError> {
1300        crate::test_log();
1301        let temp = create_ssh_config_with_unknown_fields();
1302        let file = File::open(temp.path()).expect("Failed to open tempfile");
1303        let mut reader = BufReader::new(file);
1304        let _config = SshConfig::default()
1305            .default_algorithms(DefaultAlgorithms::empty())
1306            .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)?;
1307
1308        Ok(())
1309    }
1310
1311    #[test]
1312    fn should_not_allow_unknown_field() {
1313        crate::test_log();
1314        let temp = create_ssh_config_with_unknown_fields();
1315        let file = File::open(temp.path()).expect("Failed to open tempfile");
1316        let mut reader = BufReader::new(file);
1317        assert!(matches!(
1318            SshConfig::default()
1319                .default_algorithms(DefaultAlgorithms::empty())
1320                .parse(&mut reader, ParseRule::STRICT)
1321                .unwrap_err(),
1322            SshParserError::UnknownField(..)
1323        ));
1324    }
1325
1326    #[test]
1327    fn should_store_unknown_fields() {
1328        crate::test_log();
1329        let temp = create_ssh_config_with_unknown_fields();
1330        let file = File::open(temp.path()).expect("Failed to open tempfile");
1331        let mut reader = BufReader::new(file);
1332        let config = SshConfig::default()
1333            .default_algorithms(DefaultAlgorithms::empty())
1334            .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
1335            .unwrap();
1336
1337        let host = config.query("cross-platform");
1338        assert_eq!(
1339            host.ignored_fields.get("Piropero").unwrap(),
1340            &vec![String::from("yes")]
1341        );
1342    }
1343
1344    #[test]
1345    fn should_parse_inversed_ssh_config() {
1346        crate::test_log();
1347        let temp = create_inverted_ssh_config();
1348        let file = File::open(temp.path()).expect("Failed to open tempfile");
1349        let mut reader = BufReader::new(file);
1350        let config = SshConfig::default()
1351            .default_algorithms(DefaultAlgorithms::empty())
1352            .parse(&mut reader, ParseRule::STRICT)
1353            .unwrap();
1354
1355        let home_dir = dirs::home_dir()
1356            .expect("home directory must exist")
1357            .to_string_lossy()
1358            .to_string();
1359
1360        let remote_host = config.query("remote-host");
1361
1362        // From `*-host`
1363        assert_eq!(
1364            remote_host.identity_file.unwrap()[0].as_path(),
1365            Path::new(format!("{home_dir}/.ssh/id_rsa_good").as_str()) // because it's the first in the file
1366        );
1367
1368        // From `remote-*`
1369        assert_eq!(remote_host.host_name.unwrap(), "hostname.com");
1370        assert_eq!(remote_host.user.unwrap(), "user");
1371
1372        // From `*`
1373        assert_eq!(
1374            remote_host.connect_timeout.unwrap(),
1375            Duration::from_secs(15)
1376        );
1377    }
1378
1379    #[test]
1380    fn should_parse_configuration_with_hosts() {
1381        crate::test_log();
1382        let temp = create_ssh_config_with_comments();
1383
1384        let file = File::open(temp.path()).expect("Failed to open tempfile");
1385        let mut reader = BufReader::new(file);
1386        let config = SshConfig::default()
1387            .default_algorithms(DefaultAlgorithms::empty())
1388            .parse(&mut reader, ParseRule::STRICT)
1389            .unwrap();
1390
1391        let hostname = config.query("cross-platform").host_name.unwrap();
1392        assert_eq!(&hostname, "hostname.com");
1393
1394        assert!(config.query("this").host_name.is_none());
1395    }
1396
1397    #[test]
1398    fn should_update_host_bind_address() -> Result<(), SshParserError> {
1399        crate::test_log();
1400        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1401        SshConfigParser::update_host(
1402            Field::BindAddress,
1403            vec![String::from("127.0.0.1")],
1404            &mut host,
1405            None,
1406        )?;
1407        assert_eq!(host.params.bind_address.as_deref().unwrap(), "127.0.0.1");
1408        Ok(())
1409    }
1410
1411    #[test]
1412    fn should_update_host_bind_interface() -> Result<(), SshParserError> {
1413        crate::test_log();
1414        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1415        SshConfigParser::update_host(
1416            Field::BindInterface,
1417            vec![String::from("aaa")],
1418            &mut host,
1419            None,
1420        )?;
1421        assert_eq!(host.params.bind_interface.as_deref().unwrap(), "aaa");
1422        Ok(())
1423    }
1424
1425    #[test]
1426    fn should_update_host_ca_signature_algos() -> Result<(), SshParserError> {
1427        crate::test_log();
1428        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1429        SshConfigParser::update_host(
1430            Field::CaSignatureAlgorithms,
1431            vec![String::from("a,b,c")],
1432            &mut host,
1433            None,
1434        )?;
1435        assert_eq!(
1436            host.params.ca_signature_algorithms.algorithms(),
1437            &["a", "b", "c"]
1438        );
1439        Ok(())
1440    }
1441
1442    #[test]
1443    fn should_update_host_certificate_file() -> Result<(), SshParserError> {
1444        crate::test_log();
1445        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1446        SshConfigParser::update_host(
1447            Field::CertificateFile,
1448            vec![String::from("/tmp/a.crt")],
1449            &mut host,
1450            None,
1451        )?;
1452        assert_eq!(
1453            host.params.certificate_file.as_deref().unwrap(),
1454            Path::new("/tmp/a.crt")
1455        );
1456        Ok(())
1457    }
1458
1459    #[test]
1460    fn should_update_host_ciphers() -> Result<(), SshParserError> {
1461        crate::test_log();
1462        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1463        SshConfigParser::update_host(Field::Ciphers, vec![String::from("a,b,c")], &mut host, None)?;
1464        assert_eq!(host.params.ciphers.algorithms(), &["a", "b", "c"]);
1465        Ok(())
1466    }
1467
1468    #[test]
1469    fn should_update_host_compression() -> Result<(), SshParserError> {
1470        crate::test_log();
1471        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1472        SshConfigParser::update_host(
1473            Field::Compression,
1474            vec![String::from("yes")],
1475            &mut host,
1476            None,
1477        )?;
1478        assert_eq!(host.params.compression.unwrap(), true);
1479        Ok(())
1480    }
1481
1482    #[test]
1483    fn should_update_host_connection_attempts() -> Result<(), SshParserError> {
1484        crate::test_log();
1485        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1486        SshConfigParser::update_host(
1487            Field::ConnectionAttempts,
1488            vec![String::from("4")],
1489            &mut host,
1490            None,
1491        )?;
1492        assert_eq!(host.params.connection_attempts.unwrap(), 4);
1493        Ok(())
1494    }
1495
1496    #[test]
1497    fn should_update_host_connection_timeout() -> Result<(), SshParserError> {
1498        crate::test_log();
1499        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1500        SshConfigParser::update_host(
1501            Field::ConnectTimeout,
1502            vec![String::from("10")],
1503            &mut host,
1504            None,
1505        )?;
1506        assert_eq!(
1507            host.params.connect_timeout.unwrap(),
1508            Duration::from_secs(10)
1509        );
1510        Ok(())
1511    }
1512
1513    #[test]
1514    fn should_update_host_key_algorithms() -> Result<(), SshParserError> {
1515        crate::test_log();
1516        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1517        SshConfigParser::update_host(
1518            Field::HostKeyAlgorithms,
1519            vec![String::from("a,b,c")],
1520            &mut host,
1521            None,
1522        )?;
1523        assert_eq!(
1524            host.params.host_key_algorithms.algorithms(),
1525            &["a", "b", "c"]
1526        );
1527        Ok(())
1528    }
1529
1530    #[test]
1531    fn should_update_host_host_name() -> Result<(), SshParserError> {
1532        crate::test_log();
1533        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1534        SshConfigParser::update_host(
1535            Field::HostName,
1536            vec![String::from("192.168.1.1")],
1537            &mut host,
1538            None,
1539        )?;
1540        assert_eq!(host.params.host_name.as_deref().unwrap(), "192.168.1.1");
1541        Ok(())
1542    }
1543
1544    #[test]
1545    fn should_update_host_ignore_unknown() -> Result<(), SshParserError> {
1546        crate::test_log();
1547        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1548        SshConfigParser::update_host(
1549            Field::IgnoreUnknown,
1550            vec![String::from("a,b,c")],
1551            &mut host,
1552            None,
1553        )?;
1554        assert_eq!(
1555            host.params.ignore_unknown.as_deref().unwrap(),
1556            &["a", "b", "c"]
1557        );
1558        Ok(())
1559    }
1560
1561    #[test]
1562    fn should_update_kex_algorithms() -> Result<(), SshParserError> {
1563        crate::test_log();
1564        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1565        SshConfigParser::update_host(
1566            Field::KexAlgorithms,
1567            vec![String::from("a,b,c")],
1568            &mut host,
1569            None,
1570        )?;
1571        assert_eq!(host.params.kex_algorithms.algorithms(), &["a", "b", "c"]);
1572        Ok(())
1573    }
1574
1575    #[test]
1576    fn should_update_host_mac() -> Result<(), SshParserError> {
1577        crate::test_log();
1578        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1579        SshConfigParser::update_host(Field::Mac, vec![String::from("a,b,c")], &mut host, None)?;
1580        assert_eq!(host.params.mac.algorithms(), &["a", "b", "c"]);
1581        Ok(())
1582    }
1583
1584    #[test]
1585    fn should_update_host_port() -> Result<(), SshParserError> {
1586        crate::test_log();
1587        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1588        SshConfigParser::update_host(Field::Port, vec![String::from("2222")], &mut host, None)?;
1589        assert_eq!(host.params.port.unwrap(), 2222);
1590        Ok(())
1591    }
1592
1593    #[test]
1594    fn should_update_host_pubkey_accepted_algos() -> Result<(), SshParserError> {
1595        crate::test_log();
1596        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1597        SshConfigParser::update_host(
1598            Field::PubkeyAcceptedAlgorithms,
1599            vec![String::from("a,b,c")],
1600            &mut host,
1601            None,
1602        )?;
1603        assert_eq!(
1604            host.params.pubkey_accepted_algorithms.algorithms(),
1605            &["a", "b", "c"]
1606        );
1607        Ok(())
1608    }
1609
1610    #[test]
1611    fn should_update_host_pubkey_authentication() -> Result<(), SshParserError> {
1612        crate::test_log();
1613        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1614        SshConfigParser::update_host(
1615            Field::PubkeyAuthentication,
1616            vec![String::from("yes")],
1617            &mut host,
1618            None,
1619        )?;
1620        assert_eq!(host.params.pubkey_authentication.unwrap(), true);
1621        Ok(())
1622    }
1623
1624    #[test]
1625    fn should_update_host_remote_forward() -> Result<(), SshParserError> {
1626        crate::test_log();
1627        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1628        SshConfigParser::update_host(
1629            Field::RemoteForward,
1630            vec![String::from("3005"), String::from("localhost:80")],
1631            &mut host,
1632            None,
1633        )?;
1634        SshConfigParser::update_host(
1635            Field::RemoteForward,
1636            vec![String::from("/tmp/remote.sock")],
1637            &mut host,
1638            None,
1639        )?;
1640        assert_eq!(
1641            host.params.remote_forward,
1642            vec![
1643                RemoteForward::new(
1644                    RemoteForwardListen::Port(3005),
1645                    Some(RemoteForwardDestination::Host {
1646                        host: "localhost".to_string(),
1647                        port: 80,
1648                    }),
1649                ),
1650                RemoteForward::new(
1651                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
1652                    None,
1653                ),
1654            ]
1655        );
1656        Ok(())
1657    }
1658
1659    #[test]
1660    fn should_accumulate_remote_forwards_from_matching_hosts() -> Result<(), SshParserError> {
1661        let config = r#"
1662Host test
1663    RemoteForward 8080 localhost:80
1664
1665Host *
1666    RemoteForward "/tmp/remote socket" "/tmp/local socket"
1667"#;
1668        let mut reader = BufReader::new(config.as_bytes());
1669        let config = SshConfig::default()
1670            .default_algorithms(DefaultAlgorithms::empty())
1671            .parse(&mut reader, ParseRule::STRICT)?;
1672
1673        assert_eq!(
1674            config.query("test").remote_forward,
1675            vec![
1676                RemoteForward::new(
1677                    RemoteForwardListen::Port(8080),
1678                    Some(RemoteForwardDestination::Host {
1679                        host: "localhost".to_string(),
1680                        port: 80,
1681                    }),
1682                ),
1683                RemoteForward::new(
1684                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote socket")),
1685                    Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
1686                        "/tmp/local socket",
1687                    ))),
1688                ),
1689            ]
1690        );
1691        Ok(())
1692    }
1693
1694    #[test]
1695    fn should_update_host_server_alive_interval() -> Result<(), SshParserError> {
1696        crate::test_log();
1697        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1698        SshConfigParser::update_host(
1699            Field::ServerAliveInterval,
1700            vec![String::from("40")],
1701            &mut host,
1702            None,
1703        )?;
1704        assert_eq!(
1705            host.params.server_alive_interval.unwrap(),
1706            Duration::from_secs(40)
1707        );
1708        Ok(())
1709    }
1710
1711    #[test]
1712    fn should_update_host_tcp_keep_alive() -> Result<(), SshParserError> {
1713        crate::test_log();
1714        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1715        SshConfigParser::update_host(
1716            Field::TcpKeepAlive,
1717            vec![String::from("no")],
1718            &mut host,
1719            None,
1720        )?;
1721        assert_eq!(host.params.tcp_keep_alive.unwrap(), false);
1722        Ok(())
1723    }
1724
1725    #[test]
1726    fn should_update_host_user() -> Result<(), SshParserError> {
1727        crate::test_log();
1728        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1729        SshConfigParser::update_host(Field::User, vec![String::from("pippo")], &mut host, None)?;
1730        assert_eq!(host.params.user.as_deref().unwrap(), "pippo");
1731        Ok(())
1732    }
1733
1734    #[test]
1735    fn should_not_update_host_if_unknown() -> Result<(), SshParserError> {
1736        crate::test_log();
1737        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1738        let result = SshConfigParser::update_host(
1739            Field::PasswordAuthentication,
1740            vec![String::from("yes")],
1741            &mut host,
1742            None,
1743        );
1744
1745        match result {
1746            Ok(_) | Err(SshParserError::UnsupportedField(_, _)) => Ok(()),
1747            Err(e) => Err(e),
1748        }?;
1749
1750        assert_eq!(host.params, HostParams::new(&DefaultAlgorithms::empty()));
1751        Ok(())
1752    }
1753
1754    #[test]
1755    fn should_update_host_if_unsupported() -> Result<(), SshParserError> {
1756        crate::test_log();
1757        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1758        let result = SshConfigParser::update_host(
1759            Field::PasswordAuthentication,
1760            vec![String::from("yes")],
1761            &mut host,
1762            None,
1763        );
1764
1765        match result {
1766            Err(SshParserError::UnsupportedField(field, _)) => {
1767                assert_eq!(field, "passwordauthentication");
1768                Ok(())
1769            }
1770            Ok(_) => Ok(()),
1771            Err(e) => Err(e),
1772        }?;
1773
1774        assert_eq!(host.params, HostParams::new(&DefaultAlgorithms::empty()));
1775        Ok(())
1776    }
1777
1778    #[test]
1779    fn should_tokenize_line() -> Result<(), SshParserError> {
1780        crate::test_log();
1781        assert_eq!(
1782            SshConfigParser::tokenize_line("HostName 192.168.*.* 172.26.*.*")?,
1783            (
1784                Field::HostName,
1785                vec![String::from("192.168.*.*"), String::from("172.26.*.*")]
1786            )
1787        );
1788        // Tokenize line with spaces
1789        assert_eq!(
1790            SshConfigParser::tokenize_line(
1791                "      HostName        192.168.*.*        172.26.*.*        "
1792            )?,
1793            (
1794                Field::HostName,
1795                vec![String::from("192.168.*.*"), String::from("172.26.*.*")]
1796            )
1797        );
1798        Ok(())
1799    }
1800
1801    #[test]
1802    fn should_not_tokenize_line() {
1803        crate::test_log();
1804        assert!(matches!(
1805            SshConfigParser::tokenize_line("Omar     yes").unwrap_err(),
1806            SshParserError::UnknownField(..)
1807        ));
1808    }
1809
1810    #[test]
1811    fn should_fail_parsing_field() {
1812        crate::test_log();
1813
1814        assert!(matches!(
1815            SshConfigParser::tokenize_line("                  ").unwrap_err(),
1816            SshParserError::MissingArgument
1817        ));
1818    }
1819
1820    #[test]
1821    fn should_fail_on_mismatched_quotes() {
1822        crate::test_log();
1823
1824        // Unclosed opening quote
1825        assert!(matches!(
1826            SshConfigParser::tokenize_line(r#"Hostname "example.com"#).unwrap_err(),
1827            SshParserError::InvalidQuotes
1828        ));
1829        // Unexpected closing quote (no opening)
1830        assert!(matches!(
1831            SshConfigParser::tokenize_line(r#"Hostname example.com""#).unwrap_err(),
1832            SshParserError::InvalidQuotes
1833        ));
1834        // Quote in middle, unclosed
1835        assert!(matches!(
1836            SshConfigParser::tokenize_line(r#"Hostname foo "bar"#).unwrap_err(),
1837            SshParserError::InvalidQuotes
1838        ));
1839    }
1840
1841    #[test]
1842    fn should_parse_boolean() -> Result<(), SshParserError> {
1843        crate::test_log();
1844        assert_eq!(
1845            SshConfigParser::parse_boolean(vec![String::from("yes")])?,
1846            true
1847        );
1848        assert_eq!(
1849            SshConfigParser::parse_boolean(vec![String::from("no")])?,
1850            false
1851        );
1852        Ok(())
1853    }
1854
1855    #[test]
1856    fn should_parse_boolean_true_false_case_insensitive() -> Result<(), SshParserError> {
1857        crate::test_log();
1858        for value in ["true", "True", "YES", "Yes"] {
1859            assert!(SshConfigParser::parse_boolean(vec![value.to_string()])?);
1860        }
1861        for value in ["false", "False", "NO", "No"] {
1862            assert!(!SshConfigParser::parse_boolean(vec![value.to_string()])?);
1863        }
1864        Ok(())
1865    }
1866
1867    #[test]
1868    fn should_fail_parsing_boolean() {
1869        crate::test_log();
1870        assert!(matches!(
1871            SshConfigParser::parse_boolean(vec!["boh".to_string()]).unwrap_err(),
1872            SshParserError::ExpectedBoolean
1873        ));
1874        assert!(matches!(
1875            SshConfigParser::parse_boolean(vec![]).unwrap_err(),
1876            SshParserError::MissingArgument
1877        ));
1878    }
1879
1880    #[test]
1881    fn should_parse_algos() -> Result<(), SshParserError> {
1882        crate::test_log();
1883        assert_eq!(
1884            SshConfigParser::parse_algos(vec![String::from("a,b,c,d")])?,
1885            AlgorithmsRule::Set(vec![
1886                "a".to_string(),
1887                "b".to_string(),
1888                "c".to_string(),
1889                "d".to_string(),
1890            ])
1891        );
1892
1893        assert_eq!(
1894            SshConfigParser::parse_algos(vec![String::from("a")])?,
1895            AlgorithmsRule::Set(vec!["a".to_string()])
1896        );
1897
1898        assert_eq!(
1899            SshConfigParser::parse_algos(vec![String::from("+a,b")])?,
1900            AlgorithmsRule::Append(vec!["a".to_string(), "b".to_string()])
1901        );
1902
1903        Ok(())
1904    }
1905
1906    #[test]
1907    fn should_parse_comma_separated_list() -> Result<(), SshParserError> {
1908        crate::test_log();
1909        assert_eq!(
1910            SshConfigParser::parse_comma_separated_list(vec![String::from("a,b,c,d")])?,
1911            vec![
1912                "a".to_string(),
1913                "b".to_string(),
1914                "c".to_string(),
1915                "d".to_string(),
1916            ]
1917        );
1918        assert_eq!(
1919            SshConfigParser::parse_comma_separated_list(vec![String::from("a")])?,
1920            vec!["a".to_string()]
1921        );
1922        Ok(())
1923    }
1924
1925    #[test]
1926    fn should_fail_parsing_comma_separated_list() {
1927        crate::test_log();
1928        assert!(matches!(
1929            SshConfigParser::parse_comma_separated_list(vec![]).unwrap_err(),
1930            SshParserError::MissingArgument
1931        ));
1932    }
1933
1934    #[test]
1935    fn should_parse_duration() -> Result<(), SshParserError> {
1936        crate::test_log();
1937        assert_eq!(
1938            SshConfigParser::parse_duration(vec![String::from("60")])?,
1939            Duration::from_secs(60)
1940        );
1941        Ok(())
1942    }
1943
1944    #[test]
1945    fn should_fail_parsing_duration() {
1946        crate::test_log();
1947        assert!(matches!(
1948            SshConfigParser::parse_duration(vec![String::from("AAA")]).unwrap_err(),
1949            SshParserError::ExpectedUnsigned
1950        ));
1951        assert!(matches!(
1952            SshConfigParser::parse_duration(vec![]).unwrap_err(),
1953            SshParserError::MissingArgument
1954        ));
1955    }
1956
1957    #[test]
1958    fn should_parse_host() -> Result<(), SshParserError> {
1959        crate::test_log();
1960        assert_eq!(
1961            SshConfigParser::parse_host(vec![
1962                String::from("192.168.*.*"),
1963                String::from("!192.168.1.1"),
1964                String::from("172.26.104.*"),
1965                String::from("!172.26.104.10"),
1966            ])?,
1967            vec![
1968                HostClause::new(String::from("192.168.*.*"), false),
1969                HostClause::new(String::from("192.168.1.1"), true),
1970                HostClause::new(String::from("172.26.104.*"), false),
1971                HostClause::new(String::from("172.26.104.10"), true),
1972            ]
1973        );
1974        Ok(())
1975    }
1976
1977    #[test]
1978    fn should_fail_parsing_host() {
1979        crate::test_log();
1980        assert!(matches!(
1981            SshConfigParser::parse_host(vec![]).unwrap_err(),
1982            SshParserError::MissingArgument
1983        ));
1984    }
1985
1986    #[test]
1987    fn should_parse_path() -> Result<(), SshParserError> {
1988        crate::test_log();
1989        assert_eq!(
1990            SshConfigParser::parse_path(vec![String::from("/tmp/a.txt")], None)?,
1991            PathBuf::from("/tmp/a.txt")
1992        );
1993        Ok(())
1994    }
1995
1996    #[test]
1997    fn should_parse_path_list() -> Result<(), SshParserError> {
1998        crate::test_log();
1999        assert_eq!(
2000            SshConfigParser::parse_path_list(
2001                vec![String::from("/tmp/a.txt"), String::from("/tmp/b.txt")],
2002                None
2003            )?,
2004            vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")]
2005        );
2006        Ok(())
2007    }
2008
2009    #[test]
2010    fn should_fail_parse_path_list() {
2011        crate::test_log();
2012        assert!(matches!(
2013            SshConfigParser::parse_path_list(vec![], None).unwrap_err(),
2014            SshParserError::MissingArgument
2015        ));
2016    }
2017
2018    #[test]
2019    fn should_fail_parsing_path() {
2020        crate::test_log();
2021        assert!(matches!(
2022            SshConfigParser::parse_path(vec![], None).unwrap_err(),
2023            SshParserError::MissingArgument
2024        ));
2025    }
2026
2027    #[test]
2028    fn should_parse_port() -> Result<(), SshParserError> {
2029        crate::test_log();
2030        assert_eq!(SshConfigParser::parse_port(vec![String::from("22")])?, 22);
2031        Ok(())
2032    }
2033
2034    #[test]
2035    fn should_fail_parsing_port() {
2036        crate::test_log();
2037        assert!(matches!(
2038            SshConfigParser::parse_port(vec![String::from("1234567")]).unwrap_err(),
2039            SshParserError::ExpectedPort
2040        ));
2041        assert!(matches!(
2042            SshConfigParser::parse_port(vec![]).unwrap_err(),
2043            SshParserError::MissingArgument
2044        ));
2045    }
2046
2047    #[test]
2048    fn should_parse_string() -> Result<(), SshParserError> {
2049        crate::test_log();
2050        assert_eq!(
2051            SshConfigParser::parse_string(vec![String::from("foobar")])?,
2052            String::from("foobar")
2053        );
2054        Ok(())
2055    }
2056
2057    #[test]
2058    fn should_fail_parsing_string() {
2059        crate::test_log();
2060        assert!(matches!(
2061            SshConfigParser::parse_string(vec![]).unwrap_err(),
2062            SshParserError::MissingArgument
2063        ));
2064    }
2065
2066    #[test]
2067    fn should_parse_unsigned() -> Result<(), SshParserError> {
2068        crate::test_log();
2069        assert_eq!(
2070            SshConfigParser::parse_unsigned(vec![String::from("43")])?,
2071            43
2072        );
2073        Ok(())
2074    }
2075
2076    #[test]
2077    fn should_fail_parsing_unsigned() {
2078        crate::test_log();
2079        assert!(matches!(
2080            SshConfigParser::parse_unsigned(vec![String::from("abc")]).unwrap_err(),
2081            SshParserError::ExpectedUnsigned
2082        ));
2083        assert!(matches!(
2084            SshConfigParser::parse_unsigned(vec![]).unwrap_err(),
2085            SshParserError::MissingArgument
2086        ));
2087    }
2088
2089    #[test]
2090    fn should_strip_comments() {
2091        crate::test_log();
2092
2093        assert_eq!(
2094            SshConfigParser::strip_comments("host my_host # this is my fav host").as_str(),
2095            "host my_host "
2096        );
2097        assert_eq!(
2098            SshConfigParser::strip_comments("# this is a comment").as_str(),
2099            ""
2100        );
2101    }
2102
2103    #[test]
2104    fn should_preserve_hash_inside_quoted_strings() {
2105        crate::test_log();
2106
2107        // Hash inside quotes should NOT be treated as a comment
2108        assert_eq!(
2109            SshConfigParser::strip_comments(r#"Ciphers "aes256-ctr # not a comment""#).as_str(),
2110            r#"Ciphers "aes256-ctr # not a comment""#
2111        );
2112        // Hash after closing quote should be treated as a comment
2113        assert_eq!(
2114            SshConfigParser::strip_comments(r#"Ciphers "aes256-ctr" # this is a comment"#).as_str(),
2115            r#"Ciphers "aes256-ctr" "#
2116        );
2117        // Multiple quoted sections
2118        assert_eq!(
2119            SshConfigParser::strip_comments(r#"ProxyCommand "ssh # hop" -W "dest # host""#)
2120                .as_str(),
2121            r#"ProxyCommand "ssh # hop" -W "dest # host""#
2122        );
2123        // Comment after multiple quoted sections
2124        assert_eq!(
2125            SshConfigParser::strip_comments(r#"Key "val1" "val2" # comment"#).as_str(),
2126            r#"Key "val1" "val2" "#
2127        );
2128    }
2129
2130    #[test]
2131    fn test_should_parse_config_with_quotes_and_eq() {
2132        crate::test_log();
2133
2134        let config = create_ssh_config_with_quotes_and_eq();
2135        let file = File::open(config.path()).expect("Failed to open tempfile");
2136        let mut reader = BufReader::new(file);
2137
2138        let config = SshConfig::default()
2139            .default_algorithms(DefaultAlgorithms::empty())
2140            .parse(&mut reader, ParseRule::STRICT)
2141            .expect("Failed to parse config");
2142
2143        let params = config.query("foo");
2144
2145        // connect timeout is 15
2146        assert_eq!(
2147            params.connect_timeout.expect("unspec connect timeout"),
2148            Duration::from_secs(15)
2149        );
2150        assert_eq!(
2151            params
2152                .ignore_unknown
2153                .as_deref()
2154                .expect("unspec ignore unknown"),
2155            &["Pippo", "Pluto"]
2156        );
2157        assert_eq!(
2158            params
2159                .ciphers
2160                .algorithms()
2161                .iter()
2162                .map(|x| x.as_str())
2163                .collect::<Vec<&str>>(),
2164            &["Pepperoni Pizza", "Margherita Pizza", "Hawaiian Pizza"]
2165        );
2166        assert_eq!(
2167            params
2168                .mac
2169                .algorithms()
2170                .iter()
2171                .map(|x| x.as_str())
2172                .collect::<Vec<&str>>(),
2173            &["Pasta Carbonara", "Pasta con tonno"]
2174        );
2175    }
2176
2177    #[test]
2178    fn test_should_resolve_absolute_include_path() {
2179        crate::test_log();
2180
2181        let expected = PathBuf::from("/tmp/config.local");
2182
2183        let s = "/tmp/config.local";
2184        let resolved = PathBuf::from(
2185            SshConfigParser::resolve_include_path(s, None).expect("absolute path resolves"),
2186        );
2187        assert_eq!(resolved, expected);
2188    }
2189
2190    #[test]
2191    fn should_fail_resolving_home_relative_paths_without_home() {
2192        crate::test_log();
2193        type Resolve = fn(&str) -> SshParserResult<()>;
2194        let parse_path: Resolve = |s| SshConfigParser::parse_path_arg(s, None).map(drop);
2195        let resolve_include: Resolve = |s| SshConfigParser::resolve_include_path(s, None).map(drop);
2196        let cases: [(&str, &str, Resolve); 5] = [
2197            ("parse_path_arg", "~/.ssh/id_rsa", parse_path),
2198            ("parse_path_arg", "~", parse_path),
2199            (
2200                "resolve_include_path",
2201                "~/.ssh/config.local",
2202                resolve_include,
2203            ),
2204            ("resolve_include_path", "config.d/*", resolve_include),
2205            ("resolve_include_path", "~", resolve_include),
2206        ];
2207        for (name, input, resolve) in cases {
2208            assert!(
2209                matches!(resolve(input), Err(SshParserError::HomeNotFound(ref arg)) if arg == input),
2210                "{name}({input})"
2211            );
2212        }
2213    }
2214
2215    #[cfg(windows)]
2216    #[test]
2217    fn should_expand_tilde_followed_by_windows_separator() -> Result<(), SshParserError> {
2218        crate::test_log();
2219        let home = Path::new("/home/alice");
2220        assert_eq!(
2221            SshConfigParser::parse_path_arg("~\\.ssh\\id_rsa", Some(home))?,
2222            home.join(".ssh").join("id_rsa")
2223        );
2224        Ok(())
2225    }
2226
2227    #[test]
2228    fn should_resolve_path_arguments_against_home() {
2229        crate::test_log();
2230
2231        let home = Some(Path::new("/home/alice"));
2232        // (input, expected `parse_path_arg`, expected `resolve_include_path`)
2233        let cases: [(&str, &str, &str); 6] = [
2234            ("~", "/home/alice", "/home/alice"),
2235            ("~/", "/home/alice", "/home/alice"),
2236            ("~//x", "/home/alice/x", "/home/alice/x"),
2237            (
2238                "~/.ssh/id_dsa",
2239                "/home/alice/.ssh/id_dsa",
2240                "/home/alice/.ssh/id_dsa",
2241            ),
2242            (
2243                "config.local",
2244                "config.local",
2245                "/home/alice/.ssh/config.local",
2246            ),
2247            ("~bob/config", "~bob/config", "~bob/config"),
2248        ];
2249        for (input, expected_path, expected_include) in cases {
2250            assert_eq!(
2251                SshConfigParser::parse_path_arg(input, home).expect("path argument parses"),
2252                PathBuf::from(expected_path),
2253                "parse_path_arg({input})"
2254            );
2255            assert_eq!(
2256                PathBuf::from(
2257                    SshConfigParser::resolve_include_path(input, home)
2258                        .expect("include path resolves")
2259                ),
2260                PathBuf::from(expected_include),
2261                "resolve_include_path({input})"
2262            );
2263        }
2264    }
2265
2266    #[test]
2267    fn should_resolve_paths_against_configured_home() -> Result<(), SshParserError> {
2268        crate::test_log();
2269        let home = tempfile::tempdir()?;
2270        let ssh_dir = home.path().join(".ssh");
2271        std::fs::create_dir_all(ssh_dir.join("conf.d"))?;
2272        std::fs::write(
2273            ssh_dir.join("conf.d").join("a.conf"),
2274            "Include nested.conf\nHost foo\n    IdentityFile ~/.ssh/foo_key\n",
2275        )?;
2276        std::fs::write(
2277            ssh_dir.join("nested.conf"),
2278            "Host bar\n    CertificateFile ~/.ssh/bar-cert.pub\n",
2279        )?;
2280
2281        let mut reader = BufReader::new("Include conf.d/*.conf\n".as_bytes());
2282        let config = SshConfig::default()
2283            .default_algorithms(DefaultAlgorithms::empty())
2284            .home_dir(home.path())
2285            .parse(&mut reader, ParseRule::STRICT)?;
2286
2287        assert_eq!(
2288            config.query("foo").identity_file,
2289            Some(vec![ssh_dir.join("foo_key")])
2290        );
2291        assert_eq!(
2292            config.query("bar").certificate_file,
2293            Some(ssh_dir.join("bar-cert.pub"))
2294        );
2295        Ok(())
2296    }
2297
2298    #[test]
2299    fn should_escape_glob_metacharacters_in_home() -> Result<(), SshParserError> {
2300        crate::test_log();
2301        let dir = tempfile::tempdir()?;
2302        let home = dir.path().join("user[1]");
2303        let ssh_dir = home.join(".ssh");
2304        std::fs::create_dir_all(&ssh_dir)?;
2305        std::fs::write(ssh_dir.join("extra.conf"), "Host foo\n    User bob\n")?;
2306
2307        for include in ["Include extra.conf\n", "Include ~/.ssh/extra.conf\n"] {
2308            let mut reader = BufReader::new(include.as_bytes());
2309            let config = SshConfig::default()
2310                .default_algorithms(DefaultAlgorithms::empty())
2311                .home_dir(&home)
2312                .parse(&mut reader, ParseRule::STRICT)?;
2313            assert_eq!(
2314                config.query("foo").user.as_deref(),
2315                Some("bob"),
2316                "{include}"
2317            );
2318        }
2319        Ok(())
2320    }
2321
2322    #[test]
2323    fn should_reject_include_cycle() -> Result<(), SshParserError> {
2324        crate::test_log();
2325        // A file is written as `{prefix}Include {includes}`, with each include resolved in the tempdir.
2326        type IncludeFile = (&'static str, &'static str, &'static [&'static str]);
2327
2328        // Each case lists the files to write, the entry file, and the file closing the loop.
2329        let cases: [(&[IncludeFile], &str, &str); 2] = [
2330            (
2331                &[("self.conf", "", &["self.conf"])],
2332                "self.conf",
2333                "self.conf",
2334            ),
2335            (
2336                &[
2337                    ("a.conf", "Host a\n    ", &["b.conf"]),
2338                    ("b.conf", "", &["a.conf"]),
2339                ],
2340                "a.conf",
2341                "a.conf",
2342            ),
2343        ];
2344
2345        for (files, entry, expected) in cases {
2346            let dir = tempfile::tempdir()?;
2347            for (name, prefix, includes) in files {
2348                let includes = includes
2349                    .iter()
2350                    .map(|include| dir.path().join(include).display().to_string())
2351                    .collect::<Vec<_>>()
2352                    .join(" ");
2353                std::fs::write(
2354                    dir.path().join(name),
2355                    format!("{prefix}Include {includes}\n"),
2356                )?;
2357            }
2358
2359            let config = format!(
2360                "Include {entry}\n",
2361                entry = dir.path().join(entry).display()
2362            );
2363            let mut reader = BufReader::new(config.as_bytes());
2364            let err = SshConfig::default()
2365                .default_algorithms(DefaultAlgorithms::empty())
2366                .parse(&mut reader, ParseRule::STRICT)
2367                .unwrap_err();
2368
2369            let expected = dir.path().join(expected).canonicalize()?;
2370            assert_eq!(
2371                err.to_string(),
2372                format!("include cycle detected: {path}", path = expected.display())
2373            );
2374            assert!(matches!(err, SshParserError::IncludeCycle(p) if p == expected));
2375        }
2376        Ok(())
2377    }
2378
2379    #[cfg(unix)]
2380    #[test]
2381    fn should_reject_include_cycle_through_symlink() -> Result<(), SshParserError> {
2382        crate::test_log();
2383        let dir = tempfile::tempdir()?;
2384        let real = dir.path().join("real.conf");
2385        let link = dir.path().join("link.conf");
2386        std::os::unix::fs::symlink(&real, &link)?;
2387        std::fs::write(&real, format!("Include {link}\n", link = link.display()))?;
2388
2389        let config = format!("Include {real}\n", real = real.display());
2390        let mut reader = BufReader::new(config.as_bytes());
2391        let err = SshConfig::default()
2392            .default_algorithms(DefaultAlgorithms::empty())
2393            .parse(&mut reader, ParseRule::STRICT)
2394            .unwrap_err();
2395
2396        assert!(matches!(err, SshParserError::IncludeCycle(p) if p == real.canonicalize()?));
2397        Ok(())
2398    }
2399
2400    #[test]
2401    fn should_allow_non_cyclic_repeated_include() -> Result<(), SshParserError> {
2402        crate::test_log();
2403        let dir = tempfile::tempdir()?;
2404        let shared = dir.path().join("shared.conf");
2405        std::fs::write(&shared, "Host shared\n    HostName shared.internal\n")?;
2406        let left = dir.path().join("left.conf");
2407        let right = dir.path().join("right.conf");
2408        std::fs::write(
2409            &left,
2410            format!("Include {shared}\n", shared = shared.display()),
2411        )?;
2412        std::fs::write(
2413            &right,
2414            format!("Include {shared}\n", shared = shared.display()),
2415        )?;
2416
2417        let config = format!(
2418            "Include {left} {right}\n",
2419            left = left.display(),
2420            right = right.display()
2421        );
2422        let mut reader = BufReader::new(config.as_bytes());
2423        let config = SshConfig::default()
2424            .default_algorithms(DefaultAlgorithms::empty())
2425            .parse(&mut reader, ParseRule::STRICT)?;
2426
2427        assert_eq!(
2428            config.query("shared").host_name.as_deref(),
2429            Some("shared.internal")
2430        );
2431        Ok(())
2432    }
2433
2434    #[test]
2435    fn should_fail_parsing_algos_missing_arg() {
2436        crate::test_log();
2437        assert!(matches!(
2438            SshConfigParser::parse_algos(vec![]).unwrap_err(),
2439            SshParserError::MissingArgument
2440        ));
2441    }
2442
2443    #[test]
2444    fn should_parse_duration_zero() {
2445        crate::test_log();
2446        assert_eq!(
2447            SshConfigParser::parse_duration(vec![String::from("0")]).unwrap(),
2448            Duration::from_secs(0)
2449        );
2450    }
2451
2452    #[test]
2453    fn should_parse_port_boundary() {
2454        crate::test_log();
2455        // Minimum valid port
2456        assert_eq!(
2457            SshConfigParser::parse_port(vec![String::from("1")]).unwrap(),
2458            1
2459        );
2460        // Maximum valid port
2461        assert_eq!(
2462            SshConfigParser::parse_port(vec![String::from("65535")]).unwrap(),
2463            65535
2464        );
2465    }
2466
2467    #[test]
2468    fn should_update_host_add_keys_to_agent() {
2469        crate::test_log();
2470        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2471        SshConfigParser::update_host(
2472            Field::AddKeysToAgent,
2473            vec![String::from("yes")],
2474            &mut host,
2475            None,
2476        )
2477        .unwrap();
2478        assert_eq!(host.params.add_keys_to_agent.unwrap(), true);
2479
2480        let mut host2 = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2481        SshConfigParser::update_host(
2482            Field::AddKeysToAgent,
2483            vec![String::from("no")],
2484            &mut host2,
2485            None,
2486        )
2487        .unwrap();
2488        assert_eq!(host2.params.add_keys_to_agent.unwrap(), false);
2489    }
2490
2491    #[test]
2492    fn should_update_host_forward_agent() {
2493        crate::test_log();
2494        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2495        SshConfigParser::update_host(
2496            Field::ForwardAgent,
2497            vec![String::from("yes")],
2498            &mut host,
2499            None,
2500        )
2501        .unwrap();
2502        assert_eq!(host.params.forward_agent.unwrap(), true);
2503    }
2504
2505    #[test]
2506    fn should_update_host_proxy_jump() {
2507        crate::test_log();
2508        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2509        SshConfigParser::update_host(
2510            Field::ProxyJump,
2511            vec![String::from("jump1,jump2,jump3")],
2512            &mut host,
2513            None,
2514        )
2515        .unwrap();
2516        assert_eq!(
2517            host.params.proxy_jump.unwrap(),
2518            vec![
2519                "jump1".to_string(),
2520                "jump2".to_string(),
2521                "jump3".to_string()
2522            ]
2523        );
2524    }
2525
2526    #[test]
2527    fn should_update_host_identity_file() {
2528        crate::test_log();
2529        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2530        SshConfigParser::update_host(
2531            Field::IdentityFile,
2532            vec![String::from("/path/to/key1"), String::from("/path/to/key2")],
2533            &mut host,
2534            None,
2535        )
2536        .unwrap();
2537        assert_eq!(
2538            host.params.identity_file.unwrap(),
2539            vec![
2540                PathBuf::from("/path/to/key1"),
2541                PathBuf::from("/path/to/key2")
2542            ]
2543        );
2544    }
2545
2546    #[test]
2547    fn test_should_allow_and_append_multiple_identity_files_directives() {
2548        crate::test_log();
2549        let config = r##"
2550Host test
2551    IdentityFile /path/to/key1 /path/to/key2
2552    IdentityFile /path/to/key3
2553"##;
2554        let mut reader = BufReader::new(config.as_bytes());
2555        let config = SshConfig::default()
2556            .default_algorithms(DefaultAlgorithms::empty())
2557            .parse(&mut reader, ParseRule::STRICT)
2558            .expect("Failed to parse config");
2559
2560        let params = config.query("test");
2561        assert_eq!(
2562            params.identity_file.as_ref().unwrap(),
2563            &vec![
2564                PathBuf::from("/path/to/key1"),
2565                PathBuf::from("/path/to/key2"),
2566                PathBuf::from("/path/to/key3"),
2567            ]
2568        );
2569    }
2570
2571    #[test]
2572    fn test_should_accumulate_identity_files_across_host_blocks() {
2573        crate::test_log();
2574        let config = r##"
2575Host test
2576    IdentityFile /path/to/specific_key
2577
2578Host *
2579    IdentityFile /path/to/default_key
2580"##;
2581        let mut reader = BufReader::new(config.as_bytes());
2582        let config = SshConfig::default()
2583            .default_algorithms(DefaultAlgorithms::empty())
2584            .parse(&mut reader, ParseRule::STRICT)
2585            .expect("Failed to parse config");
2586
2587        let params = config.query("test");
2588        // Both identity files should be present: specific first, then default
2589        assert_eq!(
2590            params.identity_file.as_ref().unwrap(),
2591            &vec![
2592                PathBuf::from("/path/to/specific_key"),
2593                PathBuf::from("/path/to/default_key"),
2594            ]
2595        );
2596    }
2597
2598    #[test]
2599    fn should_store_unsupported_fields_when_allowed() {
2600        crate::test_log();
2601
2602        let config = r##"
2603Host test
2604    PasswordAuthentication yes
2605"##;
2606        let mut reader = BufReader::new(config.as_bytes());
2607        let config = SshConfig::default()
2608            .default_algorithms(DefaultAlgorithms::empty())
2609            .parse(&mut reader, ParseRule::ALLOW_UNSUPPORTED_FIELDS)
2610            .unwrap();
2611
2612        let params = config.query("test");
2613        assert!(
2614            params
2615                .unsupported_fields
2616                .contains_key("passwordauthentication")
2617        );
2618    }
2619
2620    #[test]
2621    fn should_tokenize_line_with_equals_separator() {
2622        crate::test_log();
2623        let (field, args) = SshConfigParser::tokenize_line("HostName=example.com").unwrap();
2624        assert_eq!(field, Field::HostName);
2625        assert_eq!(args, vec!["example.com".to_string()]);
2626    }
2627
2628    #[test]
2629    fn should_tokenize_line_with_quoted_args() {
2630        crate::test_log();
2631        let (field, args) =
2632            SshConfigParser::tokenize_line("Ciphers \"aes256-ctr,aes128-ctr\"").unwrap();
2633        assert_eq!(field, Field::Ciphers);
2634        assert_eq!(args, vec!["aes256-ctr,aes128-ctr".to_string()]);
2635    }
2636
2637    #[test]
2638    fn should_tokenize_line_with_equals_and_quoted_args() {
2639        crate::test_log();
2640        let (field, args) =
2641            SshConfigParser::tokenize_line("Ciphers=\"aes256-ctr,aes128-ctr\"").unwrap();
2642        assert_eq!(field, Field::Ciphers);
2643        assert_eq!(args, vec!["aes256-ctr,aes128-ctr".to_string()]);
2644    }
2645
2646    #[test]
2647    fn should_unescape_quoted_args() {
2648        crate::test_log();
2649
2650        // Test escaped double quote: \" -> "
2651        let (field, args) =
2652            SshConfigParser::tokenize_line(r#"HostName "gateway\"server""#).unwrap();
2653        assert_eq!(field, Field::HostName);
2654        assert_eq!(args, vec![r#"gateway"server"#.to_string()]);
2655
2656        // Test escaped backslash: \\ -> \
2657        let (field, args) = SshConfigParser::tokenize_line(r#"HostName "path\\to\\host""#).unwrap();
2658        assert_eq!(field, Field::HostName);
2659        assert_eq!(args, vec![r#"path\to\host"#.to_string()]);
2660
2661        // Test escaped single quote: \' -> '
2662        let (field, args) = SshConfigParser::tokenize_line(r#"HostName "it\'s a test""#).unwrap();
2663        assert_eq!(field, Field::HostName);
2664        assert_eq!(args, vec!["it's a test".to_string()]);
2665
2666        // Test multiple escape sequences combined
2667        let (field, args) =
2668            SshConfigParser::tokenize_line(r#"HostName "say \"hello\" and \\go""#).unwrap();
2669        assert_eq!(field, Field::HostName);
2670        assert_eq!(args, vec![r#"say "hello" and \go"#.to_string()]);
2671
2672        // Test unrecognized escape sequence (backslash preserved)
2673        let (field, args) = SshConfigParser::tokenize_line(r#"HostName "test\nvalue""#).unwrap();
2674        assert_eq!(field, Field::HostName);
2675        assert_eq!(args, vec![r#"test\nvalue"#.to_string()]);
2676    }
2677
2678    #[test]
2679    fn should_tokenize_line_setenv() -> Result<(), SshParserError> {
2680        crate::test_log();
2681        assert_eq!(
2682            SshConfigParser::tokenize_line(
2683                r#"SetEnv TEST_1=Test1 TEST_2="Test 2" TEST_3="Test \"3\"" TEST_4=Test"4""#
2684            )?,
2685            (
2686                Field::SetEnv,
2687                vec![
2688                    r#"TEST_1=Test1"#.to_owned(),
2689                    r#"TEST_2="Test 2""#.to_owned(),
2690                    r#"TEST_3="Test \"3\"""#.to_owned(),
2691                    r#"TEST_4=Test"4""#.to_owned(),
2692                ]
2693            )
2694        );
2695        Ok(())
2696    }
2697
2698    #[test]
2699    fn should_count_unescaped_quotes() {
2700        crate::test_log();
2701
2702        // No quotes
2703        assert_eq!(SshConfigParser::count_unescaped_quotes("hello"), 0);
2704
2705        // Simple unescaped quotes
2706        assert_eq!(SshConfigParser::count_unescaped_quotes(r#""hello""#), 2);
2707
2708        // Escaped quotes should not be counted
2709        assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\"hello\""#), 0);
2710
2711        // Mixed escaped and unescaped
2712        assert_eq!(
2713            SshConfigParser::count_unescaped_quotes(r#""hello\"world""#),
2714            2
2715        );
2716
2717        // Escaped backslash before quote (quote is unescaped)
2718        assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\\""#), 1);
2719
2720        // Empty string
2721        assert_eq!(SshConfigParser::count_unescaped_quotes(""), 0);
2722
2723        // Only escaped quote
2724        assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\""#), 0);
2725    }
2726
2727    #[test]
2728    fn should_detect_ends_with_unescaped_quote() {
2729        crate::test_log();
2730
2731        // Ends with unescaped quote
2732        assert!(SshConfigParser::ends_with_unescaped_quote(r#""hello""#));
2733
2734        // Ends with escaped quote (odd backslashes)
2735        assert!(!SshConfigParser::ends_with_unescaped_quote(r#""hello\""#));
2736
2737        // Ends with escaped backslash then unescaped quote
2738        assert!(SshConfigParser::ends_with_unescaped_quote(r#""hello\\""#));
2739
2740        // Ends with three backslashes then quote (escaped)
2741        assert!(!SshConfigParser::ends_with_unescaped_quote(r#""hello\\\""#));
2742
2743        // Doesn't end with quote at all
2744        assert!(!SshConfigParser::ends_with_unescaped_quote("hello"));
2745
2746        // Single quote
2747        assert!(SshConfigParser::ends_with_unescaped_quote(r#"""#));
2748
2749        // Single escaped quote
2750        assert!(!SshConfigParser::ends_with_unescaped_quote(r#"\""#));
2751    }
2752
2753    #[test]
2754    fn should_unescape_string() {
2755        crate::test_log();
2756
2757        // Escaped double quote
2758        assert_eq!(
2759            SshConfigParser::unescape_string(r#"hello\"world"#),
2760            r#"hello"world"#
2761        );
2762
2763        // Escaped backslash
2764        assert_eq!(
2765            SshConfigParser::unescape_string(r#"path\\to\\file"#),
2766            r#"path\to\file"#
2767        );
2768
2769        // Escaped single quote
2770        assert_eq!(SshConfigParser::unescape_string(r#"it\'s"#), "it's");
2771
2772        // Multiple escape sequences
2773        assert_eq!(
2774            SshConfigParser::unescape_string(r#"say \"hi\" and \\go"#),
2775            r#"say "hi" and \go"#
2776        );
2777
2778        // Unrecognized escape (backslash preserved)
2779        assert_eq!(
2780            SshConfigParser::unescape_string(r#"test\nvalue"#),
2781            r#"test\nvalue"#
2782        );
2783
2784        // No escapes
2785        assert_eq!(SshConfigParser::unescape_string("plain text"), "plain text");
2786
2787        // Empty string
2788        assert_eq!(SshConfigParser::unescape_string(""), "");
2789
2790        // Trailing backslash (no char to escape)
2791        assert_eq!(SshConfigParser::unescape_string(r#"test\"#), r#"test\"#);
2792
2793        // Double escaped backslash
2794        assert_eq!(SshConfigParser::unescape_string(r#"\\\\"#), r#"\\"#);
2795    }
2796
2797    #[test]
2798    fn should_parse_host_with_single_pattern() {
2799        crate::test_log();
2800        let result = SshConfigParser::parse_host(vec![String::from("example.com")]).unwrap();
2801        assert_eq!(result.len(), 1);
2802        assert_eq!(result[0].pattern, "example.com");
2803        assert!(!result[0].negated);
2804    }
2805
2806    #[test]
2807    fn should_parse_host_with_exclamation_in_pattern() {
2808        crate::test_log();
2809
2810        // Pattern with ! in the middle should be treated as literal (non-negated)
2811        let result = SshConfigParser::parse_host(vec![String::from("host!name")]).unwrap();
2812        assert_eq!(result.len(), 1);
2813        assert_eq!(result[0].pattern, "host!name");
2814        assert!(!result[0].negated);
2815
2816        // Negated pattern with ! in the pattern itself
2817        let result = SshConfigParser::parse_host(vec![String::from("!host!name")]).unwrap();
2818        assert_eq!(result.len(), 1);
2819        assert_eq!(result[0].pattern, "host!name");
2820        assert!(result[0].negated);
2821
2822        // Multiple ! after the negation prefix should be preserved
2823        let result = SshConfigParser::parse_host(vec![String::from("!a!b!c")]).unwrap();
2824        assert_eq!(result.len(), 1);
2825        assert_eq!(result[0].pattern, "a!b!c");
2826        assert!(result[0].negated);
2827
2828        // Only leading ! is negation, rest is literal
2829        let result = SshConfigParser::parse_host(vec![String::from("a!b")]).unwrap();
2830        assert_eq!(result.len(), 1);
2831        assert_eq!(result[0].pattern, "a!b");
2832        assert!(!result[0].negated);
2833    }
2834
2835    #[cfg(target_os = "macos")]
2836    #[test]
2837    fn should_update_host_use_keychain() {
2838        crate::test_log();
2839        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2840        SshConfigParser::update_host(
2841            Field::UseKeychain,
2842            vec![String::from("yes")],
2843            &mut host,
2844            None,
2845        )
2846        .unwrap();
2847        assert_eq!(host.params.use_keychain.unwrap(), true);
2848    }
2849
2850    fn create_ssh_config_with_quotes_and_eq() -> NamedTempFile {
2851        let mut tmpfile: tempfile::NamedTempFile =
2852            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2853        let config = r##"
2854# ssh config
2855# written by veeso
2856
2857
2858# I put a comment here just to annoy
2859
2860IgnoreUnknown=Pippo,Pluto
2861ConnectTimeout = 15
2862Ciphers "Pepperoni Pizza,Margherita Pizza,Hawaiian Pizza"
2863Macs="Pasta Carbonara,Pasta con tonno"
2864"##;
2865        tmpfile.write_all(config.as_bytes()).unwrap();
2866        tmpfile
2867    }
2868
2869    fn create_ssh_config() -> NamedTempFile {
2870        let mut tmpfile: tempfile::NamedTempFile =
2871            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2872        let config = r##"
2873# ssh config
2874# written by veeso
2875
2876
2877        # I put a comment here just to annoy
2878
2879IgnoreUnknown Pippo,Pluto
2880
2881Compression yes
2882ConnectionAttempts          10
2883ConnectTimeout 60
2884ServerAliveInterval 40
2885TcpKeepAlive    yes
2886Ciphers     +a-manella,blowfish
2887
2888# Let's start defining some hosts
2889
2890Host 192.168.*.*    172.26.*.*      !192.168.1.30
2891    User    omar
2892    # ForwardX11 is actually not supported; I just want to see that it wont' fail parsing
2893    ForwardX11    yes
2894    BindAddress     10.8.0.10
2895    BindInterface   tun0
2896    AddKeysToAgent yes
2897    Ciphers     +coi-piedi,cazdecan,triestin-stretto
2898    IdentityFile    /home/root/.ssh/pippo.key /home/root/.ssh/pluto.key
2899    Macs     spyro,deoxys
2900    Port 2222
2901    PubkeyAcceptedAlgorithms    -omar-crypt
2902    ProxyJump jump.example.com
2903
2904Host tostapane
2905    User    ciro-esposito
2906    HostName    192.168.24.32
2907    RemoteForward   88
2908    Compression no
2909    Pippo yes
2910    Pluto 56
2911    ProxyJump jump1.example.com,jump2.example.com
2912    Macs +spyro,deoxys
2913
2914Host    192.168.1.30
2915    User    nutellaro
2916    RemoteForward   123
2917
2918Host *
2919    CaSignatureAlgorithms   random
2920    HostKeyAlgorithms   luigi,mario
2921    KexAlgorithms   desu,gigi
2922    Macs     concorde
2923    PubkeyAcceptedAlgorithms    desu,omar-crypt,fast-omar-crypt
2924"##;
2925        tmpfile.write_all(config.as_bytes()).unwrap();
2926        tmpfile
2927    }
2928
2929    fn create_inverted_ssh_config() -> NamedTempFile {
2930        let mut tmpfile: tempfile::NamedTempFile =
2931            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2932        let config = r##"
2933Host *-host
2934    IdentityFile ~/.ssh/id_rsa_good
2935
2936Host remote-*
2937    HostName hostname.com
2938    User user
2939    IdentityFile ~/.ssh/id_rsa_bad
2940
2941Host *
2942    ConnectTimeout 15
2943    IdentityFile ~/.ssh/id_rsa_ugly
2944    "##;
2945        tmpfile.write_all(config.as_bytes()).unwrap();
2946        tmpfile
2947    }
2948
2949    fn create_ssh_config_with_comments() -> NamedTempFile {
2950        let mut tmpfile: tempfile::NamedTempFile =
2951            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2952        let config = r##"
2953Host cross-platform # this is my fav host
2954    HostName hostname.com
2955    User user
2956    IdentityFile ~/.ssh/id_rsa_good
2957
2958Host *
2959    AddKeysToAgent yes
2960    IdentityFile ~/.ssh/id_rsa_bad
2961    "##;
2962        tmpfile.write_all(config.as_bytes()).unwrap();
2963        tmpfile
2964    }
2965
2966    fn create_ssh_config_with_unknown_fields() -> NamedTempFile {
2967        let mut tmpfile: tempfile::NamedTempFile =
2968            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2969        let config = r##"
2970Host cross-platform # this is my fav host
2971    HostName hostname.com
2972    User user
2973    IdentityFile ~/.ssh/id_rsa_good
2974    Piropero yes
2975
2976Host *
2977    AddKeysToAgent yes
2978    IdentityFile ~/.ssh/id_rsa_bad
2979    "##;
2980        tmpfile.write_all(config.as_bytes()).unwrap();
2981        tmpfile
2982    }
2983
2984    #[test]
2985    fn test_should_parse_config_with_include() {
2986        crate::test_log();
2987
2988        let config = create_include_config();
2989        let file = File::open(config.config.path()).expect("Failed to open tempfile");
2990        let mut reader = BufReader::new(file);
2991
2992        let config = SshConfig::default()
2993            .default_algorithms(DefaultAlgorithms::empty())
2994            .parse(&mut reader, ParseRule::STRICT)
2995            .expect("Failed to parse config");
2996
2997        let default_params = config.query("unknown-host");
2998        // verify default params
2999        assert_eq!(
3000            default_params.connect_timeout.unwrap(),
3001            Duration::from_secs(60) // first read
3002        );
3003        assert_eq!(
3004            default_params.server_alive_interval.unwrap(),
3005            Duration::from_secs(40) // first read
3006        );
3007        assert_eq!(default_params.tcp_keep_alive.unwrap(), true);
3008        assert_eq!(default_params.ciphers.algorithms().is_empty(), true);
3009        assert_eq!(
3010            default_params.ignore_unknown.as_deref().unwrap(),
3011            &["Pippo", "Pluto"]
3012        );
3013        assert_eq!(default_params.compression.unwrap(), true);
3014        assert_eq!(default_params.connection_attempts.unwrap(), 10);
3015        assert_eq!(default_params.port, Some(345));
3016
3017        // verify include 1 overwrites the default value
3018        let glob_params = config.query("192.168.1.1");
3019        assert_eq!(
3020            glob_params.connect_timeout.unwrap(),
3021            Duration::from_secs(60)
3022        );
3023        assert_eq!(
3024            glob_params.server_alive_interval.unwrap(),
3025            Duration::from_secs(40) // first read
3026        );
3027        assert_eq!(glob_params.tcp_keep_alive.unwrap(), true);
3028        assert_eq!(glob_params.ciphers.algorithms().is_empty(), true);
3029
3030        // verify tostapane
3031        let tostapane_params = config.query("tostapane");
3032        assert_eq!(
3033            tostapane_params.connect_timeout.unwrap(),
3034            Duration::from_secs(60) // first read
3035        );
3036        assert_eq!(
3037            tostapane_params.server_alive_interval.unwrap(),
3038            Duration::from_secs(40) // first read
3039        );
3040        assert_eq!(tostapane_params.tcp_keep_alive.unwrap(), true);
3041        // verify ciphers
3042        assert_eq!(
3043            tostapane_params.ciphers.algorithms(),
3044            &[
3045                "a-manella",
3046                "blowfish",
3047                "coi-piedi",
3048                "cazdecan",
3049                "triestin-stretto"
3050            ]
3051        );
3052
3053        // verify included host (microwave)
3054        let microwave_params = config.query("microwave");
3055        assert_eq!(
3056            microwave_params.connect_timeout.unwrap(),
3057            Duration::from_secs(60) // (not) updated in inc4
3058        );
3059        assert_eq!(
3060            microwave_params.server_alive_interval.unwrap(),
3061            Duration::from_secs(40) // (not) updated in inc4
3062        );
3063        assert_eq!(
3064            microwave_params.port.unwrap(),
3065            345 // updated in inc4
3066        );
3067        assert_eq!(microwave_params.tcp_keep_alive.unwrap(), true);
3068        assert_eq!(microwave_params.ciphers.algorithms().is_empty(), true);
3069        assert_eq!(microwave_params.user.as_deref().unwrap(), "mario-rossi");
3070        assert_eq!(
3071            microwave_params.host_name.as_deref().unwrap(),
3072            "192.168.24.33"
3073        );
3074        assert_eq!(
3075            microwave_params.remote_forward,
3076            vec![RemoteForward::new(RemoteForwardListen::Port(88), None)]
3077        );
3078        assert_eq!(microwave_params.compression.unwrap(), true);
3079
3080        // verify included host (fridge)
3081        let fridge_params = config.query("fridge");
3082        assert_eq!(
3083            fridge_params.connect_timeout.unwrap(),
3084            Duration::from_secs(60)
3085        ); // default
3086        assert_eq!(
3087            fridge_params.server_alive_interval.unwrap(),
3088            Duration::from_secs(40)
3089        ); // default
3090        assert_eq!(fridge_params.tcp_keep_alive.unwrap(), true);
3091        assert_eq!(fridge_params.ciphers.algorithms().is_empty(), true);
3092        assert_eq!(fridge_params.user.as_deref().unwrap(), "luigi-verdi");
3093        assert_eq!(fridge_params.host_name.as_deref().unwrap(), "192.168.24.34");
3094    }
3095
3096    #[test]
3097    fn should_restore_parent_scope_after_include() -> Result<(), SshParserError> {
3098        let mut included = NamedTempFile::new().expect("Failed to create included file");
3099        included.write_all(b"Host foo\n    HostName foo.example\n")?;
3100
3101        let config = format!(
3102            "Include {included}\nUser alice\n",
3103            included = included.path().display(),
3104        );
3105        let mut reader = BufReader::new(config.as_bytes());
3106        let config = SshConfig::default()
3107            .default_algorithms(DefaultAlgorithms::empty())
3108            .parse(&mut reader, ParseRule::STRICT)?;
3109
3110        let default = config.query("github.com");
3111        assert_eq!(default.user.as_deref(), Some("alice"));
3112        assert!(default.host_name.is_none());
3113
3114        let foo = config.query("foo");
3115        assert_eq!(foo.user.as_deref(), Some("alice"));
3116        assert_eq!(foo.host_name.as_deref(), Some("foo.example"));
3117
3118        Ok(())
3119    }
3120
3121    #[test]
3122    fn should_keep_included_values_before_parent_fallbacks() -> Result<(), SshParserError> {
3123        let mut included = NamedTempFile::new().expect("Failed to create included file");
3124        included.write_all(b"Host foo\n    User bob\n")?;
3125
3126        let config = format!(
3127            "Include {included}\nUser alice\n",
3128            included = included.path().display(),
3129        );
3130        let mut reader = BufReader::new(config.as_bytes());
3131        let config = SshConfig::default()
3132            .default_algorithms(DefaultAlgorithms::empty())
3133            .parse(&mut reader, ParseRule::STRICT)?;
3134
3135        assert_eq!(config.query("foo").user.as_deref(), Some("bob"));
3136        assert_eq!(config.query("github.com").user.as_deref(), Some("alice"));
3137
3138        Ok(())
3139    }
3140
3141    #[test]
3142    fn should_require_enclosing_and_included_host_scopes() -> Result<(), SshParserError> {
3143        let mut included = NamedTempFile::new().expect("Failed to create included file");
3144        included.write_all(b"Host app*\n    HostName included.internal\n")?;
3145
3146        let config = format!(
3147            concat!(
3148                "Host *.example.com !app-blocked.example.com\n",
3149                "    Include {included}\n",
3150                "    Port 2222\n",
3151                "Host *\n",
3152                "    User fallback\n",
3153            ),
3154            included = included.path().display(),
3155        );
3156        let mut reader = BufReader::new(config.as_bytes());
3157        let config = SshConfig::default()
3158            .default_algorithms(DefaultAlgorithms::empty())
3159            .parse(&mut reader, ParseRule::STRICT)?;
3160
3161        let allowed = config.query("app1.example.com");
3162        assert_eq!(allowed.host_name.as_deref(), Some("included.internal"));
3163        assert_eq!(allowed.port, Some(2222));
3164        assert_eq!(allowed.user.as_deref(), Some("fallback"));
3165
3166        let negated = config.query("app-blocked.example.com");
3167        assert!(negated.host_name.is_none());
3168        assert!(negated.port.is_none());
3169        assert_eq!(negated.user.as_deref(), Some("fallback"));
3170
3171        let outside = config.query("app1.example.net");
3172        assert!(outside.host_name.is_none());
3173        assert!(outside.port.is_none());
3174        assert_eq!(outside.user.as_deref(), Some("fallback"));
3175
3176        assert!(
3177            config
3178                .intersecting_hosts("app-blocked.example.com")
3179                .all(|host| host.params.host_name.as_deref() != Some("included.internal"))
3180        );
3181
3182        Ok(())
3183    }
3184
3185    #[test]
3186    fn should_restore_scope_between_consecutive_includes() -> Result<(), SshParserError> {
3187        let mut first = NamedTempFile::new().expect("Failed to create first include");
3188        first.write_all(b"Host bar\n    HostName bar.internal\n")?;
3189        let mut second = NamedTempFile::new().expect("Failed to create second include");
3190        second.write_all(b"Port 2222\n")?;
3191
3192        let config = format!(
3193            concat!(
3194                "Host foo\n",
3195                "    Include {first}\n",
3196                "    Include {second}\n",
3197                "    User alice\n",
3198            ),
3199            first = first.path().display(),
3200            second = second.path().display(),
3201        );
3202        let mut reader = BufReader::new(config.as_bytes());
3203        let config = SshConfig::default()
3204            .default_algorithms(DefaultAlgorithms::empty())
3205            .parse(&mut reader, ParseRule::STRICT)?;
3206
3207        let foo = config.query("foo");
3208        assert_eq!(foo.port, Some(2222));
3209        assert_eq!(foo.user.as_deref(), Some("alice"));
3210
3211        let bar = config.query("bar");
3212        assert!(bar.host_name.is_none());
3213        assert!(bar.port.is_none());
3214        assert!(bar.user.is_none());
3215
3216        Ok(())
3217    }
3218
3219    #[test]
3220    fn should_preserve_all_nested_include_scopes() -> Result<(), SshParserError> {
3221        let mut inner = NamedTempFile::new().expect("Failed to create inner include");
3222        inner.write_all(b"User nested\n")?;
3223
3224        let mut outer = NamedTempFile::new().expect("Failed to create outer include");
3225        writeln!(
3226            outer,
3227            "Host app*\n    Include {inner}",
3228            inner = inner.path().display(),
3229        )?;
3230
3231        let config = format!(
3232            "Host *.example.com\n    Include {outer}\n",
3233            outer = outer.path().display(),
3234        );
3235        let mut reader = BufReader::new(config.as_bytes());
3236        let config = SshConfig::default()
3237            .default_algorithms(DefaultAlgorithms::empty())
3238            .parse(&mut reader, ParseRule::STRICT)?;
3239
3240        assert_eq!(
3241            config.query("app1.example.com").user.as_deref(),
3242            Some("nested")
3243        );
3244        assert!(config.query("app1.example.net").user.is_none());
3245        assert!(config.query("other.example.com").user.is_none());
3246
3247        Ok(())
3248    }
3249
3250    #[test]
3251    fn should_include_every_path_argument() -> Result<(), SshParserError> {
3252        let mut first = NamedTempFile::new().expect("Failed to create first include");
3253        first.write_all(b"Host foo\n    HostName foo.internal\n")?;
3254        let mut second = NamedTempFile::new().expect("Failed to create second include");
3255        second.write_all(b"Host bar\n    HostName bar.internal\n")?;
3256
3257        let config = format!(
3258            "Include {first} {second}\n",
3259            first = first.path().display(),
3260            second = second.path().display(),
3261        );
3262        let mut reader = BufReader::new(config.as_bytes());
3263        let config = SshConfig::default()
3264            .default_algorithms(DefaultAlgorithms::empty())
3265            .parse(&mut reader, ParseRule::STRICT)?;
3266
3267        assert_eq!(
3268            config.query("foo").host_name.as_deref(),
3269            Some("foo.internal")
3270        );
3271        assert_eq!(
3272            config.query("bar").host_name.as_deref(),
3273            Some("bar.internal")
3274        );
3275
3276        Ok(())
3277    }
3278
3279    #[test]
3280    fn should_take_first_obtained_value_across_include_arguments() -> Result<(), SshParserError> {
3281        let mut first = NamedTempFile::new().expect("Failed to create first include");
3282        first.write_all(b"Host foo\n    User first\n")?;
3283        let mut second = NamedTempFile::new().expect("Failed to create second include");
3284        second.write_all(b"Host foo\n    User second\n    Port 2222\n")?;
3285
3286        let config = format!(
3287            "Include {first} {second}\n",
3288            first = first.path().display(),
3289            second = second.path().display(),
3290        );
3291        let mut reader = BufReader::new(config.as_bytes());
3292        let config = SshConfig::default()
3293            .default_algorithms(DefaultAlgorithms::empty())
3294            .parse(&mut reader, ParseRule::STRICT)?;
3295
3296        assert_eq!(config.query("foo").user.as_deref(), Some("first"));
3297        assert_eq!(config.query("foo").port, Some(2222));
3298
3299        let config = format!(
3300            "Include {second} {first}\n",
3301            first = first.path().display(),
3302            second = second.path().display(),
3303        );
3304        let mut reader = BufReader::new(config.as_bytes());
3305        let config = SshConfig::default()
3306            .default_algorithms(DefaultAlgorithms::empty())
3307            .parse(&mut reader, ParseRule::STRICT)?;
3308
3309        assert_eq!(config.query("foo").user.as_deref(), Some("second"));
3310
3311        Ok(())
3312    }
3313
3314    #[test]
3315    fn should_expand_glob_in_each_include_argument() -> Result<(), SshParserError> {
3316        let conf_dir = tempfile::tempdir()?;
3317        std::fs::write(
3318            conf_dir.path().join("10-a.conf"),
3319            "Host foo\n    User glob-a\n",
3320        )?;
3321        std::fs::write(
3322            conf_dir.path().join("20-b.conf"),
3323            "Host foo\n    User glob-b\n    HostName b.internal\n",
3324        )?;
3325        std::fs::write(
3326            conf_dir.path().join("other.txt"),
3327            "Host foo\n    Port 9999\n",
3328        )?;
3329        let extra_dir = tempfile::tempdir()?;
3330        std::fs::write(
3331            extra_dir.path().join("x.conf"),
3332            "Host bar\n    HostName bar.internal\n",
3333        )?;
3334
3335        let config = format!(
3336            "Include {conf}/*.conf {extra}/*.conf\n",
3337            conf = conf_dir.path().display(),
3338            extra = extra_dir.path().display(),
3339        );
3340        let mut reader = BufReader::new(config.as_bytes());
3341        let config = SshConfig::default()
3342            .default_algorithms(DefaultAlgorithms::empty())
3343            .parse(&mut reader, ParseRule::STRICT)?;
3344
3345        assert_eq!(config.query("foo").user.as_deref(), Some("glob-a"));
3346        assert_eq!(config.query("foo").host_name.as_deref(), Some("b.internal"));
3347        assert!(config.query("foo").port.is_none());
3348        assert_eq!(
3349            config.query("bar").host_name.as_deref(),
3350            Some("bar.internal")
3351        );
3352
3353        Ok(())
3354    }
3355
3356    #[test]
3357    fn should_skip_unmatched_include_arguments() -> Result<(), SshParserError> {
3358        let missing_dir = tempfile::tempdir()?;
3359        let missing = missing_dir.path().join("absent").join("*.conf");
3360        let mut present = NamedTempFile::new().expect("Failed to create include");
3361        present.write_all(b"Host foo\n    HostName foo.internal\n")?;
3362
3363        let config = format!(
3364            "Include {missing} {present} {missing}\n",
3365            missing = missing.display(),
3366            present = present.path().display(),
3367        );
3368        let mut reader = BufReader::new(config.as_bytes());
3369        let config = SshConfig::default()
3370            .default_algorithms(DefaultAlgorithms::empty())
3371            .parse(&mut reader, ParseRule::STRICT)?;
3372
3373        assert_eq!(
3374            config.query("foo").host_name.as_deref(),
3375            Some("foo.internal")
3376        );
3377
3378        Ok(())
3379    }
3380
3381    #[test]
3382    fn should_scope_every_include_argument_to_enclosing_host() -> Result<(), SshParserError> {
3383        let mut first = NamedTempFile::new().expect("Failed to create first include");
3384        first.write_all(b"Host app*\n    HostName first.internal\n")?;
3385        let mut second = NamedTempFile::new().expect("Failed to create second include");
3386        second.write_all(b"Port 2222\nHost db*\n    HostName second.internal\n")?;
3387
3388        let config = format!(
3389            "Host *.example.com\n    Include {first} {second}\n    User alice\nHost *\n    User fallback\n",
3390            first = first.path().display(),
3391            second = second.path().display(),
3392        );
3393        let mut reader = BufReader::new(config.as_bytes());
3394        let config = SshConfig::default()
3395            .default_algorithms(DefaultAlgorithms::empty())
3396            .parse(&mut reader, ParseRule::STRICT)?;
3397
3398        let app = config.query("app1.example.com");
3399        assert_eq!(app.host_name.as_deref(), Some("first.internal"));
3400        assert_eq!(app.port, Some(2222));
3401        assert_eq!(app.user.as_deref(), Some("alice"));
3402
3403        let db = config.query("db1.example.com");
3404        assert_eq!(db.host_name.as_deref(), Some("second.internal"));
3405        assert_eq!(db.port, Some(2222));
3406        assert_eq!(db.user.as_deref(), Some("alice"));
3407
3408        let outside = config.query("db1.example.net");
3409        assert!(outside.host_name.is_none());
3410        assert!(outside.port.is_none());
3411        assert_eq!(outside.user.as_deref(), Some("fallback"));
3412
3413        Ok(())
3414    }
3415
3416    #[allow(dead_code)]
3417    struct ConfigWithInclude {
3418        config: NamedTempFile,
3419        inc1: NamedTempFile,
3420        inc2: NamedTempFile,
3421        inc3: NamedTempFile,
3422        inc4: NamedTempFile,
3423    }
3424
3425    fn create_include_config() -> ConfigWithInclude {
3426        let mut config_file: tempfile::NamedTempFile =
3427            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3428        let mut inc1_file: tempfile::NamedTempFile =
3429            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3430        let mut inc2_file: tempfile::NamedTempFile =
3431            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3432        let mut inc3_file: tempfile::NamedTempFile =
3433            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3434        let mut inc4_file: tempfile::NamedTempFile =
3435            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3436
3437        let config = format!(
3438            r##"
3439# ssh config
3440# written by veeso
3441
3442
3443        # I put a comment here just to annoy
3444
3445IgnoreUnknown Pippo,Pluto
3446
3447Compression yes
3448ConnectionAttempts          10
3449ConnectTimeout 60
3450ServerAliveInterval 40
3451Include {inc1}
3452
3453# Let's start defining some hosts
3454
3455Host tostapane
3456    User    ciro-esposito
3457    HostName    192.168.24.32
3458    RemoteForward   88
3459    Compression no
3460    # Ignore unknown fields should be inherited from the global section
3461    Pippo yes
3462    Pluto 56
3463    Include {inc2}
3464
3465Host *
3466Include {inc3}
3467Include {inc4}
3468"##,
3469            inc1 = inc1_file.path().display(),
3470            inc2 = inc2_file.path().display(),
3471            inc3 = inc3_file.path().display(),
3472            inc4 = inc4_file.path().display(),
3473        );
3474        config_file.write_all(config.as_bytes()).unwrap();
3475
3476        // write include 1
3477        let inc1 = r##"
3478        ConnectTimeout 60
3479        ServerAliveInterval 60
3480        TcpKeepAlive    yes
3481        "##;
3482        inc1_file.write_all(inc1.as_bytes()).unwrap();
3483
3484        // write include 2
3485        let inc2 = r##"
3486        ConnectTimeout 180
3487        ServerAliveInterval 180
3488        Ciphers     +a-manella,blowfish,coi-piedi,cazdecan,triestin-stretto
3489        "##;
3490        inc2_file.write_all(inc2.as_bytes()).unwrap();
3491
3492        // write include 3 with host directive
3493        let inc3 = r##"
3494Host microwave
3495    User    mario-rossi
3496    HostName    192.168.24.33
3497    RemoteForward   88
3498    Compression no
3499    # Ignore unknown fields should be inherited from the global section
3500    Pippo yes
3501    Pluto 56
3502"##;
3503        inc3_file.write_all(inc3.as_bytes()).unwrap();
3504
3505        // write include 4 which updates a param from microwave and then create a new host
3506        let inc4 = r##"
3507    # Update microwave
3508    ServerAliveInterval 30
3509    Port 345
3510
3511# Force microwave update (it won't work)
3512Host microwave
3513    ConnectTimeout 30
3514
3515Host fridge
3516    User    luigi-verdi
3517    HostName    192.168.24.34
3518    RemoteForward   88
3519    Compression no
3520"##;
3521        inc4_file.write_all(inc4.as_bytes()).unwrap();
3522
3523        ConfigWithInclude {
3524            config: config_file,
3525            inc1: inc1_file,
3526            inc2: inc2_file,
3527            inc3: inc3_file,
3528            inc4: inc4_file,
3529        }
3530    }
3531}