wezterm_ssh/
config.rs

1//! Parse an ssh_config(5) formatted config file
2use regex::{Captures, Regex};
3use std::collections::BTreeMap;
4use std::path::Path;
5
6pub type ConfigMap = BTreeMap<String, String>;
7
8/// A Pattern in a `Host` list
9#[derive(Debug, PartialEq, Eq, Clone)]
10struct Pattern {
11    negated: bool,
12    pattern: String,
13}
14
15/// Compile a glob style pattern string into a regex pattern string
16fn wildcard_to_pattern(s: &str) -> String {
17    let mut pattern = String::new();
18    pattern.push('^');
19    for c in s.chars() {
20        if c == '*' {
21            pattern.push_str(".*");
22        } else if c == '?' {
23            pattern.push('.');
24        } else {
25            let s = regex::escape(&c.to_string());
26            pattern.push_str(&s);
27        }
28    }
29    pattern.push('$');
30    pattern
31}
32
33impl Pattern {
34    /// Returns true if this pattern matches the provided hostname
35    fn match_text(&self, hostname: &str) -> bool {
36        if let Ok(re) = Regex::new(&self.pattern) {
37            re.is_match(hostname)
38        } else {
39            false
40        }
41    }
42
43    fn new(text: &str, negated: bool) -> Self {
44        Self {
45            pattern: wildcard_to_pattern(text),
46            negated,
47        }
48    }
49
50    /// Returns true if hostname matches the
51    /// condition specified by a list of patterns
52    fn match_group(hostname: &str, patterns: &[Self]) -> bool {
53        for pat in patterns {
54            if pat.match_text(hostname) {
55                // We got a definitive name match.
56                // If it was an exlusion then we've been told
57                // that this doesn't really match, otherwise
58                // we got one that we were looking for
59                return !pat.negated;
60            }
61        }
62        false
63    }
64}
65
66#[derive(Clone, Eq, PartialEq, Debug)]
67enum Criteria {
68    Host(Vec<Pattern>),
69    Exec(String),
70    OriginalHost(Vec<Pattern>),
71    User(Vec<Pattern>),
72    LocalUser(Vec<Pattern>),
73    All,
74}
75
76#[derive(Copy, Clone, Eq, PartialEq, Debug)]
77enum Context {
78    FirstPass,
79    Canonical,
80    Final,
81}
82
83/// Represents `Host pattern,list` stanza in the config,
84/// and the options that it logically contains
85#[derive(Debug, PartialEq, Eq, Clone)]
86struct MatchGroup {
87    criteria: Vec<Criteria>,
88    context: Context,
89    options: ConfigMap,
90}
91
92impl MatchGroup {
93    fn is_match(&self, hostname: &str, user: &str, local_user: &str, context: Context) -> bool {
94        if self.context != context {
95            return false;
96        }
97        for c in &self.criteria {
98            match c {
99                Criteria::Host(patterns) => {
100                    if !Pattern::match_group(hostname, patterns) {
101                        return false;
102                    }
103                }
104                Criteria::Exec(_) => {
105                    log::warn!("Match Exec is not implemented");
106                }
107                Criteria::OriginalHost(patterns) => {
108                    if !Pattern::match_group(hostname, patterns) {
109                        return false;
110                    }
111                }
112                Criteria::User(patterns) => {
113                    if !Pattern::match_group(user, patterns) {
114                        return false;
115                    }
116                }
117                Criteria::LocalUser(patterns) => {
118                    if !Pattern::match_group(local_user, patterns) {
119                        return false;
120                    }
121                }
122                Criteria::All => {
123                    // Always matches
124                }
125            }
126        }
127        true
128    }
129}
130
131/// Holds the ordered set of parsed options.
132/// The config file semantics are that the first matching value
133/// for a given option takes precedence
134#[derive(Debug, PartialEq, Eq, Clone)]
135struct ParsedConfigFile {
136    /// options that appeared before any `Host` stanza
137    options: ConfigMap,
138    /// options inside a `Host` stanza
139    groups: Vec<MatchGroup>,
140}
141
142impl ParsedConfigFile {
143    fn parse(s: &str, cwd: Option<&Path>) -> Self {
144        let mut options = ConfigMap::new();
145        let mut groups = vec![];
146
147        Self::parse_impl(s, cwd, &mut options, &mut groups);
148
149        Self { options, groups }
150    }
151
152    fn do_include(
153        pattern: &str,
154        cwd: Option<&Path>,
155        options: &mut ConfigMap,
156        groups: &mut Vec<MatchGroup>,
157    ) {
158        match filenamegen::Glob::new(&pattern) {
159            Ok(g) => {
160                match cwd
161                    .as_ref()
162                    .map(|p| p.to_path_buf())
163                    .or_else(|| std::env::current_dir().ok())
164                {
165                    Some(cwd) => {
166                        for path in g.walk(&cwd) {
167                            let path = if path.is_absolute() {
168                                path
169                            } else {
170                                cwd.join(path)
171                            };
172                            match std::fs::read_to_string(&path) {
173                                Ok(data) => {
174                                    Self::parse_impl(&data, Some(&cwd), options, groups);
175                                }
176                                Err(err) => {
177                                    log::error!(
178                                        "error expanding `Include {}`: unable to open {}: {:#}",
179                                        pattern,
180                                        path.display(),
181                                        err
182                                    );
183                                }
184                            }
185                        }
186                    }
187                    None => {
188                        log::error!(
189                            "error expanding `Include {}`: unable to determine cwd",
190                            pattern
191                        );
192                    }
193                }
194            }
195            Err(err) => {
196                log::error!("error expanding `Include {}`: {:#}", pattern, err);
197            }
198        }
199    }
200
201    fn parse_impl(
202        s: &str,
203        cwd: Option<&Path>,
204        options: &mut ConfigMap,
205        groups: &mut Vec<MatchGroup>,
206    ) {
207        for line in s.lines() {
208            let line = line.trim();
209            if line.is_empty() || line.starts_with('#') {
210                continue;
211            }
212
213            if let Some(sep) = line
214                .find('=')
215                .or_else(|| line.find(|c: char| c.is_whitespace()))
216            {
217                let (k, v) = line.split_at(sep);
218                let k = k.trim().to_lowercase();
219                let v = v[1..].trim();
220
221                let v = if v.starts_with('"') && v.ends_with('"') {
222                    &v[1..v.len() - 1]
223                } else {
224                    v
225                };
226
227                fn parse_pattern_list(v: &str) -> Vec<Pattern> {
228                    let mut patterns = vec![];
229                    for p in v.split(',') {
230                        let p = p.trim();
231                        if p.starts_with('!') {
232                            patterns.push(Pattern::new(&p[1..], true));
233                        } else {
234                            patterns.push(Pattern::new(p, false));
235                        }
236                    }
237                    patterns
238                }
239                fn parse_whitespace_pattern_list(v: &str) -> Vec<Pattern> {
240                    let mut patterns = vec![];
241                    for p in v.split_ascii_whitespace() {
242                        let p = p.trim();
243                        if p.starts_with('!') {
244                            patterns.push(Pattern::new(&p[1..], true));
245                        } else {
246                            patterns.push(Pattern::new(p, false));
247                        }
248                    }
249                    patterns
250                }
251
252                if k == "include" {
253                    Self::do_include(v, cwd, options, groups);
254                    continue;
255                }
256
257                if k == "host" {
258                    let patterns = parse_whitespace_pattern_list(v);
259                    groups.push(MatchGroup {
260                        criteria: vec![Criteria::Host(patterns)],
261                        options: ConfigMap::new(),
262                        context: Context::FirstPass,
263                    });
264                    continue;
265                }
266
267                if k == "match" {
268                    let mut criteria = vec![];
269                    let mut context = Context::FirstPass;
270
271                    let mut tokens = v.split_ascii_whitespace();
272
273                    while let Some(cname) = tokens.next() {
274                        match cname.to_lowercase().as_str() {
275                            "all" => {
276                                criteria.push(Criteria::All);
277                            }
278                            "canonical" => {
279                                context = Context::Canonical;
280                            }
281                            "final" => {
282                                context = Context::Final;
283                            }
284                            "exec" => {
285                                criteria.push(Criteria::Exec(
286                                    tokens.next().unwrap_or("false").to_string(),
287                                ));
288                            }
289                            "host" => {
290                                criteria.push(Criteria::Host(parse_pattern_list(
291                                    tokens.next().unwrap_or(""),
292                                )));
293                            }
294                            "originalhost" => {
295                                criteria.push(Criteria::OriginalHost(parse_pattern_list(
296                                    tokens.next().unwrap_or(""),
297                                )));
298                            }
299                            "user" => {
300                                criteria.push(Criteria::User(parse_pattern_list(
301                                    tokens.next().unwrap_or(""),
302                                )));
303                            }
304                            "localuser" => {
305                                criteria.push(Criteria::LocalUser(parse_pattern_list(
306                                    tokens.next().unwrap_or(""),
307                                )));
308                            }
309                            _ => break,
310                        }
311                    }
312
313                    groups.push(MatchGroup {
314                        criteria,
315                        options: ConfigMap::new(),
316                        context,
317                    });
318                    continue;
319                }
320
321                fn add_option(options: &mut ConfigMap, k: String, v: &str) {
322                    // first option wins in ssh_config, except for identityfile
323                    // which explicitly allows multiple entries to combine together
324                    let is_identity_file = k == "identityfile";
325                    options
326                        .entry(k)
327                        .and_modify(|e| {
328                            if is_identity_file {
329                                e.push(' ');
330                                e.push_str(v);
331                            }
332                        })
333                        .or_insert_with(|| v.to_string());
334                }
335
336                if let Some(group) = groups.last_mut() {
337                    add_option(&mut group.options, k, v);
338                } else {
339                    add_option(options, k, v);
340                }
341            }
342        }
343    }
344
345    /// Apply configuration values that match the specified hostname to target,
346    /// but only if a given key is not already present in target, because the
347    /// semantics are that the first match wins
348    fn apply_matches(
349        &self,
350        hostname: &str,
351        user: &str,
352        local_user: &str,
353        context: Context,
354        target: &mut ConfigMap,
355    ) -> bool {
356        let mut needs_reparse = false;
357
358        for (k, v) in &self.options {
359            target.entry(k.to_string()).or_insert_with(|| v.to_string());
360        }
361        for group in &self.groups {
362            if group.context != Context::FirstPass {
363                needs_reparse = true;
364            }
365            if group.is_match(hostname, user, local_user, context) {
366                for (k, v) in &group.options {
367                    target.entry(k.to_string()).or_insert_with(|| v.to_string());
368                }
369            }
370        }
371
372        needs_reparse
373    }
374}
375
376/// A context for resolving configuration values.
377/// Holds a combination of environment and token expansion state,
378/// as well as the set of configs that should be consulted.
379#[derive(Debug, Clone)]
380pub struct Config {
381    config_files: Vec<ParsedConfigFile>,
382    options: ConfigMap,
383    tokens: ConfigMap,
384    environment: Option<ConfigMap>,
385}
386
387impl Config {
388    /// Create a new context without any config files loaded
389    pub fn new() -> Self {
390        Self {
391            config_files: vec![],
392            options: ConfigMap::new(),
393            tokens: ConfigMap::new(),
394            environment: None,
395        }
396    }
397
398    /// Assign a fake environment map, useful for testing.
399    /// The environment is used to expand certain values
400    /// from the config.
401    pub fn assign_environment(&mut self, env: ConfigMap) {
402        self.environment.replace(env);
403    }
404
405    /// Assigns token names and expansions for use with a number of
406    /// options.  The names and expansions are specified
407    /// by `man 5 ssh_config`
408    pub fn assign_tokens(&mut self, tokens: ConfigMap) {
409        self.tokens = tokens;
410    }
411
412    /// Assign the value for an option.
413    /// This is logically equivalent to the user specifying command
414    /// line options to override config values.
415    /// These values take precedence over any values found in config files.
416    pub fn set_option<K: AsRef<str>, V: AsRef<str>>(&mut self, key: K, value: V) {
417        self.options
418            .insert(key.as_ref().to_lowercase(), value.as_ref().to_string());
419    }
420
421    /// Parse `config_string` as if it were the contents of an `ssh_config` file,
422    /// and add that to the list of configs.
423    pub fn add_config_string(&mut self, config_string: &str) {
424        self.config_files
425            .push(ParsedConfigFile::parse(config_string, None));
426    }
427
428    /// Open `path`, read its contents and parse it as an `ssh_config` file,
429    /// adding that to the list of configs
430    pub fn add_config_file<P: AsRef<Path>>(&mut self, path: P) {
431        if let Ok(data) = std::fs::read_to_string(path.as_ref()) {
432            self.config_files
433                .push(ParsedConfigFile::parse(&data, path.as_ref().parent()));
434        }
435    }
436
437    /// Convenience method for adding the ~/.ssh/config and system-wide
438    /// `/etc/ssh/config` files to the list of configs
439    pub fn add_default_config_files(&mut self) {
440        if let Some(home) = dirs_next::home_dir() {
441            self.add_config_file(home.join(".ssh").join("config"));
442        }
443        self.add_config_file("/etc/ssh/ssh_config");
444        if let Ok(sysdrive) = std::env::var("SystemDrive") {
445            self.add_config_file(format!("{}/ProgramData/ssh/ssh_config", sysdrive));
446        }
447    }
448
449    fn resolve_local_user(&self) -> String {
450        for user in &["USER", "USERNAME"] {
451            if let Some(user) = self.resolve_env(user) {
452                return user;
453            }
454        }
455        "unknown-user".to_string()
456    }
457
458    /// Resolve the configuration for a given host.
459    /// The returned map will expand environment and tokens for options
460    /// where that is specified.
461    /// Note that in some configurations, the config should be parsed once
462    /// to resolve the main configuration, and then based on some options
463    /// (such as CanonicalHostname), the tokens should be updated and
464    /// the config parsed a second time in order for value expansion
465    /// to have the same results as `ssh`.
466    pub fn for_host<H: AsRef<str>>(&self, host: H) -> ConfigMap {
467        let host = host.as_ref();
468        let local_user = self.resolve_local_user();
469        let target_user = &local_user;
470
471        let mut result = self.options.clone();
472        let mut needs_reparse = false;
473
474        for config in &self.config_files {
475            if config.apply_matches(
476                host,
477                target_user,
478                &local_user,
479                Context::FirstPass,
480                &mut result,
481            ) {
482                needs_reparse = true;
483            }
484        }
485
486        if needs_reparse {
487            log::warn!(
488                "ssh configuration uses options that require two-phase \
489                parsing, which isn't supported"
490            );
491        }
492
493        for (k, v) in &mut result {
494            if let Some(tokens) = self.should_expand_tokens(k) {
495                self.expand_tokens(v, tokens);
496            }
497
498            if self.should_expand_environment(k) {
499                self.expand_environment(v);
500            }
501        }
502
503        result
504            .entry("hostname".to_string())
505            .or_insert_with(|| host.to_string());
506
507        result
508            .entry("port".to_string())
509            .or_insert_with(|| "22".to_string());
510
511        result
512            .entry("user".to_string())
513            .or_insert_with(|| target_user.clone());
514
515        if !result.contains_key("userknownhostsfile") {
516            if let Some(home) = self.resolve_home() {
517                result.insert(
518                    "userknownhostsfile".to_string(),
519                    format!("{}/.ssh/known_hosts {}/.ssh/known_hosts2", home, home,),
520                );
521            }
522        }
523
524        if !result.contains_key("identityfile") {
525            if let Some(home) = self.resolve_home() {
526                result.insert(
527                    "identityfile".to_string(),
528                    format!(
529                        "{}/.ssh/id_dsa {}/.ssh/id_ecdsa {}/.ssh/id_ed25519 {}/.ssh/id_rsa",
530                        home, home, home, home
531                    ),
532                );
533            }
534        }
535
536        if !result.contains_key("identityagent") {
537            if let Some(sock_path) = self.resolve_env("SSH_AUTH_SOCK") {
538                result.insert("identityagent".to_string(), sock_path);
539            }
540        }
541
542        result
543    }
544
545    /// Return true if a given option name is subject to environment variable
546    /// expansion.
547    fn should_expand_environment(&self, key: &str) -> bool {
548        match key {
549            "certificatefile" | "controlpath" | "identityagent" | "identityfile"
550            | "userknownhostsfile" | "localforward" | "remoteforward" => true,
551            _ => false,
552        }
553    }
554
555    /// Returns a set of tokens that should be expanded for a given option name
556    fn should_expand_tokens(&self, key: &str) -> Option<&[&str]> {
557        match key {
558            "certificatefile" | "controlpath" | "identityagent" | "identityfile"
559            | "localforward" | "remotecommand" | "remoteforward" | "userknownkostsfile" => {
560                Some(&["%C", "%d", "%h", "%i", "%L", "%l", "%n", "%p", "%r", "%u"])
561            }
562            "hostname" => Some(&["%h"]),
563            "localcommand" => Some(&[
564                "%C", "%d", "%h", "%i", "%k", "%L", "%l", "%n", "%p", "%r", "%T", "%u",
565            ]),
566            "proxycommand" => Some(&["%h", "%n", "%p", "%r"]),
567            _ => None,
568        }
569    }
570
571    /// Resolve the home directory.
572    /// For the sake of unit testing, this will look for HOME in the provided
573    /// environment override before asking the system for the home directory.
574    fn resolve_home(&self) -> Option<String> {
575        if let Some(env) = self.environment.as_ref() {
576            if let Some(home) = env.get("HOME") {
577                return Some(home.to_string());
578            }
579        }
580        if let Some(home) = dirs_next::home_dir() {
581            if let Some(home) = home.to_str() {
582                return Some(home.to_string());
583            }
584        }
585        None
586    }
587
588    /// Perform token substitution
589    fn expand_tokens(&self, value: &mut String, tokens: &[&str]) {
590        for &t in tokens {
591            if let Some(v) = self.tokens.get(t) {
592                *value = value.replace(t, v);
593            } else if t == "%u" {
594                *value = value.replace(t, &self.resolve_local_user());
595            } else if t == "%d" {
596                if let Some(home) = self.resolve_home() {
597                    let mut items = value
598                        .split_whitespace()
599                        .map(|s| s.to_string())
600                        .collect::<Vec<String>>();
601                    for item in &mut items {
602                        if item.starts_with("~/") {
603                            item.replace_range(0..1, &home);
604                        } else {
605                            *item = item.replace(t, &home);
606                        }
607                    }
608                    *value = items.join(" ");
609                }
610            }
611        }
612
613        *value = value.replace("%%", "%");
614    }
615
616    /// Resolve an environment variable; if an override is set use that,
617    /// otherwise read from the real environment.
618    fn resolve_env(&self, name: &str) -> Option<String> {
619        if let Some(env) = self.environment.as_ref() {
620            env.get(name).cloned()
621        } else {
622            std::env::var(name).ok()
623        }
624    }
625
626    /// Look for `${NAME}` and substitute the value of the `NAME` env var
627    /// into the provided string.
628    fn expand_environment(&self, value: &mut String) {
629        let re = Regex::new(r#"\$\{([a-zA-Z_][a-zA-Z_0-9]+)\}"#).unwrap();
630        *value = re
631            .replace_all(value, |caps: &Captures| -> String {
632                if let Some(rep) = self.resolve_env(&caps[1]) {
633                    rep
634                } else {
635                    caps[0].to_string()
636                }
637            })
638            .to_string();
639    }
640}
641
642#[cfg(test)]
643mod test {
644    use super::*;
645    use k9::snapshot;
646
647    #[test]
648    fn parse_user() {
649        let mut config = Config::new();
650
651        let mut fake_env = ConfigMap::new();
652        fake_env.insert("HOME".to_string(), "/home/me".to_string());
653        fake_env.insert("USER".to_string(), "me".to_string());
654        config.assign_environment(fake_env);
655
656        config.add_config_string(
657            r#"
658        Host foo
659            HostName 10.0.0.1
660            User foo
661            IdentityFile "%d/.ssh/id_pub.dsa"
662            "#,
663        );
664
665        snapshot!(
666            &config,
667            r#"
668Config {
669    config_files: [
670        ParsedConfigFile {
671            options: {},
672            groups: [
673                MatchGroup {
674                    criteria: [
675                        Host(
676                            [
677                                Pattern {
678                                    negated: false,
679                                    pattern: "^foo$",
680                                },
681                            ],
682                        ),
683                    ],
684                    context: FirstPass,
685                    options: {
686                        "hostname": "10.0.0.1",
687                        "identityfile": "%d/.ssh/id_pub.dsa",
688                        "user": "foo",
689                    },
690                },
691            ],
692        },
693    ],
694    options: {},
695    tokens: {},
696    environment: Some(
697        {
698            "HOME": "/home/me",
699            "USER": "me",
700        },
701    ),
702}
703"#
704        );
705
706        let opts = config.for_host("foo");
707        snapshot!(
708            opts,
709            r#"
710{
711    "hostname": "10.0.0.1",
712    "identityfile": "/home/me/.ssh/id_pub.dsa",
713    "port": "22",
714    "user": "foo",
715    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
716}
717"#
718        );
719    }
720
721    #[test]
722    fn multiple_identityfile() {
723        let mut config = Config::new();
724
725        let mut fake_env = ConfigMap::new();
726        fake_env.insert("HOME".to_string(), "/home/me".to_string());
727        fake_env.insert("USER".to_string(), "me".to_string());
728        config.assign_environment(fake_env);
729
730        config.add_config_string(
731            r#"
732        Host foo
733            HostName 10.0.0.1
734            User foo
735            IdentityFile "~/.ssh/id_pub.dsa"
736            IdentityFile "~/.ssh/id_pub.rsa"
737            "#,
738        );
739
740        let opts = config.for_host("foo");
741        snapshot!(
742            opts,
743            r#"
744{
745    "hostname": "10.0.0.1",
746    "identityfile": "/home/me/.ssh/id_pub.dsa /home/me/.ssh/id_pub.rsa",
747    "port": "22",
748    "user": "foo",
749    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
750}
751"#
752        );
753    }
754
755    #[test]
756    fn sub_tilde() {
757        let mut config = Config::new();
758
759        let mut fake_env = ConfigMap::new();
760        fake_env.insert("HOME".to_string(), "/home/me".to_string());
761        fake_env.insert("USER".to_string(), "me".to_string());
762        config.assign_environment(fake_env);
763
764        config.add_config_string(
765            r#"
766        Host foo
767            HostName 10.0.0.1
768            User foo
769            IdentityFile "~/.ssh/id_pub.dsa"
770            "#,
771        );
772
773        let opts = config.for_host("foo");
774        snapshot!(
775            opts,
776            r#"
777{
778    "hostname": "10.0.0.1",
779    "identityfile": "/home/me/.ssh/id_pub.dsa",
780    "port": "22",
781    "user": "foo",
782    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
783}
784"#
785        );
786    }
787
788    #[test]
789    fn parse_match() {
790        let mut config = Config::new();
791
792        let mut fake_env = ConfigMap::new();
793        fake_env.insert("HOME".to_string(), "/home/me".to_string());
794        fake_env.insert("USER".to_string(), "me".to_string());
795        config.assign_environment(fake_env);
796
797        config.add_config_string(
798            r#"
799        # I am a comment
800        Something first
801        # the prior Something takes precedence
802        Something ignored
803        Match Host 192.168.1.8,wopr
804            FowardAgent yes
805            IdentityFile "%d/.ssh/id_pub.dsa"
806
807        Match Host !a.b,*.b User fred
808            ForwardAgent no
809            IdentityAgent "${HOME}/.ssh/agent"
810
811        Match Host !a.b,*.b User me
812            ForwardAgent no
813            IdentityAgent "${HOME}/.ssh/agent-me"
814
815        Host *
816            Something  else
817            "#,
818        );
819
820        snapshot!(
821            &config,
822            r#"
823Config {
824    config_files: [
825        ParsedConfigFile {
826            options: {
827                "something": "first",
828            },
829            groups: [
830                MatchGroup {
831                    criteria: [
832                        Host(
833                            [
834                                Pattern {
835                                    negated: false,
836                                    pattern: "^192\\.168\\.1\\.8$",
837                                },
838                                Pattern {
839                                    negated: false,
840                                    pattern: "^wopr$",
841                                },
842                            ],
843                        ),
844                    ],
845                    context: FirstPass,
846                    options: {
847                        "fowardagent": "yes",
848                        "identityfile": "%d/.ssh/id_pub.dsa",
849                    },
850                },
851                MatchGroup {
852                    criteria: [
853                        Host(
854                            [
855                                Pattern {
856                                    negated: true,
857                                    pattern: "^a\\.b$",
858                                },
859                                Pattern {
860                                    negated: false,
861                                    pattern: "^.*\\.b$",
862                                },
863                            ],
864                        ),
865                        User(
866                            [
867                                Pattern {
868                                    negated: false,
869                                    pattern: "^fred$",
870                                },
871                            ],
872                        ),
873                    ],
874                    context: FirstPass,
875                    options: {
876                        "forwardagent": "no",
877                        "identityagent": "${HOME}/.ssh/agent",
878                    },
879                },
880                MatchGroup {
881                    criteria: [
882                        Host(
883                            [
884                                Pattern {
885                                    negated: true,
886                                    pattern: "^a\\.b$",
887                                },
888                                Pattern {
889                                    negated: false,
890                                    pattern: "^.*\\.b$",
891                                },
892                            ],
893                        ),
894                        User(
895                            [
896                                Pattern {
897                                    negated: false,
898                                    pattern: "^me$",
899                                },
900                            ],
901                        ),
902                    ],
903                    context: FirstPass,
904                    options: {
905                        "forwardagent": "no",
906                        "identityagent": "${HOME}/.ssh/agent-me",
907                    },
908                },
909                MatchGroup {
910                    criteria: [
911                        Host(
912                            [
913                                Pattern {
914                                    negated: false,
915                                    pattern: "^.*$",
916                                },
917                            ],
918                        ),
919                    ],
920                    context: FirstPass,
921                    options: {
922                        "something": "else",
923                    },
924                },
925            ],
926        },
927    ],
928    options: {},
929    tokens: {},
930    environment: Some(
931        {
932            "HOME": "/home/me",
933            "USER": "me",
934        },
935    ),
936}
937"#
938        );
939
940        let opts = config.for_host("random");
941        snapshot!(
942            opts,
943            r#"
944{
945    "hostname": "random",
946    "identityfile": "/home/me/.ssh/id_dsa /home/me/.ssh/id_ecdsa /home/me/.ssh/id_ed25519 /home/me/.ssh/id_rsa",
947    "port": "22",
948    "something": "first",
949    "user": "me",
950    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
951}
952"#
953        );
954
955        let opts = config.for_host("192.168.1.8");
956        snapshot!(
957            opts,
958            r#"
959{
960    "fowardagent": "yes",
961    "hostname": "192.168.1.8",
962    "identityfile": "/home/me/.ssh/id_pub.dsa",
963    "port": "22",
964    "something": "first",
965    "user": "me",
966    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
967}
968"#
969        );
970
971        let opts = config.for_host("a.b");
972        snapshot!(
973            opts,
974            r#"
975{
976    "hostname": "a.b",
977    "identityfile": "/home/me/.ssh/id_dsa /home/me/.ssh/id_ecdsa /home/me/.ssh/id_ed25519 /home/me/.ssh/id_rsa",
978    "port": "22",
979    "something": "first",
980    "user": "me",
981    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
982}
983"#
984        );
985
986        let opts = config.for_host("b.b");
987        snapshot!(
988            opts,
989            r#"
990{
991    "forwardagent": "no",
992    "hostname": "b.b",
993    "identityagent": "/home/me/.ssh/agent-me",
994    "identityfile": "/home/me/.ssh/id_dsa /home/me/.ssh/id_ecdsa /home/me/.ssh/id_ed25519 /home/me/.ssh/id_rsa",
995    "port": "22",
996    "something": "first",
997    "user": "me",
998    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
999}
1000"#
1001        );
1002
1003        let mut fake_env = ConfigMap::new();
1004        fake_env.insert("HOME".to_string(), "/home/fred".to_string());
1005        fake_env.insert("USER".to_string(), "fred".to_string());
1006        config.assign_environment(fake_env);
1007
1008        let opts = config.for_host("b.b");
1009        snapshot!(
1010            opts,
1011            r#"
1012{
1013    "forwardagent": "no",
1014    "hostname": "b.b",
1015    "identityagent": "/home/fred/.ssh/agent",
1016    "identityfile": "/home/fred/.ssh/id_dsa /home/fred/.ssh/id_ecdsa /home/fred/.ssh/id_ed25519 /home/fred/.ssh/id_rsa",
1017    "port": "22",
1018    "something": "first",
1019    "user": "fred",
1020    "userknownhostsfile": "/home/fred/.ssh/known_hosts /home/fred/.ssh/known_hosts2",
1021}
1022"#
1023        );
1024    }
1025
1026    #[test]
1027    fn parse_simple() {
1028        let mut config = Config::new();
1029
1030        let mut fake_env = ConfigMap::new();
1031        fake_env.insert("HOME".to_string(), "/home/me".to_string());
1032        fake_env.insert("USER".to_string(), "me".to_string());
1033        config.assign_environment(fake_env);
1034
1035        config.add_config_string(
1036            r#"
1037        # I am a comment
1038        Something first
1039        # the prior Something takes precedence
1040        Something ignored
1041        Host 192.168.1.8 wopr
1042            FowardAgent yes
1043            IdentityFile "%d/.ssh/id_pub.dsa"
1044
1045        Host !a.b *.b
1046            ForwardAgent no
1047            IdentityAgent "${HOME}/.ssh/agent"
1048
1049        Host *
1050            Something  else
1051            "#,
1052        );
1053
1054        snapshot!(
1055            &config,
1056            r#"
1057Config {
1058    config_files: [
1059        ParsedConfigFile {
1060            options: {
1061                "something": "first",
1062            },
1063            groups: [
1064                MatchGroup {
1065                    criteria: [
1066                        Host(
1067                            [
1068                                Pattern {
1069                                    negated: false,
1070                                    pattern: "^192\\.168\\.1\\.8$",
1071                                },
1072                                Pattern {
1073                                    negated: false,
1074                                    pattern: "^wopr$",
1075                                },
1076                            ],
1077                        ),
1078                    ],
1079                    context: FirstPass,
1080                    options: {
1081                        "fowardagent": "yes",
1082                        "identityfile": "%d/.ssh/id_pub.dsa",
1083                    },
1084                },
1085                MatchGroup {
1086                    criteria: [
1087                        Host(
1088                            [
1089                                Pattern {
1090                                    negated: true,
1091                                    pattern: "^a\\.b$",
1092                                },
1093                                Pattern {
1094                                    negated: false,
1095                                    pattern: "^.*\\.b$",
1096                                },
1097                            ],
1098                        ),
1099                    ],
1100                    context: FirstPass,
1101                    options: {
1102                        "forwardagent": "no",
1103                        "identityagent": "${HOME}/.ssh/agent",
1104                    },
1105                },
1106                MatchGroup {
1107                    criteria: [
1108                        Host(
1109                            [
1110                                Pattern {
1111                                    negated: false,
1112                                    pattern: "^.*$",
1113                                },
1114                            ],
1115                        ),
1116                    ],
1117                    context: FirstPass,
1118                    options: {
1119                        "something": "else",
1120                    },
1121                },
1122            ],
1123        },
1124    ],
1125    options: {},
1126    tokens: {},
1127    environment: Some(
1128        {
1129            "HOME": "/home/me",
1130            "USER": "me",
1131        },
1132    ),
1133}
1134"#
1135        );
1136
1137        let opts = config.for_host("random");
1138        snapshot!(
1139            opts,
1140            r#"
1141{
1142    "hostname": "random",
1143    "identityfile": "/home/me/.ssh/id_dsa /home/me/.ssh/id_ecdsa /home/me/.ssh/id_ed25519 /home/me/.ssh/id_rsa",
1144    "port": "22",
1145    "something": "first",
1146    "user": "me",
1147    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
1148}
1149"#
1150        );
1151
1152        let opts = config.for_host("192.168.1.8");
1153        snapshot!(
1154            opts,
1155            r#"
1156{
1157    "fowardagent": "yes",
1158    "hostname": "192.168.1.8",
1159    "identityfile": "/home/me/.ssh/id_pub.dsa",
1160    "port": "22",
1161    "something": "first",
1162    "user": "me",
1163    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
1164}
1165"#
1166        );
1167
1168        let opts = config.for_host("a.b");
1169        snapshot!(
1170            opts,
1171            r#"
1172{
1173    "hostname": "a.b",
1174    "identityfile": "/home/me/.ssh/id_dsa /home/me/.ssh/id_ecdsa /home/me/.ssh/id_ed25519 /home/me/.ssh/id_rsa",
1175    "port": "22",
1176    "something": "first",
1177    "user": "me",
1178    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
1179}
1180"#
1181        );
1182
1183        let opts = config.for_host("b.b");
1184        snapshot!(
1185            opts,
1186            r#"
1187{
1188    "forwardagent": "no",
1189    "hostname": "b.b",
1190    "identityagent": "/home/me/.ssh/agent",
1191    "identityfile": "/home/me/.ssh/id_dsa /home/me/.ssh/id_ecdsa /home/me/.ssh/id_ed25519 /home/me/.ssh/id_rsa",
1192    "port": "22",
1193    "something": "first",
1194    "user": "me",
1195    "userknownhostsfile": "/home/me/.ssh/known_hosts /home/me/.ssh/known_hosts2",
1196}
1197"#
1198        );
1199    }
1200}