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 every file matched by each path argument, in order, 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        if args.is_empty() {
614            return Err(SshParserError::MissingArgument);
615        }
616
617        let mut new_hosts = vec![];
618
619        for arg in args {
620            let path_match = Self::resolve_include_path(&arg);
621
622            trace!("include files: {path_match}",);
623            let files = glob(&path_match)?;
624
625            for file in files {
626                let file = file?;
627                trace!("including file: {}", file.display());
628                let mut reader = BufReader::new(File::open(file)?);
629                let mut sub_config = SshConfig::default().default_algorithms(default_algos.clone());
630                Self::parse(&mut sub_config, &mut reader, rules, ignore_unknown.clone())?;
631
632                let SshConfig {
633                    hosts: sub_hosts,
634                    host_scopes: sub_scopes,
635                    ..
636                } = sub_config;
637                for (index, sub_host) in sub_hosts.into_iter().enumerate() {
638                    trace!("adding sub-host: {sub_host:?}",);
639                    let mut scope = inherited_scope.to_vec();
640                    scope.push(pattern.to_vec());
641                    if let Some(sub_scope) = sub_scopes.get(index) {
642                        scope.extend(sub_scope.iter().cloned());
643                    }
644                    new_hosts.push((sub_host, scope));
645                }
646            }
647        }
648
649        Ok(new_hosts)
650    }
651
652    /// Tokenize line if possible. Returns [`Field`] name and args as a [`Vec`] of [`String`].
653    ///
654    /// All of these lines are valid for tokenization
655    ///
656    /// ```txt
657    /// IgnoreUnknown=Pippo,Pluto
658    /// ConnectTimeout = 15
659    /// Ciphers "Pepperoni Pizza,Margherita Pizza,Hawaiian Pizza"
660    /// Macs="Pasta Carbonara,Pasta con tonno"
661    /// ```
662    ///
663    /// So lines have syntax `field args...`, `field=args...`, `field "args"`, `field="args"`
664    fn tokenize_line(line: &str) -> SshParserResult<(Field, Vec<String>)> {
665        // check what comes first, space or =?
666        let trimmed_line = line.trim();
667        // first token is the field, and it may be separated either by a space or by '='
668        let (field, other_tokens) = if trimmed_line.find('=').unwrap_or(usize::MAX)
669            < trimmed_line.find(char::is_whitespace).unwrap_or(usize::MAX)
670        {
671            trimmed_line
672                .split_once('=')
673                .ok_or(SshParserError::MissingArgument)?
674        } else {
675            trimmed_line
676                .split_once(char::is_whitespace)
677                .ok_or(SshParserError::MissingArgument)?
678        };
679
680        trace!("tokenized line '{line}' - field '{field}' with args '{other_tokens}'",);
681
682        // other tokens should trim = and whitespace
683        let other_tokens = other_tokens.trim().trim_start_matches('=').trim();
684        trace!("other tokens trimmed: '{other_tokens}'",);
685
686        // Validate quotes - count unescaped quotes (not preceded by backslash)
687        let unescaped_quote_count = Self::count_unescaped_quotes(other_tokens);
688        if unescaped_quote_count % 2 != 0 {
689            return Err(SshParserError::InvalidQuotes);
690        }
691
692        // split arguments while respecting quoted spans (whitespace inside quotes is preserved)
693        let raw_tokens = Self::split_args_respecting_quotes(other_tokens);
694
695        // if entire args is a single fully-quoted token, strip quotes and unescape
696        let args = if raw_tokens.len() == 1
697            && raw_tokens[0].starts_with('"')
698            && raw_tokens[0].len() >= 2
699            && Self::ends_with_unescaped_quote(&raw_tokens[0])
700        {
701            trace!("quoted args: '{}'", raw_tokens[0]);
702            let t = &raw_tokens[0];
703            let content = &t[1..t.len() - 1];
704            vec![Self::unescape_string(content)]
705        } else {
706            trace!("split args: {:?}", raw_tokens);
707            raw_tokens
708        };
709
710        match Field::from_str(field) {
711            Ok(field) => Ok((field, args)),
712            Err(_) => Err(SshParserError::UnknownField(field.to_string(), args)),
713        }
714    }
715
716    // -- value parsers
717
718    /// Parse boolean value.
719    ///
720    /// Like OpenSSH, accepts `yes`/`true` and `no`/`false`, case-insensitively.
721    fn parse_boolean(args: Vec<String>) -> SshParserResult<bool> {
722        let value = args.first().ok_or(SshParserError::MissingArgument)?;
723
724        if value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("true") {
725            Ok(true)
726        } else if value.eq_ignore_ascii_case("no") || value.eq_ignore_ascii_case("false") {
727            Ok(false)
728        } else {
729            Err(SshParserError::ExpectedBoolean)
730        }
731    }
732
733    /// Parse algorithms argument
734    fn parse_algos(args: Vec<String>) -> SshParserResult<AlgorithmsRule> {
735        let first = args.first().ok_or(SshParserError::MissingArgument)?;
736
737        AlgorithmsRule::from_str(first)
738    }
739
740    /// Parse comma separated list arguments
741    fn parse_comma_separated_list(args: Vec<String>) -> SshParserResult<Vec<String>> {
742        match args
743            .first()
744            .map(|x| x.split(',').map(|x| x.to_string()).collect())
745        {
746            Some(args) => Ok(args),
747            _ => Err(SshParserError::MissingArgument),
748        }
749    }
750
751    /// Parse duration argument
752    fn parse_duration(args: Vec<String>) -> SshParserResult<Duration> {
753        let value = Self::parse_unsigned(args)?;
754        Ok(Duration::from_secs(value as u64))
755    }
756
757    /// Parse host argument.
758    /// A leading `!` indicates a negated pattern. Any `!` characters after the first position
759    /// are treated as literal characters in the pattern.
760    fn parse_host(args: Vec<String>) -> SshParserResult<Vec<HostClause>> {
761        if args.is_empty() {
762            return Err(SshParserError::MissingArgument);
763        }
764        // Collect hosts
765        Ok(args
766            .into_iter()
767            .map(|x| {
768                if let Some(pattern) = x.strip_prefix('!') {
769                    HostClause::new(pattern.to_string(), true)
770                } else {
771                    HostClause::new(x, false)
772                }
773            })
774            .collect())
775    }
776
777    /// Parse a list of paths
778    fn parse_path_list(args: Vec<String>) -> SshParserResult<Vec<PathBuf>> {
779        if args.is_empty() {
780            return Err(SshParserError::MissingArgument);
781        }
782        args.iter()
783            .map(|x| Self::parse_path_arg(x.as_str()))
784            .collect()
785    }
786
787    /// Parse path argument
788    fn parse_path(args: Vec<String>) -> SshParserResult<PathBuf> {
789        if let Some(s) = args.first() {
790            Self::parse_path_arg(s)
791        } else {
792            Err(SshParserError::MissingArgument)
793        }
794    }
795
796    /// Parse path argument
797    fn parse_path_arg(s: &str) -> SshParserResult<PathBuf> {
798        // Remove tilde
799        let s = if s.starts_with('~') {
800            let home_dir = dirs::home_dir()
801                .unwrap_or_else(|| PathBuf::from("~"))
802                .to_string_lossy()
803                .to_string();
804            s.replacen('~', &home_dir, 1)
805        } else {
806            s.to_string()
807        };
808        Ok(PathBuf::from(s))
809    }
810
811    /// Parse port number argument
812    fn parse_port(args: Vec<String>) -> SshParserResult<u16> {
813        match args.first().map(|x| u16::from_str(x)) {
814            Some(Ok(val)) => Ok(val),
815            Some(Err(_)) => Err(SshParserError::ExpectedPort),
816            None => Err(SshParserError::MissingArgument),
817        }
818    }
819
820    /// Parse a complete remote forwarding specification.
821    fn parse_remote_forward(args: Vec<String>) -> SshParserResult<RemoteForward> {
822        if !(1..=2).contains(&args.len()) {
823            return Err(SshParserError::InvalidRemoteForward(args));
824        }
825
826        let listen_argument = Self::normalize_forward_argument(&args[0]);
827        let listen = if listen_argument.contains('/') {
828            RemoteForwardListen::UnixSocket(PathBuf::from(listen_argument))
829        } else if let Ok(port) = u16::from_str(&listen_argument) {
830            RemoteForwardListen::Port(port)
831        } else if let Some((host, port)) = Self::parse_forward_host(&listen_argument, true) {
832            RemoteForwardListen::Host { host, port }
833        } else {
834            return Err(SshParserError::InvalidRemoteForward(args));
835        };
836
837        let destination = if let Some(argument) = args.get(1) {
838            let argument = Self::normalize_forward_argument(argument);
839            if argument.contains('/') {
840                Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
841                    argument,
842                )))
843            } else if let Some((host, port)) = Self::parse_forward_host(&argument, false) {
844                Some(RemoteForwardDestination::Host { host, port })
845            } else {
846                return Err(SshParserError::InvalidRemoteForward(args));
847            }
848        } else {
849            None
850        };
851
852        Ok(RemoteForward::new(listen, destination))
853    }
854
855    /// Remove optional quotes and supported escapes from a forwarding argument.
856    fn normalize_forward_argument(argument: &str) -> String {
857        if argument.starts_with('"')
858            && argument.len() >= 2
859            && Self::ends_with_unescaped_quote(argument)
860        {
861            Self::unescape_string(&argument[1..argument.len() - 1])
862        } else {
863            argument.to_string()
864        }
865    }
866
867    /// Parse an OpenSSH host and port pair.
868    fn parse_forward_host(value: &str, allow_empty_host: bool) -> Option<(String, u16)> {
869        let (host, port) = if let Some(value) = value.strip_prefix('[') {
870            let (host, port) = value.split_once("]:")?;
871            if host.is_empty() || host.contains(['[', ']']) {
872                return None;
873            }
874            (host, port)
875        } else {
876            let (host, port) = value.rsplit_once(':')?;
877            if host.contains([':', '[', ']']) {
878                return None;
879            }
880            (host, port)
881        };
882
883        if !allow_empty_host && host.is_empty() {
884            return None;
885        }
886
887        Some((host.to_string(), u16::from_str(port).ok()?))
888    }
889
890    /// Parse string argument
891    fn parse_string(args: Vec<String>) -> SshParserResult<String> {
892        if let Some(s) = args.into_iter().next() {
893            Ok(s)
894        } else {
895            Err(SshParserError::MissingArgument)
896        }
897    }
898
899    /// Parse unsigned argument
900    fn parse_unsigned(args: Vec<String>) -> SshParserResult<usize> {
901        match args.first().map(|x| usize::from_str(x)) {
902            Some(Ok(val)) => Ok(val),
903            Some(Err(_)) => Err(SshParserError::ExpectedUnsigned),
904            None => Err(SshParserError::MissingArgument),
905        }
906    }
907}
908
909#[cfg(test)]
910mod tests {
911
912    use std::fs::File;
913    use std::io::{BufReader, Write};
914    use std::path::{Path, PathBuf};
915
916    use pretty_assertions::assert_eq;
917    use tempfile::NamedTempFile;
918
919    use super::*;
920    use crate::{DefaultAlgorithms, RemoteForward, RemoteForwardDestination, RemoteForwardListen};
921
922    #[test]
923    fn should_parse_remote_forward_listeners() -> Result<(), SshParserError> {
924        let cases = [
925            (
926                vec!["8080"],
927                RemoteForward::new(RemoteForwardListen::Port(8080), None),
928            ),
929            (
930                vec!["localhost:8080"],
931                RemoteForward::new(
932                    RemoteForwardListen::Host {
933                        host: "localhost".to_string(),
934                        port: 8080,
935                    },
936                    None,
937                ),
938            ),
939            (
940                vec!["0.0.0.0:8080"],
941                RemoteForward::new(
942                    RemoteForwardListen::Host {
943                        host: "0.0.0.0".to_string(),
944                        port: 8080,
945                    },
946                    None,
947                ),
948            ),
949            (
950                vec!["*:8080"],
951                RemoteForward::new(
952                    RemoteForwardListen::Host {
953                        host: "*".to_string(),
954                        port: 8080,
955                    },
956                    None,
957                ),
958            ),
959            (
960                vec![":8080"],
961                RemoteForward::new(
962                    RemoteForwardListen::Host {
963                        host: String::new(),
964                        port: 8080,
965                    },
966                    None,
967                ),
968            ),
969            (
970                vec!["[::1]:8080"],
971                RemoteForward::new(
972                    RemoteForwardListen::Host {
973                        host: "::1".to_string(),
974                        port: 8080,
975                    },
976                    None,
977                ),
978            ),
979            (
980                vec!["/tmp/remote.sock"],
981                RemoteForward::new(
982                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
983                    None,
984                ),
985            ),
986        ];
987
988        for (args, expected) in cases {
989            let args = args.into_iter().map(str::to_string).collect();
990            assert_eq!(SshConfigParser::parse_remote_forward(args)?, expected);
991        }
992        Ok(())
993    }
994
995    #[test]
996    fn should_parse_remote_forward_destinations() -> Result<(), SshParserError> {
997        let cases = [
998            (
999                vec!["8080", "localhost:80"],
1000                RemoteForward::new(
1001                    RemoteForwardListen::Port(8080),
1002                    Some(RemoteForwardDestination::Host {
1003                        host: "localhost".to_string(),
1004                        port: 80,
1005                    }),
1006                ),
1007            ),
1008            (
1009                vec!["/tmp/remote.sock", "/tmp/local.sock"],
1010                RemoteForward::new(
1011                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
1012                    Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
1013                        "/tmp/local.sock",
1014                    ))),
1015                ),
1016            ),
1017            (
1018                vec!["\"/tmp/remote socket\"", "[2001:db8::1]:443"],
1019                RemoteForward::new(
1020                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote socket")),
1021                    Some(RemoteForwardDestination::Host {
1022                        host: "2001:db8::1".to_string(),
1023                        port: 443,
1024                    }),
1025                ),
1026            ),
1027        ];
1028
1029        for (args, expected) in cases {
1030            let args = args.into_iter().map(str::to_string).collect();
1031            assert_eq!(SshConfigParser::parse_remote_forward(args)?, expected);
1032        }
1033        Ok(())
1034    }
1035
1036    #[test]
1037    fn should_reject_invalid_remote_forward() {
1038        for args in [
1039            vec![],
1040            vec!["localhost"],
1041            vec!["[::1:8080"],
1042            vec!["70000"],
1043            vec!["localhost:70000"],
1044            vec!["8080", "80"],
1045            vec!["8080", "localhost:80", "unexpected"],
1046        ] {
1047            let args = args.into_iter().map(str::to_string).collect();
1048            assert!(matches!(
1049                SshConfigParser::parse_remote_forward(args),
1050                Err(SshParserError::InvalidRemoteForward(_))
1051            ));
1052        }
1053    }
1054
1055    #[test]
1056    fn should_parse_configuration() -> Result<(), SshParserError> {
1057        crate::test_log();
1058        let temp = create_ssh_config();
1059        let file = File::open(temp.path()).expect("Failed to open tempfile");
1060        let mut reader = BufReader::new(file);
1061        let config = SshConfig::default()
1062            .default_algorithms(DefaultAlgorithms {
1063                ca_signature_algorithms: vec![],
1064                ciphers: vec![],
1065                host_key_algorithms: vec![],
1066                kex_algorithms: vec![],
1067                mac: vec![],
1068                pubkey_accepted_algorithms: vec!["omar-crypt".to_string()],
1069            })
1070            .parse(&mut reader, ParseRule::STRICT)?;
1071
1072        // Query openssh cmdline overrides (options preceding the first `Host` section,
1073        // overriding all following options)
1074        let params = config.query("*");
1075        assert_eq!(
1076            params.ignore_unknown.as_deref().unwrap(),
1077            &["Pippo", "Pluto"]
1078        );
1079        assert_eq!(params.compression.unwrap(), true);
1080        assert_eq!(params.connection_attempts.unwrap(), 10);
1081        assert_eq!(params.connect_timeout.unwrap(), Duration::from_secs(60));
1082        assert_eq!(
1083            params.server_alive_interval.unwrap(),
1084            Duration::from_secs(40)
1085        );
1086        assert_eq!(params.tcp_keep_alive.unwrap(), true);
1087        assert_eq!(params.ciphers.algorithms(), &["a-manella", "blowfish"]);
1088        assert_eq!(
1089            params.pubkey_accepted_algorithms.algorithms(),
1090            &["desu", "omar-crypt", "fast-omar-crypt"]
1091        );
1092
1093        // Query explicit all-hosts fallback options (`Host *`)
1094        assert_eq!(params.ca_signature_algorithms.algorithms(), &["random"]);
1095        assert_eq!(
1096            params.host_key_algorithms.algorithms(),
1097            &["luigi", "mario",]
1098        );
1099        assert_eq!(params.kex_algorithms.algorithms(), &["desu", "gigi",]);
1100        assert_eq!(params.mac.algorithms(), &["concorde"]);
1101        assert!(params.bind_address.is_none());
1102
1103        // Query 172.26.104.4, yielding cmdline overrides,
1104        // explicit `Host 192.168.*.* 172.26.*.* !192.168.1.30` options,
1105        // and all-hosts fallback options.
1106        let params_172_26_104_4 = config.query("172.26.104.4");
1107
1108        // cmdline overrides
1109        assert_eq!(params_172_26_104_4.add_keys_to_agent.unwrap(), true);
1110        assert_eq!(params_172_26_104_4.compression.unwrap(), true);
1111        assert_eq!(params_172_26_104_4.connection_attempts.unwrap(), 10);
1112        assert_eq!(
1113            params_172_26_104_4.connect_timeout.unwrap(),
1114            Duration::from_secs(60)
1115        );
1116        assert_eq!(params_172_26_104_4.tcp_keep_alive.unwrap(), true);
1117
1118        // all-hosts fallback options, merged with host-specific options
1119        assert_eq!(
1120            params_172_26_104_4.ca_signature_algorithms.algorithms(),
1121            &["random"]
1122        );
1123        assert_eq!(
1124            params_172_26_104_4.ciphers.algorithms(),
1125            &["a-manella", "blowfish",]
1126        );
1127        assert_eq!(params_172_26_104_4.mac.algorithms(), &["spyro", "deoxys"]); // use subconfig; defined before * macs
1128        assert_eq!(
1129            params_172_26_104_4.proxy_jump.unwrap(),
1130            &["jump.example.com"]
1131        ); // use subconfig; defined before * macs
1132        assert_eq!(
1133            params_172_26_104_4
1134                .pubkey_accepted_algorithms
1135                .algorithms()
1136                .is_empty(), // should have removed omar-crypt
1137            true
1138        );
1139        assert_eq!(
1140            params_172_26_104_4.bind_address.as_deref().unwrap(),
1141            "10.8.0.10"
1142        );
1143        assert_eq!(
1144            params_172_26_104_4.bind_interface.as_deref().unwrap(),
1145            "tun0"
1146        );
1147        assert_eq!(params_172_26_104_4.port.unwrap(), 2222);
1148        assert_eq!(
1149            params_172_26_104_4.identity_file.as_deref().unwrap(),
1150            vec![
1151                Path::new("/home/root/.ssh/pippo.key"),
1152                Path::new("/home/root/.ssh/pluto.key")
1153            ]
1154        );
1155        assert_eq!(params_172_26_104_4.user.as_deref().unwrap(), "omar");
1156
1157        // Query tostapane
1158        let params_tostapane = config.query("tostapane");
1159        assert_eq!(params_tostapane.compression.unwrap(), true); // it takes the first value defined, which is `yes`
1160        assert_eq!(params_tostapane.connection_attempts.unwrap(), 10);
1161        assert_eq!(
1162            params_tostapane.connect_timeout.unwrap(),
1163            Duration::from_secs(60)
1164        );
1165        assert_eq!(params_tostapane.tcp_keep_alive.unwrap(), true);
1166        assert_eq!(
1167            params_tostapane.remote_forward,
1168            vec![RemoteForward::new(RemoteForwardListen::Port(88), None)]
1169        );
1170        assert_eq!(params_tostapane.user.as_deref().unwrap(), "ciro-esposito");
1171
1172        // all-hosts fallback options
1173        assert_eq!(
1174            params_tostapane.ca_signature_algorithms.algorithms(),
1175            &["random"]
1176        );
1177        assert_eq!(
1178            params_tostapane.ciphers.algorithms(),
1179            &["a-manella", "blowfish",]
1180        );
1181        assert_eq!(
1182            params_tostapane.mac.algorithms(),
1183            vec!["spyro".to_string(), "deoxys".to_string(),]
1184        );
1185        assert_eq!(
1186            params_tostapane.proxy_jump.unwrap(),
1187            vec![
1188                "jump1.example.com".to_string(),
1189                "jump2.example.com".to_string(),
1190            ]
1191        );
1192        assert_eq!(
1193            params_tostapane.pubkey_accepted_algorithms.algorithms(),
1194            &["desu", "omar-crypt", "fast-omar-crypt"]
1195        );
1196
1197        // query 192.168.1.30
1198        let params_192_168_1_30 = config.query("192.168.1.30");
1199
1200        // host-specific options
1201        assert_eq!(params_192_168_1_30.user.as_deref().unwrap(), "nutellaro");
1202        assert_eq!(
1203            params_192_168_1_30.remote_forward,
1204            vec![RemoteForward::new(RemoteForwardListen::Port(123), None)]
1205        );
1206
1207        // cmdline overrides
1208        assert_eq!(params_192_168_1_30.compression.unwrap(), true);
1209        assert_eq!(params_192_168_1_30.connection_attempts.unwrap(), 10);
1210        assert_eq!(
1211            params_192_168_1_30.connect_timeout.unwrap(),
1212            Duration::from_secs(60)
1213        );
1214        assert_eq!(params_192_168_1_30.tcp_keep_alive.unwrap(), true);
1215
1216        // all-hosts fallback options
1217        assert_eq!(
1218            params_192_168_1_30.ca_signature_algorithms.algorithms(),
1219            &["random"]
1220        );
1221        assert_eq!(
1222            params_192_168_1_30.ciphers.algorithms(),
1223            &["a-manella", "blowfish"]
1224        );
1225        assert_eq!(params_192_168_1_30.mac.algorithms(), &["concorde"]);
1226        assert_eq!(
1227            params_192_168_1_30.pubkey_accepted_algorithms.algorithms(),
1228            &["desu", "omar-crypt", "fast-omar-crypt"]
1229        );
1230
1231        Ok(())
1232    }
1233
1234    #[test]
1235    fn should_allow_unknown_field() -> Result<(), SshParserError> {
1236        crate::test_log();
1237        let temp = create_ssh_config_with_unknown_fields();
1238        let file = File::open(temp.path()).expect("Failed to open tempfile");
1239        let mut reader = BufReader::new(file);
1240        let _config = SshConfig::default()
1241            .default_algorithms(DefaultAlgorithms::empty())
1242            .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)?;
1243
1244        Ok(())
1245    }
1246
1247    #[test]
1248    fn should_not_allow_unknown_field() {
1249        crate::test_log();
1250        let temp = create_ssh_config_with_unknown_fields();
1251        let file = File::open(temp.path()).expect("Failed to open tempfile");
1252        let mut reader = BufReader::new(file);
1253        assert!(matches!(
1254            SshConfig::default()
1255                .default_algorithms(DefaultAlgorithms::empty())
1256                .parse(&mut reader, ParseRule::STRICT)
1257                .unwrap_err(),
1258            SshParserError::UnknownField(..)
1259        ));
1260    }
1261
1262    #[test]
1263    fn should_store_unknown_fields() {
1264        crate::test_log();
1265        let temp = create_ssh_config_with_unknown_fields();
1266        let file = File::open(temp.path()).expect("Failed to open tempfile");
1267        let mut reader = BufReader::new(file);
1268        let config = SshConfig::default()
1269            .default_algorithms(DefaultAlgorithms::empty())
1270            .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
1271            .unwrap();
1272
1273        let host = config.query("cross-platform");
1274        assert_eq!(
1275            host.ignored_fields.get("Piropero").unwrap(),
1276            &vec![String::from("yes")]
1277        );
1278    }
1279
1280    #[test]
1281    fn should_parse_inversed_ssh_config() {
1282        crate::test_log();
1283        let temp = create_inverted_ssh_config();
1284        let file = File::open(temp.path()).expect("Failed to open tempfile");
1285        let mut reader = BufReader::new(file);
1286        let config = SshConfig::default()
1287            .default_algorithms(DefaultAlgorithms::empty())
1288            .parse(&mut reader, ParseRule::STRICT)
1289            .unwrap();
1290
1291        let home_dir = dirs::home_dir()
1292            .unwrap_or_else(|| PathBuf::from("~"))
1293            .to_string_lossy()
1294            .to_string();
1295
1296        let remote_host = config.query("remote-host");
1297
1298        // From `*-host`
1299        assert_eq!(
1300            remote_host.identity_file.unwrap()[0].as_path(),
1301            Path::new(format!("{home_dir}/.ssh/id_rsa_good").as_str()) // because it's the first in the file
1302        );
1303
1304        // From `remote-*`
1305        assert_eq!(remote_host.host_name.unwrap(), "hostname.com");
1306        assert_eq!(remote_host.user.unwrap(), "user");
1307
1308        // From `*`
1309        assert_eq!(
1310            remote_host.connect_timeout.unwrap(),
1311            Duration::from_secs(15)
1312        );
1313    }
1314
1315    #[test]
1316    fn should_parse_configuration_with_hosts() {
1317        crate::test_log();
1318        let temp = create_ssh_config_with_comments();
1319
1320        let file = File::open(temp.path()).expect("Failed to open tempfile");
1321        let mut reader = BufReader::new(file);
1322        let config = SshConfig::default()
1323            .default_algorithms(DefaultAlgorithms::empty())
1324            .parse(&mut reader, ParseRule::STRICT)
1325            .unwrap();
1326
1327        let hostname = config.query("cross-platform").host_name.unwrap();
1328        assert_eq!(&hostname, "hostname.com");
1329
1330        assert!(config.query("this").host_name.is_none());
1331    }
1332
1333    #[test]
1334    fn should_update_host_bind_address() -> Result<(), SshParserError> {
1335        crate::test_log();
1336        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1337        SshConfigParser::update_host(
1338            Field::BindAddress,
1339            vec![String::from("127.0.0.1")],
1340            &mut host,
1341            ParseRule::ALLOW_UNKNOWN_FIELDS,
1342            &DefaultAlgorithms::empty(),
1343        )?;
1344        assert_eq!(host.params.bind_address.as_deref().unwrap(), "127.0.0.1");
1345        Ok(())
1346    }
1347
1348    #[test]
1349    fn should_update_host_bind_interface() -> Result<(), SshParserError> {
1350        crate::test_log();
1351        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1352        SshConfigParser::update_host(
1353            Field::BindInterface,
1354            vec![String::from("aaa")],
1355            &mut host,
1356            ParseRule::ALLOW_UNKNOWN_FIELDS,
1357            &DefaultAlgorithms::empty(),
1358        )?;
1359        assert_eq!(host.params.bind_interface.as_deref().unwrap(), "aaa");
1360        Ok(())
1361    }
1362
1363    #[test]
1364    fn should_update_host_ca_signature_algos() -> Result<(), SshParserError> {
1365        crate::test_log();
1366        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1367        SshConfigParser::update_host(
1368            Field::CaSignatureAlgorithms,
1369            vec![String::from("a,b,c")],
1370            &mut host,
1371            ParseRule::ALLOW_UNKNOWN_FIELDS,
1372            &DefaultAlgorithms::empty(),
1373        )?;
1374        assert_eq!(
1375            host.params.ca_signature_algorithms.algorithms(),
1376            &["a", "b", "c"]
1377        );
1378        Ok(())
1379    }
1380
1381    #[test]
1382    fn should_update_host_certificate_file() -> Result<(), SshParserError> {
1383        crate::test_log();
1384        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1385        SshConfigParser::update_host(
1386            Field::CertificateFile,
1387            vec![String::from("/tmp/a.crt")],
1388            &mut host,
1389            ParseRule::ALLOW_UNKNOWN_FIELDS,
1390            &DefaultAlgorithms::empty(),
1391        )?;
1392        assert_eq!(
1393            host.params.certificate_file.as_deref().unwrap(),
1394            Path::new("/tmp/a.crt")
1395        );
1396        Ok(())
1397    }
1398
1399    #[test]
1400    fn should_update_host_ciphers() -> Result<(), SshParserError> {
1401        crate::test_log();
1402        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1403        SshConfigParser::update_host(
1404            Field::Ciphers,
1405            vec![String::from("a,b,c")],
1406            &mut host,
1407            ParseRule::ALLOW_UNKNOWN_FIELDS,
1408            &DefaultAlgorithms::empty(),
1409        )?;
1410        assert_eq!(host.params.ciphers.algorithms(), &["a", "b", "c"]);
1411        Ok(())
1412    }
1413
1414    #[test]
1415    fn should_update_host_compression() -> Result<(), SshParserError> {
1416        crate::test_log();
1417        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1418        SshConfigParser::update_host(
1419            Field::Compression,
1420            vec![String::from("yes")],
1421            &mut host,
1422            ParseRule::ALLOW_UNKNOWN_FIELDS,
1423            &DefaultAlgorithms::empty(),
1424        )?;
1425        assert_eq!(host.params.compression.unwrap(), true);
1426        Ok(())
1427    }
1428
1429    #[test]
1430    fn should_update_host_connection_attempts() -> Result<(), SshParserError> {
1431        crate::test_log();
1432        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1433        SshConfigParser::update_host(
1434            Field::ConnectionAttempts,
1435            vec![String::from("4")],
1436            &mut host,
1437            ParseRule::ALLOW_UNKNOWN_FIELDS,
1438            &DefaultAlgorithms::empty(),
1439        )?;
1440        assert_eq!(host.params.connection_attempts.unwrap(), 4);
1441        Ok(())
1442    }
1443
1444    #[test]
1445    fn should_update_host_connection_timeout() -> Result<(), SshParserError> {
1446        crate::test_log();
1447        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1448        SshConfigParser::update_host(
1449            Field::ConnectTimeout,
1450            vec![String::from("10")],
1451            &mut host,
1452            ParseRule::ALLOW_UNKNOWN_FIELDS,
1453            &DefaultAlgorithms::empty(),
1454        )?;
1455        assert_eq!(
1456            host.params.connect_timeout.unwrap(),
1457            Duration::from_secs(10)
1458        );
1459        Ok(())
1460    }
1461
1462    #[test]
1463    fn should_update_host_key_algorithms() -> Result<(), SshParserError> {
1464        crate::test_log();
1465        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1466        SshConfigParser::update_host(
1467            Field::HostKeyAlgorithms,
1468            vec![String::from("a,b,c")],
1469            &mut host,
1470            ParseRule::ALLOW_UNKNOWN_FIELDS,
1471            &DefaultAlgorithms::empty(),
1472        )?;
1473        assert_eq!(
1474            host.params.host_key_algorithms.algorithms(),
1475            &["a", "b", "c"]
1476        );
1477        Ok(())
1478    }
1479
1480    #[test]
1481    fn should_update_host_host_name() -> Result<(), SshParserError> {
1482        crate::test_log();
1483        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1484        SshConfigParser::update_host(
1485            Field::HostName,
1486            vec![String::from("192.168.1.1")],
1487            &mut host,
1488            ParseRule::ALLOW_UNKNOWN_FIELDS,
1489            &DefaultAlgorithms::empty(),
1490        )?;
1491        assert_eq!(host.params.host_name.as_deref().unwrap(), "192.168.1.1");
1492        Ok(())
1493    }
1494
1495    #[test]
1496    fn should_update_host_ignore_unknown() -> Result<(), SshParserError> {
1497        crate::test_log();
1498        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1499        SshConfigParser::update_host(
1500            Field::IgnoreUnknown,
1501            vec![String::from("a,b,c")],
1502            &mut host,
1503            ParseRule::ALLOW_UNKNOWN_FIELDS,
1504            &DefaultAlgorithms::empty(),
1505        )?;
1506        assert_eq!(
1507            host.params.ignore_unknown.as_deref().unwrap(),
1508            &["a", "b", "c"]
1509        );
1510        Ok(())
1511    }
1512
1513    #[test]
1514    fn should_update_kex_algorithms() -> Result<(), SshParserError> {
1515        crate::test_log();
1516        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1517        SshConfigParser::update_host(
1518            Field::KexAlgorithms,
1519            vec![String::from("a,b,c")],
1520            &mut host,
1521            ParseRule::ALLOW_UNKNOWN_FIELDS,
1522            &DefaultAlgorithms::empty(),
1523        )?;
1524        assert_eq!(host.params.kex_algorithms.algorithms(), &["a", "b", "c"]);
1525        Ok(())
1526    }
1527
1528    #[test]
1529    fn should_update_host_mac() -> Result<(), SshParserError> {
1530        crate::test_log();
1531        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1532        SshConfigParser::update_host(
1533            Field::Mac,
1534            vec![String::from("a,b,c")],
1535            &mut host,
1536            ParseRule::ALLOW_UNKNOWN_FIELDS,
1537            &DefaultAlgorithms::empty(),
1538        )?;
1539        assert_eq!(host.params.mac.algorithms(), &["a", "b", "c"]);
1540        Ok(())
1541    }
1542
1543    #[test]
1544    fn should_update_host_port() -> Result<(), SshParserError> {
1545        crate::test_log();
1546        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1547        SshConfigParser::update_host(
1548            Field::Port,
1549            vec![String::from("2222")],
1550            &mut host,
1551            ParseRule::ALLOW_UNKNOWN_FIELDS,
1552            &DefaultAlgorithms::empty(),
1553        )?;
1554        assert_eq!(host.params.port.unwrap(), 2222);
1555        Ok(())
1556    }
1557
1558    #[test]
1559    fn should_update_host_pubkey_accepted_algos() -> Result<(), SshParserError> {
1560        crate::test_log();
1561        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1562        SshConfigParser::update_host(
1563            Field::PubkeyAcceptedAlgorithms,
1564            vec![String::from("a,b,c")],
1565            &mut host,
1566            ParseRule::ALLOW_UNKNOWN_FIELDS,
1567            &DefaultAlgorithms::empty(),
1568        )?;
1569        assert_eq!(
1570            host.params.pubkey_accepted_algorithms.algorithms(),
1571            &["a", "b", "c"]
1572        );
1573        Ok(())
1574    }
1575
1576    #[test]
1577    fn should_update_host_pubkey_authentication() -> Result<(), SshParserError> {
1578        crate::test_log();
1579        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1580        SshConfigParser::update_host(
1581            Field::PubkeyAuthentication,
1582            vec![String::from("yes")],
1583            &mut host,
1584            ParseRule::ALLOW_UNKNOWN_FIELDS,
1585            &DefaultAlgorithms::empty(),
1586        )?;
1587        assert_eq!(host.params.pubkey_authentication.unwrap(), true);
1588        Ok(())
1589    }
1590
1591    #[test]
1592    fn should_update_host_remote_forward() -> Result<(), SshParserError> {
1593        crate::test_log();
1594        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1595        SshConfigParser::update_host(
1596            Field::RemoteForward,
1597            vec![String::from("3005"), String::from("localhost:80")],
1598            &mut host,
1599            ParseRule::ALLOW_UNKNOWN_FIELDS,
1600            &DefaultAlgorithms::empty(),
1601        )?;
1602        SshConfigParser::update_host(
1603            Field::RemoteForward,
1604            vec![String::from("/tmp/remote.sock")],
1605            &mut host,
1606            ParseRule::ALLOW_UNKNOWN_FIELDS,
1607            &DefaultAlgorithms::empty(),
1608        )?;
1609        assert_eq!(
1610            host.params.remote_forward,
1611            vec![
1612                RemoteForward::new(
1613                    RemoteForwardListen::Port(3005),
1614                    Some(RemoteForwardDestination::Host {
1615                        host: "localhost".to_string(),
1616                        port: 80,
1617                    }),
1618                ),
1619                RemoteForward::new(
1620                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote.sock")),
1621                    None,
1622                ),
1623            ]
1624        );
1625        Ok(())
1626    }
1627
1628    #[test]
1629    fn should_accumulate_remote_forwards_from_matching_hosts() -> Result<(), SshParserError> {
1630        let config = r#"
1631Host test
1632    RemoteForward 8080 localhost:80
1633
1634Host *
1635    RemoteForward "/tmp/remote socket" "/tmp/local socket"
1636"#;
1637        let mut reader = BufReader::new(config.as_bytes());
1638        let config = SshConfig::default()
1639            .default_algorithms(DefaultAlgorithms::empty())
1640            .parse(&mut reader, ParseRule::STRICT)?;
1641
1642        assert_eq!(
1643            config.query("test").remote_forward,
1644            vec![
1645                RemoteForward::new(
1646                    RemoteForwardListen::Port(8080),
1647                    Some(RemoteForwardDestination::Host {
1648                        host: "localhost".to_string(),
1649                        port: 80,
1650                    }),
1651                ),
1652                RemoteForward::new(
1653                    RemoteForwardListen::UnixSocket(PathBuf::from("/tmp/remote socket")),
1654                    Some(RemoteForwardDestination::UnixSocket(PathBuf::from(
1655                        "/tmp/local socket",
1656                    ))),
1657                ),
1658            ]
1659        );
1660        Ok(())
1661    }
1662
1663    #[test]
1664    fn should_update_host_server_alive_interval() -> Result<(), SshParserError> {
1665        crate::test_log();
1666        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1667        SshConfigParser::update_host(
1668            Field::ServerAliveInterval,
1669            vec![String::from("40")],
1670            &mut host,
1671            ParseRule::ALLOW_UNKNOWN_FIELDS,
1672            &DefaultAlgorithms::empty(),
1673        )?;
1674        assert_eq!(
1675            host.params.server_alive_interval.unwrap(),
1676            Duration::from_secs(40)
1677        );
1678        Ok(())
1679    }
1680
1681    #[test]
1682    fn should_update_host_tcp_keep_alive() -> Result<(), SshParserError> {
1683        crate::test_log();
1684        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1685        SshConfigParser::update_host(
1686            Field::TcpKeepAlive,
1687            vec![String::from("no")],
1688            &mut host,
1689            ParseRule::ALLOW_UNKNOWN_FIELDS,
1690            &DefaultAlgorithms::empty(),
1691        )?;
1692        assert_eq!(host.params.tcp_keep_alive.unwrap(), false);
1693        Ok(())
1694    }
1695
1696    #[test]
1697    fn should_update_host_user() -> Result<(), SshParserError> {
1698        crate::test_log();
1699        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1700        SshConfigParser::update_host(
1701            Field::User,
1702            vec![String::from("pippo")],
1703            &mut host,
1704            ParseRule::ALLOW_UNKNOWN_FIELDS,
1705            &DefaultAlgorithms::empty(),
1706        )?;
1707        assert_eq!(host.params.user.as_deref().unwrap(), "pippo");
1708        Ok(())
1709    }
1710
1711    #[test]
1712    fn should_not_update_host_if_unknown() -> Result<(), SshParserError> {
1713        crate::test_log();
1714        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1715        let result = SshConfigParser::update_host(
1716            Field::PasswordAuthentication,
1717            vec![String::from("yes")],
1718            &mut host,
1719            ParseRule::ALLOW_UNKNOWN_FIELDS,
1720            &DefaultAlgorithms::empty(),
1721        );
1722
1723        match result {
1724            Ok(_) | Err(SshParserError::UnsupportedField(_, _)) => Ok(()),
1725            Err(e) => Err(e),
1726        }?;
1727
1728        assert_eq!(host.params, HostParams::new(&DefaultAlgorithms::empty()));
1729        Ok(())
1730    }
1731
1732    #[test]
1733    fn should_update_host_if_unsupported() -> Result<(), SshParserError> {
1734        crate::test_log();
1735        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
1736        let result = SshConfigParser::update_host(
1737            Field::PasswordAuthentication,
1738            vec![String::from("yes")],
1739            &mut host,
1740            ParseRule::ALLOW_UNKNOWN_FIELDS,
1741            &DefaultAlgorithms::empty(),
1742        );
1743
1744        match result {
1745            Err(SshParserError::UnsupportedField(field, _)) => {
1746                assert_eq!(field, "passwordauthentication");
1747                Ok(())
1748            }
1749            Ok(_) => Ok(()),
1750            Err(e) => Err(e),
1751        }?;
1752
1753        assert_eq!(host.params, HostParams::new(&DefaultAlgorithms::empty()));
1754        Ok(())
1755    }
1756
1757    #[test]
1758    fn should_tokenize_line() -> Result<(), SshParserError> {
1759        crate::test_log();
1760        assert_eq!(
1761            SshConfigParser::tokenize_line("HostName 192.168.*.* 172.26.*.*")?,
1762            (
1763                Field::HostName,
1764                vec![String::from("192.168.*.*"), String::from("172.26.*.*")]
1765            )
1766        );
1767        // Tokenize line with spaces
1768        assert_eq!(
1769            SshConfigParser::tokenize_line(
1770                "      HostName        192.168.*.*        172.26.*.*        "
1771            )?,
1772            (
1773                Field::HostName,
1774                vec![String::from("192.168.*.*"), String::from("172.26.*.*")]
1775            )
1776        );
1777        Ok(())
1778    }
1779
1780    #[test]
1781    fn should_not_tokenize_line() {
1782        crate::test_log();
1783        assert!(matches!(
1784            SshConfigParser::tokenize_line("Omar     yes").unwrap_err(),
1785            SshParserError::UnknownField(..)
1786        ));
1787    }
1788
1789    #[test]
1790    fn should_fail_parsing_field() {
1791        crate::test_log();
1792
1793        assert!(matches!(
1794            SshConfigParser::tokenize_line("                  ").unwrap_err(),
1795            SshParserError::MissingArgument
1796        ));
1797    }
1798
1799    #[test]
1800    fn should_fail_on_mismatched_quotes() {
1801        crate::test_log();
1802
1803        // Unclosed opening quote
1804        assert!(matches!(
1805            SshConfigParser::tokenize_line(r#"Hostname "example.com"#).unwrap_err(),
1806            SshParserError::InvalidQuotes
1807        ));
1808        // Unexpected closing quote (no opening)
1809        assert!(matches!(
1810            SshConfigParser::tokenize_line(r#"Hostname example.com""#).unwrap_err(),
1811            SshParserError::InvalidQuotes
1812        ));
1813        // Quote in middle, unclosed
1814        assert!(matches!(
1815            SshConfigParser::tokenize_line(r#"Hostname foo "bar"#).unwrap_err(),
1816            SshParserError::InvalidQuotes
1817        ));
1818    }
1819
1820    #[test]
1821    fn should_parse_boolean() -> Result<(), SshParserError> {
1822        crate::test_log();
1823        assert_eq!(
1824            SshConfigParser::parse_boolean(vec![String::from("yes")])?,
1825            true
1826        );
1827        assert_eq!(
1828            SshConfigParser::parse_boolean(vec![String::from("no")])?,
1829            false
1830        );
1831        Ok(())
1832    }
1833
1834    #[test]
1835    fn should_parse_boolean_true_false_case_insensitive() -> Result<(), SshParserError> {
1836        crate::test_log();
1837        for value in ["true", "True", "YES", "Yes"] {
1838            assert!(SshConfigParser::parse_boolean(vec![value.to_string()])?);
1839        }
1840        for value in ["false", "False", "NO", "No"] {
1841            assert!(!SshConfigParser::parse_boolean(vec![value.to_string()])?);
1842        }
1843        Ok(())
1844    }
1845
1846    #[test]
1847    fn should_fail_parsing_boolean() {
1848        crate::test_log();
1849        assert!(matches!(
1850            SshConfigParser::parse_boolean(vec!["boh".to_string()]).unwrap_err(),
1851            SshParserError::ExpectedBoolean
1852        ));
1853        assert!(matches!(
1854            SshConfigParser::parse_boolean(vec![]).unwrap_err(),
1855            SshParserError::MissingArgument
1856        ));
1857    }
1858
1859    #[test]
1860    fn should_parse_algos() -> Result<(), SshParserError> {
1861        crate::test_log();
1862        assert_eq!(
1863            SshConfigParser::parse_algos(vec![String::from("a,b,c,d")])?,
1864            AlgorithmsRule::Set(vec![
1865                "a".to_string(),
1866                "b".to_string(),
1867                "c".to_string(),
1868                "d".to_string(),
1869            ])
1870        );
1871
1872        assert_eq!(
1873            SshConfigParser::parse_algos(vec![String::from("a")])?,
1874            AlgorithmsRule::Set(vec!["a".to_string()])
1875        );
1876
1877        assert_eq!(
1878            SshConfigParser::parse_algos(vec![String::from("+a,b")])?,
1879            AlgorithmsRule::Append(vec!["a".to_string(), "b".to_string()])
1880        );
1881
1882        Ok(())
1883    }
1884
1885    #[test]
1886    fn should_parse_comma_separated_list() -> Result<(), SshParserError> {
1887        crate::test_log();
1888        assert_eq!(
1889            SshConfigParser::parse_comma_separated_list(vec![String::from("a,b,c,d")])?,
1890            vec![
1891                "a".to_string(),
1892                "b".to_string(),
1893                "c".to_string(),
1894                "d".to_string(),
1895            ]
1896        );
1897        assert_eq!(
1898            SshConfigParser::parse_comma_separated_list(vec![String::from("a")])?,
1899            vec!["a".to_string()]
1900        );
1901        Ok(())
1902    }
1903
1904    #[test]
1905    fn should_fail_parsing_comma_separated_list() {
1906        crate::test_log();
1907        assert!(matches!(
1908            SshConfigParser::parse_comma_separated_list(vec![]).unwrap_err(),
1909            SshParserError::MissingArgument
1910        ));
1911    }
1912
1913    #[test]
1914    fn should_parse_duration() -> Result<(), SshParserError> {
1915        crate::test_log();
1916        assert_eq!(
1917            SshConfigParser::parse_duration(vec![String::from("60")])?,
1918            Duration::from_secs(60)
1919        );
1920        Ok(())
1921    }
1922
1923    #[test]
1924    fn should_fail_parsing_duration() {
1925        crate::test_log();
1926        assert!(matches!(
1927            SshConfigParser::parse_duration(vec![String::from("AAA")]).unwrap_err(),
1928            SshParserError::ExpectedUnsigned
1929        ));
1930        assert!(matches!(
1931            SshConfigParser::parse_duration(vec![]).unwrap_err(),
1932            SshParserError::MissingArgument
1933        ));
1934    }
1935
1936    #[test]
1937    fn should_parse_host() -> Result<(), SshParserError> {
1938        crate::test_log();
1939        assert_eq!(
1940            SshConfigParser::parse_host(vec![
1941                String::from("192.168.*.*"),
1942                String::from("!192.168.1.1"),
1943                String::from("172.26.104.*"),
1944                String::from("!172.26.104.10"),
1945            ])?,
1946            vec![
1947                HostClause::new(String::from("192.168.*.*"), false),
1948                HostClause::new(String::from("192.168.1.1"), true),
1949                HostClause::new(String::from("172.26.104.*"), false),
1950                HostClause::new(String::from("172.26.104.10"), true),
1951            ]
1952        );
1953        Ok(())
1954    }
1955
1956    #[test]
1957    fn should_fail_parsing_host() {
1958        crate::test_log();
1959        assert!(matches!(
1960            SshConfigParser::parse_host(vec![]).unwrap_err(),
1961            SshParserError::MissingArgument
1962        ));
1963    }
1964
1965    #[test]
1966    fn should_parse_path() -> Result<(), SshParserError> {
1967        crate::test_log();
1968        assert_eq!(
1969            SshConfigParser::parse_path(vec![String::from("/tmp/a.txt")])?,
1970            PathBuf::from("/tmp/a.txt")
1971        );
1972        Ok(())
1973    }
1974
1975    #[test]
1976    fn should_parse_path_and_resolve_tilde() -> Result<(), SshParserError> {
1977        crate::test_log();
1978        let mut expected = dirs::home_dir().unwrap();
1979        expected.push(".ssh/id_dsa");
1980        assert_eq!(
1981            SshConfigParser::parse_path(vec![String::from("~/.ssh/id_dsa")])?,
1982            expected
1983        );
1984        Ok(())
1985    }
1986
1987    #[test]
1988    fn should_parse_path_list() -> Result<(), SshParserError> {
1989        crate::test_log();
1990        assert_eq!(
1991            SshConfigParser::parse_path_list(vec![
1992                String::from("/tmp/a.txt"),
1993                String::from("/tmp/b.txt")
1994            ])?,
1995            vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")]
1996        );
1997        Ok(())
1998    }
1999
2000    #[test]
2001    fn should_fail_parse_path_list() {
2002        crate::test_log();
2003        assert!(matches!(
2004            SshConfigParser::parse_path_list(vec![]).unwrap_err(),
2005            SshParserError::MissingArgument
2006        ));
2007    }
2008
2009    #[test]
2010    fn should_fail_parsing_path() {
2011        crate::test_log();
2012        assert!(matches!(
2013            SshConfigParser::parse_path(vec![]).unwrap_err(),
2014            SshParserError::MissingArgument
2015        ));
2016    }
2017
2018    #[test]
2019    fn should_parse_port() -> Result<(), SshParserError> {
2020        crate::test_log();
2021        assert_eq!(SshConfigParser::parse_port(vec![String::from("22")])?, 22);
2022        Ok(())
2023    }
2024
2025    #[test]
2026    fn should_fail_parsing_port() {
2027        crate::test_log();
2028        assert!(matches!(
2029            SshConfigParser::parse_port(vec![String::from("1234567")]).unwrap_err(),
2030            SshParserError::ExpectedPort
2031        ));
2032        assert!(matches!(
2033            SshConfigParser::parse_port(vec![]).unwrap_err(),
2034            SshParserError::MissingArgument
2035        ));
2036    }
2037
2038    #[test]
2039    fn should_parse_string() -> Result<(), SshParserError> {
2040        crate::test_log();
2041        assert_eq!(
2042            SshConfigParser::parse_string(vec![String::from("foobar")])?,
2043            String::from("foobar")
2044        );
2045        Ok(())
2046    }
2047
2048    #[test]
2049    fn should_fail_parsing_string() {
2050        crate::test_log();
2051        assert!(matches!(
2052            SshConfigParser::parse_string(vec![]).unwrap_err(),
2053            SshParserError::MissingArgument
2054        ));
2055    }
2056
2057    #[test]
2058    fn should_parse_unsigned() -> Result<(), SshParserError> {
2059        crate::test_log();
2060        assert_eq!(
2061            SshConfigParser::parse_unsigned(vec![String::from("43")])?,
2062            43
2063        );
2064        Ok(())
2065    }
2066
2067    #[test]
2068    fn should_fail_parsing_unsigned() {
2069        crate::test_log();
2070        assert!(matches!(
2071            SshConfigParser::parse_unsigned(vec![String::from("abc")]).unwrap_err(),
2072            SshParserError::ExpectedUnsigned
2073        ));
2074        assert!(matches!(
2075            SshConfigParser::parse_unsigned(vec![]).unwrap_err(),
2076            SshParserError::MissingArgument
2077        ));
2078    }
2079
2080    #[test]
2081    fn should_strip_comments() {
2082        crate::test_log();
2083
2084        assert_eq!(
2085            SshConfigParser::strip_comments("host my_host # this is my fav host").as_str(),
2086            "host my_host "
2087        );
2088        assert_eq!(
2089            SshConfigParser::strip_comments("# this is a comment").as_str(),
2090            ""
2091        );
2092    }
2093
2094    #[test]
2095    fn should_preserve_hash_inside_quoted_strings() {
2096        crate::test_log();
2097
2098        // Hash inside quotes should NOT be treated as a comment
2099        assert_eq!(
2100            SshConfigParser::strip_comments(r#"Ciphers "aes256-ctr # not a comment""#).as_str(),
2101            r#"Ciphers "aes256-ctr # not a comment""#
2102        );
2103        // Hash after closing quote should be treated as a comment
2104        assert_eq!(
2105            SshConfigParser::strip_comments(r#"Ciphers "aes256-ctr" # this is a comment"#).as_str(),
2106            r#"Ciphers "aes256-ctr" "#
2107        );
2108        // Multiple quoted sections
2109        assert_eq!(
2110            SshConfigParser::strip_comments(r#"ProxyCommand "ssh # hop" -W "dest # host""#)
2111                .as_str(),
2112            r#"ProxyCommand "ssh # hop" -W "dest # host""#
2113        );
2114        // Comment after multiple quoted sections
2115        assert_eq!(
2116            SshConfigParser::strip_comments(r#"Key "val1" "val2" # comment"#).as_str(),
2117            r#"Key "val1" "val2" "#
2118        );
2119    }
2120
2121    #[test]
2122    fn test_should_parse_config_with_quotes_and_eq() {
2123        crate::test_log();
2124
2125        let config = create_ssh_config_with_quotes_and_eq();
2126        let file = File::open(config.path()).expect("Failed to open tempfile");
2127        let mut reader = BufReader::new(file);
2128
2129        let config = SshConfig::default()
2130            .default_algorithms(DefaultAlgorithms::empty())
2131            .parse(&mut reader, ParseRule::STRICT)
2132            .expect("Failed to parse config");
2133
2134        let params = config.query("foo");
2135
2136        // connect timeout is 15
2137        assert_eq!(
2138            params.connect_timeout.expect("unspec connect timeout"),
2139            Duration::from_secs(15)
2140        );
2141        assert_eq!(
2142            params
2143                .ignore_unknown
2144                .as_deref()
2145                .expect("unspec ignore unknown"),
2146            &["Pippo", "Pluto"]
2147        );
2148        assert_eq!(
2149            params
2150                .ciphers
2151                .algorithms()
2152                .iter()
2153                .map(|x| x.as_str())
2154                .collect::<Vec<&str>>(),
2155            &["Pepperoni Pizza", "Margherita Pizza", "Hawaiian Pizza"]
2156        );
2157        assert_eq!(
2158            params
2159                .mac
2160                .algorithms()
2161                .iter()
2162                .map(|x| x.as_str())
2163                .collect::<Vec<&str>>(),
2164            &["Pasta Carbonara", "Pasta con tonno"]
2165        );
2166    }
2167
2168    #[test]
2169    fn test_should_resolve_absolute_include_path() {
2170        crate::test_log();
2171
2172        let expected = PathBuf::from("/tmp/config.local");
2173
2174        let s = "/tmp/config.local";
2175        let resolved = PathBuf::from(SshConfigParser::resolve_include_path(s));
2176        assert_eq!(resolved, expected);
2177    }
2178
2179    #[test]
2180    fn test_should_resolve_relative_include_path() {
2181        crate::test_log();
2182
2183        let expected = dirs::home_dir()
2184            .unwrap_or_else(|| PathBuf::from("~"))
2185            .join(".ssh")
2186            .join("config.local");
2187
2188        let s = "config.local";
2189        let resolved = PathBuf::from(SshConfigParser::resolve_include_path(s));
2190        assert_eq!(resolved, expected);
2191    }
2192
2193    #[test]
2194    fn test_should_resolve_include_path_with_tilde() {
2195        let p = "~/.ssh/config.local";
2196        let resolved = SshConfigParser::resolve_include_path(p);
2197        let mut expected = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
2198        expected.push(".ssh");
2199        expected.push("config.local");
2200        assert_eq!(PathBuf::from(resolved), expected);
2201    }
2202
2203    #[test]
2204    fn should_fail_parsing_algos_missing_arg() {
2205        crate::test_log();
2206        assert!(matches!(
2207            SshConfigParser::parse_algos(vec![]).unwrap_err(),
2208            SshParserError::MissingArgument
2209        ));
2210    }
2211
2212    #[test]
2213    fn should_parse_duration_zero() {
2214        crate::test_log();
2215        assert_eq!(
2216            SshConfigParser::parse_duration(vec![String::from("0")]).unwrap(),
2217            Duration::from_secs(0)
2218        );
2219    }
2220
2221    #[test]
2222    fn should_parse_port_boundary() {
2223        crate::test_log();
2224        // Minimum valid port
2225        assert_eq!(
2226            SshConfigParser::parse_port(vec![String::from("1")]).unwrap(),
2227            1
2228        );
2229        // Maximum valid port
2230        assert_eq!(
2231            SshConfigParser::parse_port(vec![String::from("65535")]).unwrap(),
2232            65535
2233        );
2234    }
2235
2236    #[test]
2237    fn should_update_host_add_keys_to_agent() {
2238        crate::test_log();
2239        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2240        SshConfigParser::update_host(
2241            Field::AddKeysToAgent,
2242            vec![String::from("yes")],
2243            &mut host,
2244            ParseRule::STRICT,
2245            &DefaultAlgorithms::empty(),
2246        )
2247        .unwrap();
2248        assert_eq!(host.params.add_keys_to_agent.unwrap(), true);
2249
2250        let mut host2 = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2251        SshConfigParser::update_host(
2252            Field::AddKeysToAgent,
2253            vec![String::from("no")],
2254            &mut host2,
2255            ParseRule::STRICT,
2256            &DefaultAlgorithms::empty(),
2257        )
2258        .unwrap();
2259        assert_eq!(host2.params.add_keys_to_agent.unwrap(), false);
2260    }
2261
2262    #[test]
2263    fn should_update_host_forward_agent() {
2264        crate::test_log();
2265        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2266        SshConfigParser::update_host(
2267            Field::ForwardAgent,
2268            vec![String::from("yes")],
2269            &mut host,
2270            ParseRule::STRICT,
2271            &DefaultAlgorithms::empty(),
2272        )
2273        .unwrap();
2274        assert_eq!(host.params.forward_agent.unwrap(), true);
2275    }
2276
2277    #[test]
2278    fn should_update_host_proxy_jump() {
2279        crate::test_log();
2280        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2281        SshConfigParser::update_host(
2282            Field::ProxyJump,
2283            vec![String::from("jump1,jump2,jump3")],
2284            &mut host,
2285            ParseRule::STRICT,
2286            &DefaultAlgorithms::empty(),
2287        )
2288        .unwrap();
2289        assert_eq!(
2290            host.params.proxy_jump.unwrap(),
2291            vec![
2292                "jump1".to_string(),
2293                "jump2".to_string(),
2294                "jump3".to_string()
2295            ]
2296        );
2297    }
2298
2299    #[test]
2300    fn should_update_host_identity_file() {
2301        crate::test_log();
2302        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2303        SshConfigParser::update_host(
2304            Field::IdentityFile,
2305            vec![String::from("/path/to/key1"), String::from("/path/to/key2")],
2306            &mut host,
2307            ParseRule::STRICT,
2308            &DefaultAlgorithms::empty(),
2309        )
2310        .unwrap();
2311        assert_eq!(
2312            host.params.identity_file.unwrap(),
2313            vec![
2314                PathBuf::from("/path/to/key1"),
2315                PathBuf::from("/path/to/key2")
2316            ]
2317        );
2318    }
2319
2320    #[test]
2321    fn test_should_allow_and_append_multiple_identity_files_directives() {
2322        crate::test_log();
2323        let config = r##"
2324Host test
2325    IdentityFile /path/to/key1 /path/to/key2
2326    IdentityFile /path/to/key3
2327"##;
2328        let mut reader = BufReader::new(config.as_bytes());
2329        let config = SshConfig::default()
2330            .default_algorithms(DefaultAlgorithms::empty())
2331            .parse(&mut reader, ParseRule::STRICT)
2332            .expect("Failed to parse config");
2333
2334        let params = config.query("test");
2335        assert_eq!(
2336            params.identity_file.as_ref().unwrap(),
2337            &vec![
2338                PathBuf::from("/path/to/key1"),
2339                PathBuf::from("/path/to/key2"),
2340                PathBuf::from("/path/to/key3"),
2341            ]
2342        );
2343    }
2344
2345    #[test]
2346    fn test_should_accumulate_identity_files_across_host_blocks() {
2347        crate::test_log();
2348        let config = r##"
2349Host test
2350    IdentityFile /path/to/specific_key
2351
2352Host *
2353    IdentityFile /path/to/default_key
2354"##;
2355        let mut reader = BufReader::new(config.as_bytes());
2356        let config = SshConfig::default()
2357            .default_algorithms(DefaultAlgorithms::empty())
2358            .parse(&mut reader, ParseRule::STRICT)
2359            .expect("Failed to parse config");
2360
2361        let params = config.query("test");
2362        // Both identity files should be present: specific first, then default
2363        assert_eq!(
2364            params.identity_file.as_ref().unwrap(),
2365            &vec![
2366                PathBuf::from("/path/to/specific_key"),
2367                PathBuf::from("/path/to/default_key"),
2368            ]
2369        );
2370    }
2371
2372    #[test]
2373    fn should_store_unsupported_fields_when_allowed() {
2374        crate::test_log();
2375
2376        let config = r##"
2377Host test
2378    PasswordAuthentication yes
2379"##;
2380        let mut reader = BufReader::new(config.as_bytes());
2381        let config = SshConfig::default()
2382            .default_algorithms(DefaultAlgorithms::empty())
2383            .parse(&mut reader, ParseRule::ALLOW_UNSUPPORTED_FIELDS)
2384            .unwrap();
2385
2386        let params = config.query("test");
2387        assert!(
2388            params
2389                .unsupported_fields
2390                .contains_key("passwordauthentication")
2391        );
2392    }
2393
2394    #[test]
2395    fn should_tokenize_line_with_equals_separator() {
2396        crate::test_log();
2397        let (field, args) = SshConfigParser::tokenize_line("HostName=example.com").unwrap();
2398        assert_eq!(field, Field::HostName);
2399        assert_eq!(args, vec!["example.com".to_string()]);
2400    }
2401
2402    #[test]
2403    fn should_tokenize_line_with_quoted_args() {
2404        crate::test_log();
2405        let (field, args) =
2406            SshConfigParser::tokenize_line("Ciphers \"aes256-ctr,aes128-ctr\"").unwrap();
2407        assert_eq!(field, Field::Ciphers);
2408        assert_eq!(args, vec!["aes256-ctr,aes128-ctr".to_string()]);
2409    }
2410
2411    #[test]
2412    fn should_tokenize_line_with_equals_and_quoted_args() {
2413        crate::test_log();
2414        let (field, args) =
2415            SshConfigParser::tokenize_line("Ciphers=\"aes256-ctr,aes128-ctr\"").unwrap();
2416        assert_eq!(field, Field::Ciphers);
2417        assert_eq!(args, vec!["aes256-ctr,aes128-ctr".to_string()]);
2418    }
2419
2420    #[test]
2421    fn should_unescape_quoted_args() {
2422        crate::test_log();
2423
2424        // Test escaped double quote: \" -> "
2425        let (field, args) =
2426            SshConfigParser::tokenize_line(r#"HostName "gateway\"server""#).unwrap();
2427        assert_eq!(field, Field::HostName);
2428        assert_eq!(args, vec![r#"gateway"server"#.to_string()]);
2429
2430        // Test escaped backslash: \\ -> \
2431        let (field, args) = SshConfigParser::tokenize_line(r#"HostName "path\\to\\host""#).unwrap();
2432        assert_eq!(field, Field::HostName);
2433        assert_eq!(args, vec![r#"path\to\host"#.to_string()]);
2434
2435        // Test escaped single quote: \' -> '
2436        let (field, args) = SshConfigParser::tokenize_line(r#"HostName "it\'s a test""#).unwrap();
2437        assert_eq!(field, Field::HostName);
2438        assert_eq!(args, vec!["it's a test".to_string()]);
2439
2440        // Test multiple escape sequences combined
2441        let (field, args) =
2442            SshConfigParser::tokenize_line(r#"HostName "say \"hello\" and \\go""#).unwrap();
2443        assert_eq!(field, Field::HostName);
2444        assert_eq!(args, vec![r#"say "hello" and \go"#.to_string()]);
2445
2446        // Test unrecognized escape sequence (backslash preserved)
2447        let (field, args) = SshConfigParser::tokenize_line(r#"HostName "test\nvalue""#).unwrap();
2448        assert_eq!(field, Field::HostName);
2449        assert_eq!(args, vec![r#"test\nvalue"#.to_string()]);
2450    }
2451
2452    #[test]
2453    fn should_tokenize_line_setenv() -> Result<(), SshParserError> {
2454        crate::test_log();
2455        assert_eq!(
2456            SshConfigParser::tokenize_line(
2457                r#"SetEnv TEST_1=Test1 TEST_2="Test 2" TEST_3="Test \"3\"" TEST_4=Test"4""#
2458            )?,
2459            (
2460                Field::SetEnv,
2461                vec![
2462                    r#"TEST_1=Test1"#.to_owned(),
2463                    r#"TEST_2="Test 2""#.to_owned(),
2464                    r#"TEST_3="Test \"3\"""#.to_owned(),
2465                    r#"TEST_4=Test"4""#.to_owned(),
2466                ]
2467            )
2468        );
2469        Ok(())
2470    }
2471
2472    #[test]
2473    fn should_count_unescaped_quotes() {
2474        crate::test_log();
2475
2476        // No quotes
2477        assert_eq!(SshConfigParser::count_unescaped_quotes("hello"), 0);
2478
2479        // Simple unescaped quotes
2480        assert_eq!(SshConfigParser::count_unescaped_quotes(r#""hello""#), 2);
2481
2482        // Escaped quotes should not be counted
2483        assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\"hello\""#), 0);
2484
2485        // Mixed escaped and unescaped
2486        assert_eq!(
2487            SshConfigParser::count_unescaped_quotes(r#""hello\"world""#),
2488            2
2489        );
2490
2491        // Escaped backslash before quote (quote is unescaped)
2492        assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\\""#), 1);
2493
2494        // Empty string
2495        assert_eq!(SshConfigParser::count_unescaped_quotes(""), 0);
2496
2497        // Only escaped quote
2498        assert_eq!(SshConfigParser::count_unescaped_quotes(r#"\""#), 0);
2499    }
2500
2501    #[test]
2502    fn should_detect_ends_with_unescaped_quote() {
2503        crate::test_log();
2504
2505        // Ends with unescaped quote
2506        assert!(SshConfigParser::ends_with_unescaped_quote(r#""hello""#));
2507
2508        // Ends with escaped quote (odd backslashes)
2509        assert!(!SshConfigParser::ends_with_unescaped_quote(r#""hello\""#));
2510
2511        // Ends with escaped backslash then unescaped quote
2512        assert!(SshConfigParser::ends_with_unescaped_quote(r#""hello\\""#));
2513
2514        // Ends with three backslashes then quote (escaped)
2515        assert!(!SshConfigParser::ends_with_unescaped_quote(r#""hello\\\""#));
2516
2517        // Doesn't end with quote at all
2518        assert!(!SshConfigParser::ends_with_unescaped_quote("hello"));
2519
2520        // Single quote
2521        assert!(SshConfigParser::ends_with_unescaped_quote(r#"""#));
2522
2523        // Single escaped quote
2524        assert!(!SshConfigParser::ends_with_unescaped_quote(r#"\""#));
2525    }
2526
2527    #[test]
2528    fn should_unescape_string() {
2529        crate::test_log();
2530
2531        // Escaped double quote
2532        assert_eq!(
2533            SshConfigParser::unescape_string(r#"hello\"world"#),
2534            r#"hello"world"#
2535        );
2536
2537        // Escaped backslash
2538        assert_eq!(
2539            SshConfigParser::unescape_string(r#"path\\to\\file"#),
2540            r#"path\to\file"#
2541        );
2542
2543        // Escaped single quote
2544        assert_eq!(SshConfigParser::unescape_string(r#"it\'s"#), "it's");
2545
2546        // Multiple escape sequences
2547        assert_eq!(
2548            SshConfigParser::unescape_string(r#"say \"hi\" and \\go"#),
2549            r#"say "hi" and \go"#
2550        );
2551
2552        // Unrecognized escape (backslash preserved)
2553        assert_eq!(
2554            SshConfigParser::unescape_string(r#"test\nvalue"#),
2555            r#"test\nvalue"#
2556        );
2557
2558        // No escapes
2559        assert_eq!(SshConfigParser::unescape_string("plain text"), "plain text");
2560
2561        // Empty string
2562        assert_eq!(SshConfigParser::unescape_string(""), "");
2563
2564        // Trailing backslash (no char to escape)
2565        assert_eq!(SshConfigParser::unescape_string(r#"test\"#), r#"test\"#);
2566
2567        // Double escaped backslash
2568        assert_eq!(SshConfigParser::unescape_string(r#"\\\\"#), r#"\\"#);
2569    }
2570
2571    #[test]
2572    fn should_parse_host_with_single_pattern() {
2573        crate::test_log();
2574        let result = SshConfigParser::parse_host(vec![String::from("example.com")]).unwrap();
2575        assert_eq!(result.len(), 1);
2576        assert_eq!(result[0].pattern, "example.com");
2577        assert!(!result[0].negated);
2578    }
2579
2580    #[test]
2581    fn should_parse_host_with_exclamation_in_pattern() {
2582        crate::test_log();
2583
2584        // Pattern with ! in the middle should be treated as literal (non-negated)
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        // Negated pattern with ! in the pattern itself
2591        let result = SshConfigParser::parse_host(vec![String::from("!host!name")]).unwrap();
2592        assert_eq!(result.len(), 1);
2593        assert_eq!(result[0].pattern, "host!name");
2594        assert!(result[0].negated);
2595
2596        // Multiple ! after the negation prefix should be preserved
2597        let result = SshConfigParser::parse_host(vec![String::from("!a!b!c")]).unwrap();
2598        assert_eq!(result.len(), 1);
2599        assert_eq!(result[0].pattern, "a!b!c");
2600        assert!(result[0].negated);
2601
2602        // Only leading ! is negation, rest is literal
2603        let result = SshConfigParser::parse_host(vec![String::from("a!b")]).unwrap();
2604        assert_eq!(result.len(), 1);
2605        assert_eq!(result[0].pattern, "a!b");
2606        assert!(!result[0].negated);
2607    }
2608
2609    #[cfg(target_os = "macos")]
2610    #[test]
2611    fn should_update_host_use_keychain() {
2612        crate::test_log();
2613        let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty()));
2614        SshConfigParser::update_host(
2615            Field::UseKeychain,
2616            vec![String::from("yes")],
2617            &mut host,
2618            ParseRule::STRICT,
2619            &DefaultAlgorithms::empty(),
2620        )
2621        .unwrap();
2622        assert_eq!(host.params.use_keychain.unwrap(), true);
2623    }
2624
2625    fn create_ssh_config_with_quotes_and_eq() -> NamedTempFile {
2626        let mut tmpfile: tempfile::NamedTempFile =
2627            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2628        let config = r##"
2629# ssh config
2630# written by veeso
2631
2632
2633# I put a comment here just to annoy
2634
2635IgnoreUnknown=Pippo,Pluto
2636ConnectTimeout = 15
2637Ciphers "Pepperoni Pizza,Margherita Pizza,Hawaiian Pizza"
2638Macs="Pasta Carbonara,Pasta con tonno"
2639"##;
2640        tmpfile.write_all(config.as_bytes()).unwrap();
2641        tmpfile
2642    }
2643
2644    fn create_ssh_config() -> NamedTempFile {
2645        let mut tmpfile: tempfile::NamedTempFile =
2646            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2647        let config = r##"
2648# ssh config
2649# written by veeso
2650
2651
2652        # I put a comment here just to annoy
2653
2654IgnoreUnknown Pippo,Pluto
2655
2656Compression yes
2657ConnectionAttempts          10
2658ConnectTimeout 60
2659ServerAliveInterval 40
2660TcpKeepAlive    yes
2661Ciphers     +a-manella,blowfish
2662
2663# Let's start defining some hosts
2664
2665Host 192.168.*.*    172.26.*.*      !192.168.1.30
2666    User    omar
2667    # ForwardX11 is actually not supported; I just want to see that it wont' fail parsing
2668    ForwardX11    yes
2669    BindAddress     10.8.0.10
2670    BindInterface   tun0
2671    AddKeysToAgent yes
2672    Ciphers     +coi-piedi,cazdecan,triestin-stretto
2673    IdentityFile    /home/root/.ssh/pippo.key /home/root/.ssh/pluto.key
2674    Macs     spyro,deoxys
2675    Port 2222
2676    PubkeyAcceptedAlgorithms    -omar-crypt
2677    ProxyJump jump.example.com
2678
2679Host tostapane
2680    User    ciro-esposito
2681    HostName    192.168.24.32
2682    RemoteForward   88
2683    Compression no
2684    Pippo yes
2685    Pluto 56
2686    ProxyJump jump1.example.com,jump2.example.com
2687    Macs +spyro,deoxys
2688
2689Host    192.168.1.30
2690    User    nutellaro
2691    RemoteForward   123
2692
2693Host *
2694    CaSignatureAlgorithms   random
2695    HostKeyAlgorithms   luigi,mario
2696    KexAlgorithms   desu,gigi
2697    Macs     concorde
2698    PubkeyAcceptedAlgorithms    desu,omar-crypt,fast-omar-crypt
2699"##;
2700        tmpfile.write_all(config.as_bytes()).unwrap();
2701        tmpfile
2702    }
2703
2704    fn create_inverted_ssh_config() -> NamedTempFile {
2705        let mut tmpfile: tempfile::NamedTempFile =
2706            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2707        let config = r##"
2708Host *-host
2709    IdentityFile ~/.ssh/id_rsa_good
2710
2711Host remote-*
2712    HostName hostname.com
2713    User user
2714    IdentityFile ~/.ssh/id_rsa_bad
2715
2716Host *
2717    ConnectTimeout 15
2718    IdentityFile ~/.ssh/id_rsa_ugly
2719    "##;
2720        tmpfile.write_all(config.as_bytes()).unwrap();
2721        tmpfile
2722    }
2723
2724    fn create_ssh_config_with_comments() -> NamedTempFile {
2725        let mut tmpfile: tempfile::NamedTempFile =
2726            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2727        let config = r##"
2728Host cross-platform # this is my fav host
2729    HostName hostname.com
2730    User user
2731    IdentityFile ~/.ssh/id_rsa_good
2732
2733Host *
2734    AddKeysToAgent yes
2735    IdentityFile ~/.ssh/id_rsa_bad
2736    "##;
2737        tmpfile.write_all(config.as_bytes()).unwrap();
2738        tmpfile
2739    }
2740
2741    fn create_ssh_config_with_unknown_fields() -> NamedTempFile {
2742        let mut tmpfile: tempfile::NamedTempFile =
2743            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
2744        let config = r##"
2745Host cross-platform # this is my fav host
2746    HostName hostname.com
2747    User user
2748    IdentityFile ~/.ssh/id_rsa_good
2749    Piropero yes
2750
2751Host *
2752    AddKeysToAgent yes
2753    IdentityFile ~/.ssh/id_rsa_bad
2754    "##;
2755        tmpfile.write_all(config.as_bytes()).unwrap();
2756        tmpfile
2757    }
2758
2759    #[test]
2760    fn test_should_parse_config_with_include() {
2761        crate::test_log();
2762
2763        let config = create_include_config();
2764        let file = File::open(config.config.path()).expect("Failed to open tempfile");
2765        let mut reader = BufReader::new(file);
2766
2767        let config = SshConfig::default()
2768            .default_algorithms(DefaultAlgorithms::empty())
2769            .parse(&mut reader, ParseRule::STRICT)
2770            .expect("Failed to parse config");
2771
2772        let default_params = config.query("unknown-host");
2773        // verify default params
2774        assert_eq!(
2775            default_params.connect_timeout.unwrap(),
2776            Duration::from_secs(60) // first read
2777        );
2778        assert_eq!(
2779            default_params.server_alive_interval.unwrap(),
2780            Duration::from_secs(40) // first read
2781        );
2782        assert_eq!(default_params.tcp_keep_alive.unwrap(), true);
2783        assert_eq!(default_params.ciphers.algorithms().is_empty(), true);
2784        assert_eq!(
2785            default_params.ignore_unknown.as_deref().unwrap(),
2786            &["Pippo", "Pluto"]
2787        );
2788        assert_eq!(default_params.compression.unwrap(), true);
2789        assert_eq!(default_params.connection_attempts.unwrap(), 10);
2790        assert_eq!(default_params.port, Some(345));
2791
2792        // verify include 1 overwrites the default value
2793        let glob_params = config.query("192.168.1.1");
2794        assert_eq!(
2795            glob_params.connect_timeout.unwrap(),
2796            Duration::from_secs(60)
2797        );
2798        assert_eq!(
2799            glob_params.server_alive_interval.unwrap(),
2800            Duration::from_secs(40) // first read
2801        );
2802        assert_eq!(glob_params.tcp_keep_alive.unwrap(), true);
2803        assert_eq!(glob_params.ciphers.algorithms().is_empty(), true);
2804
2805        // verify tostapane
2806        let tostapane_params = config.query("tostapane");
2807        assert_eq!(
2808            tostapane_params.connect_timeout.unwrap(),
2809            Duration::from_secs(60) // first read
2810        );
2811        assert_eq!(
2812            tostapane_params.server_alive_interval.unwrap(),
2813            Duration::from_secs(40) // first read
2814        );
2815        assert_eq!(tostapane_params.tcp_keep_alive.unwrap(), true);
2816        // verify ciphers
2817        assert_eq!(
2818            tostapane_params.ciphers.algorithms(),
2819            &[
2820                "a-manella",
2821                "blowfish",
2822                "coi-piedi",
2823                "cazdecan",
2824                "triestin-stretto"
2825            ]
2826        );
2827
2828        // verify included host (microwave)
2829        let microwave_params = config.query("microwave");
2830        assert_eq!(
2831            microwave_params.connect_timeout.unwrap(),
2832            Duration::from_secs(60) // (not) updated in inc4
2833        );
2834        assert_eq!(
2835            microwave_params.server_alive_interval.unwrap(),
2836            Duration::from_secs(40) // (not) updated in inc4
2837        );
2838        assert_eq!(
2839            microwave_params.port.unwrap(),
2840            345 // updated in inc4
2841        );
2842        assert_eq!(microwave_params.tcp_keep_alive.unwrap(), true);
2843        assert_eq!(microwave_params.ciphers.algorithms().is_empty(), true);
2844        assert_eq!(microwave_params.user.as_deref().unwrap(), "mario-rossi");
2845        assert_eq!(
2846            microwave_params.host_name.as_deref().unwrap(),
2847            "192.168.24.33"
2848        );
2849        assert_eq!(
2850            microwave_params.remote_forward,
2851            vec![RemoteForward::new(RemoteForwardListen::Port(88), None)]
2852        );
2853        assert_eq!(microwave_params.compression.unwrap(), true);
2854
2855        // verify included host (fridge)
2856        let fridge_params = config.query("fridge");
2857        assert_eq!(
2858            fridge_params.connect_timeout.unwrap(),
2859            Duration::from_secs(60)
2860        ); // default
2861        assert_eq!(
2862            fridge_params.server_alive_interval.unwrap(),
2863            Duration::from_secs(40)
2864        ); // default
2865        assert_eq!(fridge_params.tcp_keep_alive.unwrap(), true);
2866        assert_eq!(fridge_params.ciphers.algorithms().is_empty(), true);
2867        assert_eq!(fridge_params.user.as_deref().unwrap(), "luigi-verdi");
2868        assert_eq!(fridge_params.host_name.as_deref().unwrap(), "192.168.24.34");
2869    }
2870
2871    #[test]
2872    fn should_restore_parent_scope_after_include() -> Result<(), SshParserError> {
2873        let mut included = NamedTempFile::new().expect("Failed to create included file");
2874        included.write_all(b"Host foo\n    HostName foo.example\n")?;
2875
2876        let config = format!(
2877            "Include {included}\nUser alice\n",
2878            included = included.path().display(),
2879        );
2880        let mut reader = BufReader::new(config.as_bytes());
2881        let config = SshConfig::default()
2882            .default_algorithms(DefaultAlgorithms::empty())
2883            .parse(&mut reader, ParseRule::STRICT)?;
2884
2885        let default = config.query("github.com");
2886        assert_eq!(default.user.as_deref(), Some("alice"));
2887        assert!(default.host_name.is_none());
2888
2889        let foo = config.query("foo");
2890        assert_eq!(foo.user.as_deref(), Some("alice"));
2891        assert_eq!(foo.host_name.as_deref(), Some("foo.example"));
2892
2893        Ok(())
2894    }
2895
2896    #[test]
2897    fn should_keep_included_values_before_parent_fallbacks() -> Result<(), SshParserError> {
2898        let mut included = NamedTempFile::new().expect("Failed to create included file");
2899        included.write_all(b"Host foo\n    User bob\n")?;
2900
2901        let config = format!(
2902            "Include {included}\nUser alice\n",
2903            included = included.path().display(),
2904        );
2905        let mut reader = BufReader::new(config.as_bytes());
2906        let config = SshConfig::default()
2907            .default_algorithms(DefaultAlgorithms::empty())
2908            .parse(&mut reader, ParseRule::STRICT)?;
2909
2910        assert_eq!(config.query("foo").user.as_deref(), Some("bob"));
2911        assert_eq!(config.query("github.com").user.as_deref(), Some("alice"));
2912
2913        Ok(())
2914    }
2915
2916    #[test]
2917    fn should_require_enclosing_and_included_host_scopes() -> Result<(), SshParserError> {
2918        let mut included = NamedTempFile::new().expect("Failed to create included file");
2919        included.write_all(b"Host app*\n    HostName included.internal\n")?;
2920
2921        let config = format!(
2922            concat!(
2923                "Host *.example.com !app-blocked.example.com\n",
2924                "    Include {included}\n",
2925                "    Port 2222\n",
2926                "Host *\n",
2927                "    User fallback\n",
2928            ),
2929            included = included.path().display(),
2930        );
2931        let mut reader = BufReader::new(config.as_bytes());
2932        let config = SshConfig::default()
2933            .default_algorithms(DefaultAlgorithms::empty())
2934            .parse(&mut reader, ParseRule::STRICT)?;
2935
2936        let allowed = config.query("app1.example.com");
2937        assert_eq!(allowed.host_name.as_deref(), Some("included.internal"));
2938        assert_eq!(allowed.port, Some(2222));
2939        assert_eq!(allowed.user.as_deref(), Some("fallback"));
2940
2941        let negated = config.query("app-blocked.example.com");
2942        assert!(negated.host_name.is_none());
2943        assert!(negated.port.is_none());
2944        assert_eq!(negated.user.as_deref(), Some("fallback"));
2945
2946        let outside = config.query("app1.example.net");
2947        assert!(outside.host_name.is_none());
2948        assert!(outside.port.is_none());
2949        assert_eq!(outside.user.as_deref(), Some("fallback"));
2950
2951        assert!(
2952            config
2953                .intersecting_hosts("app-blocked.example.com")
2954                .all(|host| host.params.host_name.as_deref() != Some("included.internal"))
2955        );
2956
2957        Ok(())
2958    }
2959
2960    #[test]
2961    fn should_restore_scope_between_consecutive_includes() -> Result<(), SshParserError> {
2962        let mut first = NamedTempFile::new().expect("Failed to create first include");
2963        first.write_all(b"Host bar\n    HostName bar.internal\n")?;
2964        let mut second = NamedTempFile::new().expect("Failed to create second include");
2965        second.write_all(b"Port 2222\n")?;
2966
2967        let config = format!(
2968            concat!(
2969                "Host foo\n",
2970                "    Include {first}\n",
2971                "    Include {second}\n",
2972                "    User alice\n",
2973            ),
2974            first = first.path().display(),
2975            second = second.path().display(),
2976        );
2977        let mut reader = BufReader::new(config.as_bytes());
2978        let config = SshConfig::default()
2979            .default_algorithms(DefaultAlgorithms::empty())
2980            .parse(&mut reader, ParseRule::STRICT)?;
2981
2982        let foo = config.query("foo");
2983        assert_eq!(foo.port, Some(2222));
2984        assert_eq!(foo.user.as_deref(), Some("alice"));
2985
2986        let bar = config.query("bar");
2987        assert!(bar.host_name.is_none());
2988        assert!(bar.port.is_none());
2989        assert!(bar.user.is_none());
2990
2991        Ok(())
2992    }
2993
2994    #[test]
2995    fn should_preserve_all_nested_include_scopes() -> Result<(), SshParserError> {
2996        let mut inner = NamedTempFile::new().expect("Failed to create inner include");
2997        inner.write_all(b"User nested\n")?;
2998
2999        let mut outer = NamedTempFile::new().expect("Failed to create outer include");
3000        writeln!(
3001            outer,
3002            "Host app*\n    Include {inner}",
3003            inner = inner.path().display(),
3004        )?;
3005
3006        let config = format!(
3007            "Host *.example.com\n    Include {outer}\n",
3008            outer = outer.path().display(),
3009        );
3010        let mut reader = BufReader::new(config.as_bytes());
3011        let config = SshConfig::default()
3012            .default_algorithms(DefaultAlgorithms::empty())
3013            .parse(&mut reader, ParseRule::STRICT)?;
3014
3015        assert_eq!(
3016            config.query("app1.example.com").user.as_deref(),
3017            Some("nested")
3018        );
3019        assert!(config.query("app1.example.net").user.is_none());
3020        assert!(config.query("other.example.com").user.is_none());
3021
3022        Ok(())
3023    }
3024
3025    #[test]
3026    fn should_include_every_path_argument() -> Result<(), SshParserError> {
3027        let mut first = NamedTempFile::new().expect("Failed to create first include");
3028        first.write_all(b"Host foo\n    HostName foo.internal\n")?;
3029        let mut second = NamedTempFile::new().expect("Failed to create second include");
3030        second.write_all(b"Host bar\n    HostName bar.internal\n")?;
3031
3032        let config = format!(
3033            "Include {first} {second}\n",
3034            first = first.path().display(),
3035            second = second.path().display(),
3036        );
3037        let mut reader = BufReader::new(config.as_bytes());
3038        let config = SshConfig::default()
3039            .default_algorithms(DefaultAlgorithms::empty())
3040            .parse(&mut reader, ParseRule::STRICT)?;
3041
3042        assert_eq!(
3043            config.query("foo").host_name.as_deref(),
3044            Some("foo.internal")
3045        );
3046        assert_eq!(
3047            config.query("bar").host_name.as_deref(),
3048            Some("bar.internal")
3049        );
3050
3051        Ok(())
3052    }
3053
3054    #[test]
3055    fn should_take_first_obtained_value_across_include_arguments() -> Result<(), SshParserError> {
3056        let mut first = NamedTempFile::new().expect("Failed to create first include");
3057        first.write_all(b"Host foo\n    User first\n")?;
3058        let mut second = NamedTempFile::new().expect("Failed to create second include");
3059        second.write_all(b"Host foo\n    User second\n    Port 2222\n")?;
3060
3061        let config = format!(
3062            "Include {first} {second}\n",
3063            first = first.path().display(),
3064            second = second.path().display(),
3065        );
3066        let mut reader = BufReader::new(config.as_bytes());
3067        let config = SshConfig::default()
3068            .default_algorithms(DefaultAlgorithms::empty())
3069            .parse(&mut reader, ParseRule::STRICT)?;
3070
3071        assert_eq!(config.query("foo").user.as_deref(), Some("first"));
3072        assert_eq!(config.query("foo").port, Some(2222));
3073
3074        let config = format!(
3075            "Include {second} {first}\n",
3076            first = first.path().display(),
3077            second = second.path().display(),
3078        );
3079        let mut reader = BufReader::new(config.as_bytes());
3080        let config = SshConfig::default()
3081            .default_algorithms(DefaultAlgorithms::empty())
3082            .parse(&mut reader, ParseRule::STRICT)?;
3083
3084        assert_eq!(config.query("foo").user.as_deref(), Some("second"));
3085
3086        Ok(())
3087    }
3088
3089    #[test]
3090    fn should_expand_glob_in_each_include_argument() -> Result<(), SshParserError> {
3091        let conf_dir = tempfile::tempdir()?;
3092        std::fs::write(
3093            conf_dir.path().join("10-a.conf"),
3094            "Host foo\n    User glob-a\n",
3095        )?;
3096        std::fs::write(
3097            conf_dir.path().join("20-b.conf"),
3098            "Host foo\n    User glob-b\n    HostName b.internal\n",
3099        )?;
3100        std::fs::write(
3101            conf_dir.path().join("other.txt"),
3102            "Host foo\n    Port 9999\n",
3103        )?;
3104        let extra_dir = tempfile::tempdir()?;
3105        std::fs::write(
3106            extra_dir.path().join("x.conf"),
3107            "Host bar\n    HostName bar.internal\n",
3108        )?;
3109
3110        let config = format!(
3111            "Include {conf}/*.conf {extra}/*.conf\n",
3112            conf = conf_dir.path().display(),
3113            extra = extra_dir.path().display(),
3114        );
3115        let mut reader = BufReader::new(config.as_bytes());
3116        let config = SshConfig::default()
3117            .default_algorithms(DefaultAlgorithms::empty())
3118            .parse(&mut reader, ParseRule::STRICT)?;
3119
3120        assert_eq!(config.query("foo").user.as_deref(), Some("glob-a"));
3121        assert_eq!(config.query("foo").host_name.as_deref(), Some("b.internal"));
3122        assert!(config.query("foo").port.is_none());
3123        assert_eq!(
3124            config.query("bar").host_name.as_deref(),
3125            Some("bar.internal")
3126        );
3127
3128        Ok(())
3129    }
3130
3131    #[test]
3132    fn should_skip_unmatched_include_arguments() -> Result<(), SshParserError> {
3133        let missing_dir = tempfile::tempdir()?;
3134        let missing = missing_dir.path().join("absent").join("*.conf");
3135        let mut present = NamedTempFile::new().expect("Failed to create include");
3136        present.write_all(b"Host foo\n    HostName foo.internal\n")?;
3137
3138        let config = format!(
3139            "Include {missing} {present} {missing}\n",
3140            missing = missing.display(),
3141            present = present.path().display(),
3142        );
3143        let mut reader = BufReader::new(config.as_bytes());
3144        let config = SshConfig::default()
3145            .default_algorithms(DefaultAlgorithms::empty())
3146            .parse(&mut reader, ParseRule::STRICT)?;
3147
3148        assert_eq!(
3149            config.query("foo").host_name.as_deref(),
3150            Some("foo.internal")
3151        );
3152
3153        Ok(())
3154    }
3155
3156    #[test]
3157    fn should_scope_every_include_argument_to_enclosing_host() -> Result<(), SshParserError> {
3158        let mut first = NamedTempFile::new().expect("Failed to create first include");
3159        first.write_all(b"Host app*\n    HostName first.internal\n")?;
3160        let mut second = NamedTempFile::new().expect("Failed to create second include");
3161        second.write_all(b"Port 2222\nHost db*\n    HostName second.internal\n")?;
3162
3163        let config = format!(
3164            "Host *.example.com\n    Include {first} {second}\n    User alice\nHost *\n    User fallback\n",
3165            first = first.path().display(),
3166            second = second.path().display(),
3167        );
3168        let mut reader = BufReader::new(config.as_bytes());
3169        let config = SshConfig::default()
3170            .default_algorithms(DefaultAlgorithms::empty())
3171            .parse(&mut reader, ParseRule::STRICT)?;
3172
3173        let app = config.query("app1.example.com");
3174        assert_eq!(app.host_name.as_deref(), Some("first.internal"));
3175        assert_eq!(app.port, Some(2222));
3176        assert_eq!(app.user.as_deref(), Some("alice"));
3177
3178        let db = config.query("db1.example.com");
3179        assert_eq!(db.host_name.as_deref(), Some("second.internal"));
3180        assert_eq!(db.port, Some(2222));
3181        assert_eq!(db.user.as_deref(), Some("alice"));
3182
3183        let outside = config.query("db1.example.net");
3184        assert!(outside.host_name.is_none());
3185        assert!(outside.port.is_none());
3186        assert_eq!(outside.user.as_deref(), Some("fallback"));
3187
3188        Ok(())
3189    }
3190
3191    #[allow(dead_code)]
3192    struct ConfigWithInclude {
3193        config: NamedTempFile,
3194        inc1: NamedTempFile,
3195        inc2: NamedTempFile,
3196        inc3: NamedTempFile,
3197        inc4: NamedTempFile,
3198    }
3199
3200    fn create_include_config() -> ConfigWithInclude {
3201        let mut config_file: tempfile::NamedTempFile =
3202            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3203        let mut inc1_file: tempfile::NamedTempFile =
3204            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3205        let mut inc2_file: tempfile::NamedTempFile =
3206            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3207        let mut inc3_file: tempfile::NamedTempFile =
3208            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3209        let mut inc4_file: tempfile::NamedTempFile =
3210            tempfile::NamedTempFile::new().expect("Failed to create tempfile");
3211
3212        let config = format!(
3213            r##"
3214# ssh config
3215# written by veeso
3216
3217
3218        # I put a comment here just to annoy
3219
3220IgnoreUnknown Pippo,Pluto
3221
3222Compression yes
3223ConnectionAttempts          10
3224ConnectTimeout 60
3225ServerAliveInterval 40
3226Include {inc1}
3227
3228# Let's start defining some hosts
3229
3230Host tostapane
3231    User    ciro-esposito
3232    HostName    192.168.24.32
3233    RemoteForward   88
3234    Compression no
3235    # Ignore unknown fields should be inherited from the global section
3236    Pippo yes
3237    Pluto 56
3238    Include {inc2}
3239
3240Host *
3241Include {inc3}
3242Include {inc4}
3243"##,
3244            inc1 = inc1_file.path().display(),
3245            inc2 = inc2_file.path().display(),
3246            inc3 = inc3_file.path().display(),
3247            inc4 = inc4_file.path().display(),
3248        );
3249        config_file.write_all(config.as_bytes()).unwrap();
3250
3251        // write include 1
3252        let inc1 = r##"
3253        ConnectTimeout 60
3254        ServerAliveInterval 60
3255        TcpKeepAlive    yes
3256        "##;
3257        inc1_file.write_all(inc1.as_bytes()).unwrap();
3258
3259        // write include 2
3260        let inc2 = r##"
3261        ConnectTimeout 180
3262        ServerAliveInterval 180
3263        Ciphers     +a-manella,blowfish,coi-piedi,cazdecan,triestin-stretto
3264        "##;
3265        inc2_file.write_all(inc2.as_bytes()).unwrap();
3266
3267        // write include 3 with host directive
3268        let inc3 = r##"
3269Host microwave
3270    User    mario-rossi
3271    HostName    192.168.24.33
3272    RemoteForward   88
3273    Compression no
3274    # Ignore unknown fields should be inherited from the global section
3275    Pippo yes
3276    Pluto 56
3277"##;
3278        inc3_file.write_all(inc3.as_bytes()).unwrap();
3279
3280        // write include 4 which updates a param from microwave and then create a new host
3281        let inc4 = r##"
3282    # Update microwave
3283    ServerAliveInterval 30
3284    Port 345
3285
3286# Force microwave update (it won't work)
3287Host microwave
3288    ConnectTimeout 30
3289
3290Host fridge
3291    User    luigi-verdi
3292    HostName    192.168.24.34
3293    RemoteForward   88
3294    Compression no
3295"##;
3296        inc4_file.write_all(inc4.as_bytes()).unwrap();
3297
3298        ConfigWithInclude {
3299            config: config_file,
3300            inc1: inc1_file,
3301            inc2: inc2_file,
3302            inc3: inc3_file,
3303            inc4: inc4_file,
3304        }
3305    }
3306}