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