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