remotefs_ssh/ssh/
config.rs

1//! ## Config
2//!
3//! implements configuration resolver for ssh
4
5use std::fs::File;
6use std::io::BufReader;
7use std::path::Path;
8use std::time::Duration;
9
10use remotefs::{RemoteError, RemoteErrorType, RemoteResult};
11use ssh2_config::{DefaultAlgorithms, HostParams, ParseRule, SshConfig};
12
13use super::SshOpts;
14
15/// Ssh configuration params
16pub struct Config {
17    pub params: HostParams,
18    pub host: String,
19    /// Host resolved from configuration
20    pub resolved_host: String,
21    /// Address is host:port
22    pub address: String,
23    pub username: String,
24    pub connection_timeout: Duration,
25    pub connection_attempts: usize,
26}
27
28impl Config {
29    // -- private
30
31    /// Create `Config` from `HostParams` and `SshOpts`
32    fn from_params(params: HostParams, opts: &SshOpts) -> Self {
33        Config {
34            host: opts.host.to_string(),
35            resolved_host: Self::resolve_host(&params, opts),
36            address: Self::resolve_address(&params, opts),
37            username: Self::resolve_username(&params, opts),
38            connection_timeout: Self::resolve_connection_timeout(&params, opts),
39            connection_attempts: Self::resolve_connection_attempts(&params),
40            params,
41        }
42    }
43
44    /// Parse config at `p` and get params for `host`
45    fn parse(p: &Path, host: &str, rules: ParseRule) -> RemoteResult<HostParams> {
46        trace!("Parsing configuration at {}", p.display());
47        let mut reader = BufReader::new(File::open(p).map_err(|e| {
48            RemoteError::new_ex(
49                RemoteErrorType::IoError,
50                format!("Could not open configuration file: {e}"),
51            )
52        })?);
53        SshConfig::default()
54            .parse(&mut reader, rules)
55            .map_err(|e| {
56                RemoteError::new_ex(
57                    RemoteErrorType::IoError,
58                    format!("Could not parse configuration file: {e}"),
59                )
60            })
61            .map(|x| x.query(host))
62    }
63
64    /// Given host params and ssh options, returns resolved remote host
65    fn resolve_host(params: &HostParams, opts: &SshOpts) -> String {
66        // Host should be overridden
67        match params.host_name.as_deref() {
68            Some(h) => h.to_string(),
69            None => opts.host.to_string(),
70        }
71    }
72
73    /// Given host params and ssh options, returns resolved remote address
74    fn resolve_address(params: &HostParams, opts: &SshOpts) -> String {
75        let host = Self::resolve_host(params, opts);
76        // Opts.port has priority
77        let port = match opts.port {
78            None => params.port.unwrap_or(22),
79            Some(p) => p,
80        };
81        format!("{host}:{port}")
82    }
83
84    /// Resolve username from opts and params.
85    /// If defined in opts, get username in opts,
86    /// if define in params and not in opts, get from params,
87    /// otherwise empty string
88    fn resolve_username(params: &HostParams, opts: &SshOpts) -> String {
89        match opts.username.as_ref() {
90            Some(u) => u.to_string(),
91            None => params.user.as_deref().unwrap_or("").to_string(),
92        }
93    }
94
95    /// Given host params, resolve connection timeout
96    fn resolve_connection_timeout(params: &HostParams, opts: &SshOpts) -> Duration {
97        match opts.connection_timeout {
98            Some(t) => t,
99            None => params
100                .connect_timeout
101                .unwrap_or_else(|| Duration::from_secs(30)),
102        }
103    }
104
105    /// Given host params, resolve connection attempts.
106    /// If `none`, gets 1
107    fn resolve_connection_attempts(params: &HostParams) -> usize {
108        params.connection_attempts.unwrap_or(1)
109    }
110}
111
112impl TryFrom<&SshOpts> for Config {
113    type Error = RemoteError;
114
115    fn try_from(opts: &SshOpts) -> Result<Self, Self::Error> {
116        if let Some(p) = opts.config_file.as_deref() {
117            let params = Self::parse(p, opts.host.as_str(), opts.parse_rules)?;
118            Ok(Self::from_params(params, opts))
119        } else {
120            let params = HostParams::new(&DefaultAlgorithms::default());
121            Ok(Self::from_params(params, opts))
122        }
123    }
124}
125
126#[cfg(test)]
127mod test {
128
129    use pretty_assertions::{assert_eq, assert_ne};
130
131    use super::*;
132    use crate::mock::ssh as ssh_mock;
133
134    #[test]
135    fn should_init_config_from_default_ssh_opts() {
136        let opts = SshOpts::new("192.168.1.1");
137        let config = Config::try_from(&opts).ok().unwrap();
138        assert_eq!(config.connection_attempts, 1);
139        assert_eq!(config.connection_timeout, Duration::from_secs(30));
140        assert_eq!(config.address.as_str(), "192.168.1.1:22");
141        assert_eq!(config.host.as_str(), "192.168.1.1");
142        assert!(config.username.is_empty());
143        assert_eq!(
144            config.params,
145            HostParams::new(&DefaultAlgorithms::default())
146        );
147    }
148
149    #[test]
150    fn should_init_config_from_custom_opts() {
151        let opts = SshOpts::new("192.168.1.1")
152            .connection_timeout(Duration::from_secs(10))
153            .port(2222)
154            .username("omar");
155        let config = Config::try_from(&opts).ok().unwrap();
156        assert_eq!(config.connection_attempts, 1);
157        assert_eq!(config.connection_timeout, Duration::from_secs(10));
158        assert_eq!(config.host.as_str(), "192.168.1.1");
159        assert_eq!(config.address.as_str(), "192.168.1.1:2222");
160        assert_eq!(config.username.as_str(), "omar");
161        assert_eq!(
162            config.params,
163            HostParams::new(&DefaultAlgorithms::default())
164        );
165    }
166
167    #[test]
168    fn should_init_config_from_file() {
169        let config_file = ssh_mock::create_ssh_config(22);
170        let opts = SshOpts::new("sftp").config_file(config_file.path(), ParseRule::STRICT);
171        let config = Config::try_from(&opts).ok().unwrap();
172        assert_eq!(config.connection_attempts, 3);
173        assert_eq!(config.connection_timeout, Duration::from_secs(60));
174        assert_eq!(config.host.as_str(), "sftp");
175        assert_eq!(config.resolved_host.as_str(), "127.0.0.1");
176        assert_eq!(config.address.as_str(), "127.0.0.1:22");
177        assert_eq!(config.username.as_str(), "sftp");
178        assert_ne!(
179            config.params,
180            HostParams::new(&DefaultAlgorithms::default())
181        );
182    }
183
184    #[test]
185    fn should_init_config_from_file_with_override() {
186        let config_file = ssh_mock::create_ssh_config(22);
187        let opts = SshOpts::new("sftp")
188            .config_file(config_file.path(), ParseRule::STRICT)
189            .connection_timeout(Duration::from_secs(10))
190            .port(22)
191            .username("omar");
192        let config = Config::try_from(&opts).ok().unwrap();
193        assert_eq!(config.connection_attempts, 3);
194        assert_eq!(config.connection_timeout, Duration::from_secs(10));
195        assert_eq!(config.host.as_str(), "sftp");
196        assert_eq!(config.resolved_host.as_str(), "127.0.0.1");
197        assert_eq!(config.address.as_str(), "127.0.0.1:22");
198        assert_eq!(config.username.as_str(), "omar");
199        assert_ne!(
200            config.params,
201            HostParams::new(&DefaultAlgorithms::default())
202        );
203    }
204}