ssh_packet/arch/
id.rs

1//! Utilities for the SSH identification string.
2
3use thiserror::Error;
4
5/// Errors which can occur when attempting to parse an [`Id`].
6#[non_exhaustive]
7#[derive(Debug, Error)]
8pub enum ParseError {
9    /// An error occured while performing I/O operations.
10    #[error(transparent)]
11    Io(#[from] std::io::Error),
12
13    /// The parsed identifier was not conformant.
14    #[error("SSH identifier was either misformatted or misprefixed")]
15    BadIdentifer(String),
16}
17
18/// Identification string as defined in the SSH protocol.
19///
20/// The format must match the following pattern:
21/// `SSH-<protoversion>-<softwareversion>[ <comments>]`.
22///
23/// see <https://datatracker.ietf.org/doc/html/rfc4253#section-4.2>.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct Id {
26    /// The SSH's protocol version, should be `2.0` in our case.
27    pub protoversion: String,
28
29    /// A string identifying the software curently used, in example `billsSSH_3.6.3q3`.
30    pub softwareversion: String,
31
32    /// Optional comments with additionnal informations about the software.
33    pub comments: Option<String>,
34}
35
36impl Id {
37    /// Convenience method to create an `SSH-2.0` identifier string.
38    pub fn v2(softwareversion: impl Into<String>, comments: Option<impl Into<String>>) -> Self {
39        const VERSION: &str = "2.0";
40
41        Self {
42            protoversion: VERSION.into(),
43            softwareversion: softwareversion.into(),
44            comments: comments.map(Into::into),
45        }
46    }
47
48    #[cfg(feature = "futures")]
49    #[cfg_attr(docsrs, doc(cfg(feature = "futures")))]
50    /// Read an [`Id`], discarding any _extra lines_ sent by the server
51    /// from the provided asynchronous `reader`.
52    pub async fn from_reader<R>(reader: &mut R) -> Result<Self, ParseError>
53    where
54        R: futures::io::AsyncBufRead + Unpin,
55    {
56        use std::io;
57
58        use futures::TryStreamExt;
59
60        let text = futures::io::AsyncBufReadExt::lines(reader)
61            // Skip extra lines the server can send before identifying
62            .try_skip_while(|line| futures::future::ok(!line.starts_with("SSH")))
63            .try_next()
64            .await?
65            .ok_or(io::Error::new(
66                io::ErrorKind::UnexpectedEof,
67                "unexpected EOF while waiting for SSH identifer",
68            ))?;
69
70        text.parse()
71    }
72
73    #[cfg(feature = "futures")]
74    #[cfg_attr(docsrs, doc(cfg(feature = "futures")))]
75    /// Write the [`Id`] to the provided asynchronous `writer`.
76    pub async fn to_writer<W>(&self, writer: &mut W) -> std::io::Result<()>
77    where
78        W: futures::io::AsyncWrite + Unpin,
79    {
80        use futures::io::AsyncWriteExt;
81
82        writer.write_all(self.to_string().as_bytes()).await?;
83        writer.write_all(b"\r\n").await?;
84
85        Ok(())
86    }
87}
88
89impl std::fmt::Display for Id {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        write!(f, "SSH-{}-{}", self.protoversion, self.softwareversion)?;
92
93        if let Some(comments) = &self.comments {
94            write!(f, " {comments}")?;
95        }
96
97        Ok(())
98    }
99}
100
101impl std::str::FromStr for Id {
102    type Err = ParseError;
103
104    fn from_str(s: &str) -> Result<Self, Self::Err> {
105        let (id, comments) = s
106            .split_once(' ')
107            .map_or_else(|| (s, None), |(id, comments)| (id, Some(comments)));
108
109        match id.splitn(3, '-').collect::<Vec<_>>()[..] {
110            ["SSH", protoversion, softwareversion]
111                if !protoversion.is_empty() && !softwareversion.is_empty() =>
112            {
113                Ok(Self {
114                    protoversion: protoversion.to_string(),
115                    softwareversion: softwareversion.to_string(),
116                    comments: comments.map(str::to_string),
117                })
118            }
119            _ => Err(ParseError::BadIdentifer(s.into())),
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    #![allow(clippy::unwrap_used, clippy::unimplemented)]
127    use rstest::rstest;
128    use std::str::FromStr;
129
130    use super::*;
131
132    #[rstest]
133    #[case("SSH-2.0-billsSSH_3.6.3q3")]
134    #[case("SSH-1.99-billsSSH_3.6.3q3")]
135    #[case("SSH-2.0-billsSSH_3.6.3q3 with-comment")]
136    #[case("SSH-2.0-billsSSH_3.6.3q3 utf∞-comment")]
137    #[case("SSH-2.0-billsSSH_3.6.3q3 ")] // empty comment
138    fn it_parses_valid(#[case] text: &str) {
139        Id::from_str(text).expect(text);
140    }
141
142    #[rstest]
143    #[case("")]
144    #[case("FOO-2.0-billsSSH_3.6.3q3")]
145    #[case("-2.0-billsSSH_3.6.3q3")]
146    #[case("SSH--billsSSH_3.6.3q3")]
147    #[case("SSH-2.0-")]
148    fn it_rejects_invalid(#[case] text: &str) {
149        Id::from_str(text).expect_err(text);
150    }
151
152    #[rstest]
153    #[case(Id::v2("billsSSH_3.6.3q3", None::<String>))]
154    #[case(Id::v2("billsSSH_utf∞", None::<String>))]
155    #[case(Id::v2("billsSSH_3.6.3q3", Some("with-comment")))]
156    #[case(Id::v2("billsSSH_3.6.3q3", Some("utf∞-comment")))]
157    #[case(Id::v2("billsSSH_3.6.3q3", Some("")))] // empty comment
158    fn it_reparses_consistently(#[case] id: Id) {
159        assert_eq!(id, id.to_string().parse().unwrap());
160    }
161}