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