Skip to main content

ssh_packet/arch/
id.rs

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