Skip to main content

ssh2_config/
parser.rs

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