1#[non_exhaustive]
5#[derive(Debug)]
6pub enum ParseError {
7 Io(std::io::Error),
9
10 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub struct Id {
39 pub protoversion: String,
41
42 pub softwareversion: String,
44
45 pub comments: Option<String>,
47}
48
49impl Id {
50 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 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 .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 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 ")] 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("")))] fn it_reparses_consistently(#[case] id: Id) {
172 assert_eq!(id, id.to_string().parse().unwrap());
173 }
174}