1use thiserror::Error;
4
5#[non_exhaustive]
7#[derive(Debug, Error)]
8pub enum ParseError {
9 #[error(transparent)]
11 Io(#[from] std::io::Error),
12
13 #[error("SSH identifier was either misformatted or misprefixed")]
15 BadIdentifer(String),
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct Id {
26 pub protoversion: String,
28
29 pub softwareversion: String,
31
32 pub comments: Option<String>,
34}
35
36impl Id {
37 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 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 .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 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 ")] 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("")))] fn it_reparses_consistently(#[case] id: Id) {
159 assert_eq!(id, id.to_string().parse().unwrap());
160 }
161}