Skip to main content

signstar_request_signature/ssh/
client.rs

1//! SSH-client for sending signing requests.
2//!
3//! This module provides Signstar client. The client is used to
4//! connect to a Signstar host and request signatures for given files.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! # async fn sign() -> testresult::TestResult {
10//! use signstar_request_signature::Request;
11//! use signstar_request_signature::ssh::client::ConnectConfig;
12//!
13//! let options = ConnectConfig::from_first_system_config()?;
14//!
15//! let mut session = options.connect("user").await?;
16//! let request = Request::for_file("package")?;
17//! let response = session.send(&request).await?;
18//! // process response
19//! #     Ok(()) }
20//! ```
21use std::collections::{BTreeMap, HashMap};
22use std::fs::read;
23use std::num::NonZeroU32;
24use std::path::Path;
25use std::str::FromStr;
26use std::{path::PathBuf, sync::Arc, time::Duration};
27
28use garde::Validate;
29use rand::seq::SliceRandom;
30use rand::thread_rng;
31use russh::client::AuthResult;
32use russh::keys::agent::client::AgentClient;
33use russh::keys::ssh_key::known_hosts::Entry;
34use russh::keys::ssh_key::{HashAlg, PublicKey};
35use russh::{ChannelMsg, Disconnect, MethodSet, client};
36use serde::{Deserialize, Deserializer};
37use tokio::net::UnixStream;
38
39use crate::{Request, Response};
40
41/// SSH communication error.
42#[derive(Debug, thiserror::Error)]
43pub enum Error {
44    /// Invalid options used.
45    #[error("Invalid options used: {0}")]
46    InvalidOptions(String),
47
48    /// Authentication failed.
49    #[error("Authentication failed")]
50    AuthFailed {
51        /// The server suggests to proceed with these authentication methods.
52        remaining_methods: MethodSet,
53
54        /// The server says that though authentication method has been accepted, further
55        /// authentication is required.
56        partial_success: bool,
57    },
58
59    /// Invalid user specified.
60    #[error("User {user} is not defined in the config file")]
61    InvalidUser {
62        /// User name that is missing.
63        user: String,
64    },
65
66    /// Invalid Signstar host specified.
67    #[error("Host {host} is not defined in the config file")]
68    InvalidSignstarHost {
69        /// Host name that is not defined.
70        host: String,
71    },
72
73    /// I/O error occurred.
74    #[error("I/O error: {source} when processing {file}")]
75    Io {
76        /// File being processed.
77        ///
78        /// This field will be empty ([`PathBuf::new`]) if the error
79        /// was encountered when processing generic I/O streams.
80        file: PathBuf,
81
82        /// Source error.
83        source: std::io::Error,
84    },
85
86    /// The remote program did not exit cleanly.
87    #[error("Program did not exit cleanly")]
88    UncleanExit,
89
90    /// The remote application returned a non-zero status code.
91    #[error("Remote application failed with status code: {status_code}")]
92    RemoteApplicationFailure {
93        /// Status code returned by the application.
94        status_code: NonZeroU32,
95    },
96
97    /// Internal `russh` protocol error.
98    #[error("SSH protocol error: {0}")]
99    SshProtocol(#[from] russh::Error),
100
101    /// SSH format error.
102    #[error("SSH format error: {0}")]
103    SshFormat(#[from] russh::keys::Error),
104
105    /// Internal `russh` client agent error.
106    #[error("SSH agent error: {0}")]
107    Agent(#[from] russh::AgentAuthError),
108
109    /// JSON serialization error.
110    #[error("Serde serialization error: {0}")]
111    Serialization(#[from] serde_json::Error),
112}
113
114type Result<T> = std::result::Result<T, Error>;
115
116/// The default config file below "/usr/".
117pub const DEFAULT_CONFIG: &str = "/usr/share/signstar/request-signature.toml";
118
119/// The override config file below "/run/".
120pub const RUN_OVERRIDE_CONFIG: &str = "/run/signstar/request-signature.toml";
121
122/// The override config file below "/etc/".
123pub const ETC_OVERRIDE_CONFIG: &str = "/etc/signstar/request-signature.toml";
124
125/// The connection to a Signstar system a specific user can use.
126#[derive(Debug, Deserialize, Validate)]
127pub struct SignstarHost {
128    /// The host name of the Signstar system.
129    #[garde(length(min = 1))]
130    host: String,
131
132    /// The SSH port of the Signstar system.
133    #[garde(range(min = 1))]
134    port: u16,
135
136    /// The known_hosts entries for the Signstar system.
137    #[serde(deserialize_with = "deserialize_entries")]
138    #[garde(length(min = 1))]
139    known_hosts: Vec<Entry>,
140}
141
142/// Connection configuration for sending a signature request.
143///
144/// The configuration tracks a list of all valid targets for connecting.
145#[derive(Debug, Default, Deserialize, Validate)]
146#[garde(custom(validate_host_ids))]
147pub struct ConnectConfig {
148    /// List of hosts configured.
149    #[garde(
150        custom(validate_host_port_uniqueness),
151        custom(validate_known_hosts_consistency),
152        dive
153    )]
154    pub hosts: BTreeMap<String, SignstarHost>,
155
156    /// List of users.
157    #[garde(custom(validate_ssh_public_key_consistency), dive)]
158    pub users: BTreeMap<String, ConnectOptions>,
159}
160
161impl ConnectConfig {
162    /// The order of configuration files.
163    ///
164    /// The following files are inspected, in descending priority:
165    /// - `/etc/signstar/request-signature.toml`
166    /// - `/run/signstar/request-signature.toml`
167    /// - `/usr/share/signstar/request-signature.toml`
168    pub const CONFIG_ORDER: &[&str] = &[ETC_OVERRIDE_CONFIG, RUN_OVERRIDE_CONFIG, DEFAULT_CONFIG];
169
170    /// Connects to a host over SSH and returns a [`Session`] object.
171    ///
172    /// This function sets up an authenticated, bidirectional channel
173    /// between the client and the server. No signing requests are exchanged at this point but any
174    /// number of them can be issued later using [`Session::send`] function.
175    ///
176    /// # Examples
177    ///
178    /// ```no_run
179    /// # async fn sign() -> testresult::TestResult {
180    /// use signstar_request_signature::ssh::client::ConnectConfig;
181    ///
182    /// let options = ConnectConfig::from_first_system_config()?;
183    ///
184    /// let mut session = options.connect("user").await?;
185    /// // use session to send signing requests
186    /// #     Ok(()) }
187    /// ```
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if:
192    /// - the client public key is not set,
193    /// - the server public key is not present in the provided SSH `known_hosts` data,
194    /// - the client public key is not recognized by the server,
195    /// - the client authentication with the agent fails,
196    /// - an SSH protocol error is encountered.
197    pub async fn connect(&self, username: &str) -> Result<Session> {
198        let user = &self.users.get(username).ok_or(Error::InvalidUser {
199            user: username.to_string(),
200        })?;
201        let host_id = user.hosts.choose(&mut thread_rng());
202        let target = if let Some(host_id) = host_id {
203            &self.hosts.get(host_id).ok_or(Error::InvalidSignstarHost {
204                host: host_id.to_string(),
205            })?
206        } else {
207            return Err(Error::InvalidOptions(
208                "at least one host must be defined".into(),
209            ));
210        };
211        let client_auth_public_key = &user.user_public_key;
212
213        let config = Arc::new(client::Config {
214            inactivity_timeout: Some(Duration::from_secs(5)),
215            ..Default::default()
216        });
217
218        let stream = UnixStream::connect(&user.agent_socket)
219            .await
220            .map_err(|source| Error::Io {
221                file: user.agent_socket.clone(),
222                source,
223            })?;
224        let mut future = AgentClient::connect(stream);
225        let mut session = client::connect(
226            config,
227            (target.host.clone(), target.port),
228            KeyValidator {
229                host: target.host.clone(),
230                port: target.port,
231                entries: target.known_hosts.clone(),
232            },
233        )
234        .await?;
235        let auth_res = session
236            .authenticate_publickey_with(
237                username,
238                client_auth_public_key.clone(),
239                Some(HashAlg::Sha512),
240                &mut future,
241            )
242            .await?;
243
244        if let AuthResult::Failure {
245            remaining_methods,
246            partial_success,
247        } = auth_res
248        {
249            return Err(Error::AuthFailed {
250                remaining_methods,
251                partial_success,
252            });
253        }
254
255        Ok(Session {
256            session,
257            host: target.host.clone(),
258            port: target.port,
259        })
260    }
261
262    /// Return the first configuration file that exists in the filesystem out of
263    /// [`Self::CONFIG_ORDER`] list or returns a [`crate::Error::ConfigMissing`] error.
264    ///
265    /// # Errors
266    ///
267    /// Returns an error:
268    /// - [`crate::Error::Io`] if checking the config fails
269    /// - [`crate::Error::ConfigMissing`] if no configuration files have been found
270    fn first_existing_system_config() -> std::result::Result<PathBuf, crate::Error> {
271        let candidates = Self::CONFIG_ORDER.iter();
272        for file in candidates {
273            let file = PathBuf::from(file);
274            if std::fs::exists(&file).map_err(|source| crate::Error::Io {
275                file: file.clone(),
276                source,
277            })? {
278                return Ok(file);
279            }
280        }
281        Err(crate::Error::ConfigMissing)
282    }
283
284    /// Return the configuration created from the first configuration file that exists in the
285    /// filesystem out of [`Self::CONFIG_ORDER`] list or returns a
286    /// [`crate::Error::ConfigMissing`] error.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error:
291    /// - [`crate::Error::Io`] if checking the config fails
292    /// - [`crate::Error::ConfigMissing`] if no configuration files have been found
293    /// - [`crate::Error::Validation`] if the config file has invalid contents
294    pub fn from_first_system_config() -> std::result::Result<Self, crate::Error> {
295        Self::from_config_file(Self::first_existing_system_config()?)
296    }
297
298    /// Reads the configuration file, validates it and returns the [`ConnectConfig`].
299    ///
300    /// # Errors
301    ///
302    /// Returns an error:
303    /// - [`crate::Error::Io`] if reading the config file fails
304    /// - [`crate::Error::Validation`] if the config file has invalid contents
305    pub fn from_config_file(file: impl AsRef<Path>) -> std::result::Result<Self, crate::Error> {
306        let contents = read(file.as_ref()).map_err(|source| crate::Error::Io {
307            file: file.as_ref().into(),
308            source,
309        })?;
310        let config: Self = toml::from_slice(&contents)?;
311        config
312            .validate()
313            .map_err(|source| crate::Error::Validation {
314                context: "reading configuration file".to_owned(),
315                source,
316            })?;
317        Ok(config)
318    }
319}
320
321/// Ensures, that SSH public keys are unique per user.
322///
323/// # Errors
324///
325/// Returns an error if an SSH public key is used by more than one user.
326fn validate_ssh_public_key_consistency(
327    users: &BTreeMap<String, ConnectOptions>,
328    _context: &(),
329) -> garde::Result {
330    let failures = users
331        .iter()
332        .map(|(user, connect_option)| (user, &connect_option.user_public_key))
333        .fold(
334            HashMap::<&PublicKey, Vec<&str>>::new(),
335            |mut acc, (user, user_public_key)| {
336                acc.entry(user_public_key).or_default().push(user);
337                acc
338            },
339        )
340        .into_iter()
341        .filter(|(_, users)| users.len() > 1)
342        .map(|(pk, users)| {
343            format!(
344                "Public key {pk} is shared by these users: {users}",
345                pk = pk.to_string(),
346                users = users.join(", ")
347            )
348        })
349        .collect::<Vec<_>>();
350    if !failures.is_empty() {
351        return Err(garde::Error::new(format!(
352            "The SSH public key used for a user cannot be used for another user:\n{failures}",
353            failures = failures.join("\n")
354        )));
355    }
356
357    Ok(())
358}
359
360/// Ensures, that the `known_hosts` entries are unique per `host:port` combinations.
361///
362/// # Errors
363///
364/// Returns an error if the `known_hosts` entries differ for any instance of a `host:port`
365/// combination.
366fn validate_known_hosts_consistency(
367    connect_options: &BTreeMap<String, SignstarHost>,
368    _context: &(),
369) -> garde::Result {
370    let failures = connect_options
371        .iter()
372        .map(|(host, config)| (host, &config.known_hosts))
373        .fold(
374            HashMap::<String, Vec<&str>>::new(),
375            |mut acc, (host, known_hosts)| {
376                for known_host in known_hosts {
377                    acc.entry(known_host.to_string()).or_default().push(host);
378                }
379                acc
380            },
381        )
382        .into_iter()
383        .filter(|(_, hosts)| hosts.len() > 1)
384        .map(|(entry, hosts)| {
385            format!(
386                "Entry {entry} is shared by these hosts: {hosts}",
387                hosts = hosts.join(", ")
388            )
389        })
390        .collect::<Vec<_>>();
391    if !failures.is_empty() {
392        return Err(garde::Error::new(format!(
393            "The known host entry used for a host cannot be used for another host:\n{failures}",
394            failures = failures.join("\n")
395        )));
396    }
397
398    Ok(())
399}
400
401/// Ensures, that each host:port combination is unique.
402///
403/// # Errors
404///
405/// Returns an error if there is a duplicate host:port combination.
406fn validate_host_port_uniqueness(
407    connect_options: &BTreeMap<String, SignstarHost>,
408    _context: &(),
409) -> garde::Result {
410    let failures = connect_options
411        .iter()
412        .map(|(host_id, config)| (host_id, &config.host, config.port))
413        .fold(
414            HashMap::<(&str, u16), Vec<&str>>::new(),
415            |mut acc, (host_id, host, port)| {
416                acc.entry((host, port)).or_default().push(host_id);
417
418                acc
419            },
420        )
421        .into_iter()
422        .filter(|(_, host_ids)| host_ids.len() > 1)
423        .map(|((host, port), host_ids)| {
424            format!(
425                "Host {host}:{port} is shared by these hosts: {host_ids}",
426                host_ids = host_ids.join(", ")
427            )
428        })
429        .collect::<Vec<_>>();
430    if !failures.is_empty() {
431        return Err(garde::Error::new(format!(
432            "Two connections cannot have the same host:port combinations:\n{failures}",
433            failures = failures.join("\n")
434        )));
435    }
436
437    Ok(())
438}
439
440/// Ensures, that each `host_id` in a [`ConnectConfig`] is defined.
441///
442/// # Errors
443///
444/// Returns an error if there are host_ids which are not defined.
445fn validate_host_ids(config: &ConnectConfig, _context: &()) -> garde::Result {
446    let failures = config
447        .users
448        .iter()
449        .flat_map(|(user_id, options)| {
450            options
451                .hosts
452                .iter()
453                .filter(|host| !config.hosts.contains_key(*host))
454                .map(move |invalid_host| (user_id, invalid_host))
455        })
456        .map(|(user, invalid_host)| {
457            format!("User {user} is using host_id which is not defined: {invalid_host}",)
458        })
459        .collect::<Vec<_>>();
460
461    if !failures.is_empty() {
462        return Err(garde::Error::new(failures.join("\n")));
463    }
464
465    Ok(())
466}
467
468/// Connection options for sending a signature request.
469///
470/// The options capture target host parameters and all necessary
471/// information related to authentication for both the client
472/// (client's public key and authentication agent) and server (a list
473/// of valid and known server public keys).
474///
475/// # Examples
476///
477/// ```no_run
478/// # fn main() -> testresult::TestResult {
479/// use signstar_request_signature::ssh::client::ConnectOptions;
480///
481/// let options = ConnectOptions::new("localhost".into(), "ssh-ed25519 ...")?
482///     .agent_socket(std::env::var("SSH_AUTH_SOCK")?);
483/// # Ok(()) }
484/// ```
485#[derive(Debug, Deserialize, Validate)]
486pub struct ConnectOptions {
487    /// The path to the SSH agent socket used for the connection to `user` on the Signstar
488    /// system(s).
489    #[garde(skip)]
490    pub agent_socket: PathBuf,
491
492    /// The SSH public key used for the connections to `user` on the Signstar system(s).
493    #[garde(skip)]
494    pub user_public_key: PublicKey,
495
496    /// The list of Signstar hosts that the `user` can connect to.
497    #[garde(length(min = 1))]
498    pub hosts: Vec<String>,
499}
500
501impl ConnectOptions {
502    /// Sets the path to an OpenSSH agent socket for client authentication.
503    pub fn agent_socket(mut self, agent_socket: impl Into<PathBuf>) -> Self {
504        self.agent_socket = agent_socket.into();
505        self
506    }
507
508    /// Sets an SSH public key of a client for SSH authentication.
509    ///
510    /// # Examples
511    ///
512    /// ```
513    /// # fn main() -> testresult::TestResult {
514    /// use signstar_request_signature::ssh::client::ConnectOptions;
515    ///
516    /// let client_pk =
517    ///     "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILHCXBJYlPPkrt2WYyP3SZoMx43lDBB5QALjE762EQlc";
518    /// let options = ConnectOptions::new("localhost".into(), client_pk)?;
519    /// #     Ok(()) }
520    /// ```
521    ///
522    /// # Errors
523    ///
524    /// Returns an error if the public key is not well-formatted. This
525    /// function only accepts public keys following the
526    /// [`authorized_keys` file format].
527    ///
528    /// [`authorized_keys` file format]: https://man.archlinux.org/man/core/openssh/sshd.8.en#AUTHORIZED_KEYS_FILE_FORMAT.
529    pub fn user_public_key(mut self, user_public_key: impl Into<String>) -> Result<Self> {
530        self.user_public_key =
531            PublicKey::from_openssh(&user_public_key.into()).map_err(russh::keys::Error::SshKey)?;
532        Ok(self)
533    }
534
535    /// Constructs a new [`ConnectOptions`] with target host and client public key.
536    pub fn new(host: String, client_auth_public_key: impl AsRef<str>) -> Result<Self> {
537        let client_auth_public_key = PublicKey::from_openssh(client_auth_public_key.as_ref())
538            .map_err(russh::keys::Error::SshKey)?;
539        Ok(Self {
540            hosts: vec![host],
541            agent_socket: Default::default(),
542            user_public_key: client_auth_public_key,
543        })
544    }
545}
546
547fn deserialize_entries<'de, D>(deserializer: D) -> std::result::Result<Vec<Entry>, D::Error>
548where
549    D: Deserializer<'de>,
550{
551    Vec::<String>::deserialize(deserializer)?
552        .into_iter()
553        .map(|entry: String| Entry::from_str(&entry).map_err(serde::de::Error::custom))
554        .collect::<std::result::Result<_, _>>()
555}
556
557/// Validator for a host's SSH keys and a list of `known_hosts` entries.
558///
559/// Tracks a `host` and its `port`, as well as a list of `entries` in the [SSH `known_hosts` file
560/// format].
561///
562/// [SSH `known_hosts` file format]: https://man.archlinux.org/man/sshd.8#SSH_KNOWN_HOSTS_FILE_FORMAT
563struct KeyValidator {
564    host: String,
565    port: u16,
566    entries: Vec<Entry>,
567}
568
569impl client::Handler for KeyValidator {
570    type Error = Error;
571
572    /// Checks whether a set of server details can be found in SSH `known_hosts` data.
573    ///
574    /// Based on a `host` and its `port`, this function evaluates whether a supplied `key` is part
575    /// of a list of `entries` in the SSH known_hosts file format. Returns `true`, if the
576    /// combination of `key`, `host` and `port` matches an entry in the list of `entries` and that
577    /// entry is not a CA key or a revoked key. Returns `false` in all other cases.
578    async fn check_server_key(&mut self, server_public_key: &PublicKey) -> Result<bool> {
579        Ok(crate::ssh::known_hosts::is_server_known(
580            self.entries.iter(),
581            &self.host,
582            self.port,
583            server_public_key,
584        ))
585    }
586}
587
588/// An open session with a host that can be used to send multiple signing requests.
589pub struct Session {
590    session: client::Handle<KeyValidator>,
591    host: String,
592    port: u16,
593}
594
595impl std::fmt::Debug for Session {
596    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
597        write!(
598            f,
599            "SSH session for host {} on port {}",
600            self.host, self.port
601        )
602    }
603}
604
605impl Session {
606    /// Send a signing request to the server and return a signing response.
607    ///
608    /// # Examples
609    ///
610    /// ```no_run
611    /// # async fn sign() -> testresult::TestResult {
612    /// use signstar_request_signature::Request;
613    /// use signstar_request_signature::ssh::client::ConnectConfig;
614    ///
615    /// let options = ConnectConfig::from_first_system_config()?;
616    ///
617    /// let mut session = options.connect("user").await?;
618    /// let request = Request::for_file("package")?;
619    /// let response = session.send(&request).await?;
620    /// // process response
621    /// #     Ok(()) }
622    /// ```
623    ///
624    /// # Errors
625    ///
626    /// Returns an error if sending or processing the signing request fails:
627    /// - if the remote server rejects the signing request,
628    /// - if the remote application exits unexpectedly,
629    /// - the returned data cannot be deserialized into a [`Response`],
630    /// - if an SSH protocol error is encountered.
631    pub async fn send(&mut self, data: &Request) -> Result<Response> {
632        let mut channel = self.session.channel_open_session().await?;
633        // the command name is empty as it is assumed that the server will
634        // pick correct binary anyway
635        let command_name = b"";
636        channel.exec(true, command_name).await?;
637        let data = serde_json::to_vec(&data)?;
638        channel.data(data.as_ref()).await?;
639        channel.eof().await?;
640
641        let mut code = None;
642        let mut stdout = vec![];
643
644        while let Some(msg) = channel.wait().await {
645            match msg {
646                ChannelMsg::Data { ref data } => {
647                    stdout.extend(data.iter());
648                }
649                ChannelMsg::ExitStatus { exit_status } => {
650                    code = Some(exit_status);
651                    // cannot leave the loop immediately, there might still be more data to receive
652                }
653                _ => {}
654            }
655        }
656
657        if let Some(code) = code {
658            if let Some(status_code) = NonZeroU32::new(code) {
659                Err(Error::RemoteApplicationFailure { status_code })
660            } else {
661                Ok(serde_json::from_slice(&stdout)?)
662            }
663        } else {
664            Err(Error::UncleanExit)
665        }
666    }
667
668    /// Close the authentication session.
669    ///
670    /// This function cleanly closes the session and informs the
671    /// server that no further requests will be sent.
672    ///
673    /// # Examples
674    ///
675    /// This example shows that after the [`Session::close`] function is invoked no further requests
676    /// can be sent.
677    ///
678    /// ```compile_fail
679    /// # async fn sign() -> testresult::TestResult {
680    /// use signstar_request_signature::ssh::client::ConnectConfig;
681    ///
682    /// let options = ConnectConfig::from_first_system_config();
683    ///
684    /// let mut session = options.connect("user").await?;
685    /// session.close();
686    ///
687    /// // the session object has been consumed and cannot be reused
688    /// let request = Request::for_file("package")?;
689    /// let response = session.send(&request).await?;
690    /// #     Ok(()) }
691    /// ```
692    ///
693    /// # Errors
694    ///
695    /// Returns an error if at any stage of the connecting process fails:
696    /// - if the client public key is not set,
697    /// - if the server public key is not pinned in the known hosts file,
698    /// - if the client public key is not recognized by the server,
699    /// - if the client authentication with the agent fails,
700    /// - if an SSH protocol error is encountered.
701    pub async fn close(self) -> Result<()> {
702        self.session
703            .disconnect(
704                Disconnect::ByApplication,
705                "Client is closing the connection",
706                "en",
707            )
708            .await?;
709        Ok(())
710    }
711}
712
713#[cfg(test)]
714mod tests {
715
716    use garde::Validate;
717    use insta::assert_snapshot;
718    use testresult::TestResult;
719
720    use super::*;
721
722    #[test]
723    fn validate_success() -> TestResult {
724        let config = ConnectConfig {
725                users: [("test-user".into(), ConnectOptions::new(
726                    "local".into(),
727                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
728                )?)].into_iter().collect(),
729                hosts: [("local".into(), SignstarHost {
730                    host: "127.0.0.1".into(),
731                    port: 22,
732                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
733                })].into_iter().collect(),
734            };
735
736        config.validate()?;
737
738        Ok(())
739    }
740
741    #[test]
742    fn validate_ssh_public_key_cannot_be_reused() -> TestResult {
743        let config = ConnectConfig {
744                users: [("test-user".into(), ConnectOptions::new(
745                    "local".into(),
746                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
747                )?),("test-user2".into(), ConnectOptions::new(
748                    "local".into(),
749                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
750                )?)].into_iter().collect(),
751                hosts: [("local".into(), SignstarHost {
752                    host: "127.0.0.1".into(),
753                    port: 22,
754                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
755                })].into_iter().collect(),
756            };
757
758        let error_msg = match config.validate() {
759            Ok(()) => {
760                panic!("Expected to fail with garde::Error, but succeeded instead.")
761            }
762            Err(error) => error.to_string(),
763        };
764
765        assert_snapshot!(error_msg);
766
767        Ok(())
768    }
769
770    #[test]
771    fn validate_known_hosts_entries_unique_per_host_port_of_the_user() -> TestResult {
772        let config = ConnectConfig {
773                users: [("test-user".into(), ConnectOptions::new(
774                    "local".into(),
775                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
776                )?),("test-user2".into(), ConnectOptions::new(
777                    "local2".into(),
778                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
779                )?)].into_iter().collect(),
780                hosts: [("local".into(), SignstarHost {
781                    host: "127.0.0.1".into(),
782                    port: 22,
783                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
784                }),("local2".into(), SignstarHost {
785                    host: "127.0.0.2".into(),
786                    port: 22,
787                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
788                })].into_iter().collect(),
789            };
790
791        let error_msg = match config.validate() {
792            Ok(()) => {
793                panic!("Expected to fail with garde::Error, but succeeded instead.")
794            }
795            Err(error) => error.to_string(),
796        };
797
798        assert_snapshot!(error_msg);
799
800        Ok(())
801    }
802
803    #[test]
804    fn validate_host_port_uniqueness() -> TestResult {
805        let config = ConnectConfig {
806                users: [("test-user".into(), ConnectOptions::new(
807                    "local".into(),
808                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
809                )?),("test-user2".into(), ConnectOptions::new(
810                    "local2".into(),
811                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
812                )?)].into_iter().collect(),
813                hosts: [("local".into(), SignstarHost {
814                    host: "127.0.0.1".into(),
815                    port: 22,
816                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAD")?],
817                }),("local2".into(), SignstarHost {
818                    host: "127.0.0.1".into(),
819                    port: 22,
820                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
821                })].into_iter().collect(),
822            };
823
824        let error_msg = match config.validate() {
825            Ok(()) => {
826                panic!("Expected to fail with garde::Error, but succeeded instead.")
827            }
828            Err(error) => error.to_string(),
829        };
830
831        assert_snapshot!(error_msg);
832
833        Ok(())
834    }
835
836    #[test]
837    fn validate_host_ids() -> TestResult {
838        let config = ConnectConfig {
839                users: [("test-user".into(), ConnectOptions::new(
840                    "test".into(),
841                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
842                )?),("test-user2".into(), ConnectOptions::new(
843                    "test".into(),
844                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
845                )?)].into_iter().collect(),
846                hosts: [("test2".into(), SignstarHost {
847                    host: "127.0.0.1".into(),
848                    port: 22,
849                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
850                })].into_iter().collect(),
851            };
852
853        let error_msg = match config.validate() {
854            Ok(()) => {
855                panic!("Expected to fail with garde::Error, but succeeded instead.")
856            }
857            Err(error) => error.to_string(),
858        };
859
860        assert_snapshot!(error_msg);
861
862        Ok(())
863    }
864}