Skip to main content

ssh2_config/
parser.rs

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