1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
use crate::error::{self, Error, Result};
pub(crate) const OUR_VERSION: &[u8] = b"SSH-2.0-Sunset-1";
pub(crate) const SSH_PREFIX: &[u8] = b"SSH-2.0-";
// RFC4253 4.2 says max length 255 incl CR LF.
// TODO find what's in the wild
const MAX_REMOTE_VERSION_LEN: usize = 253;
const MAX_LINES: usize = 50;
pub const CR: u8 = 0x0d;
pub const LF: u8 = 0x0a;
pub(crate) fn write_version(buf: &mut [u8]) -> Result<usize> {
let total_len = OUR_VERSION.len() + 2;
if total_len > buf.len() {
return error::NoRoom.fail();
}
let (d, b) = buf.split_at_mut(OUR_VERSION.len());
d.copy_from_slice(OUR_VERSION);
b[0] = CR;
b[1] = LF;
Ok(total_len)
}
/// Parses and stores the remove SSH version string
#[derive(Debug)]
pub struct RemoteVersion {
storage: [u8; MAX_REMOTE_VERSION_LEN],
/// Parse state
st: VersPars,
num_lines: usize,
is_client: bool,
}
/// Version parsing state.
/// We need to match
/// `SSH-2.0-softwareversion SP comments CR LF`
/// at the start of a line. The server may first send other lines
/// which are discarded.
// TODO: SSH impls advertising SSH1 compatibility will have "SSH-1.99-" instead.
// We may need to handle parsing that as well for compatibility. It's possible
// they aren't common or important these days.
#[derive(Debug)]
pub(crate) enum VersPars {
/// Reading start of a line, before receiving a full SSH-2.0- prefix
Start(usize),
/// Have a line that didn't start with SSH-2.0-, discarding until LF
Discarding,
/// Currently reading a SSH-2.0- string, waiting for ending CR
FillSSH(usize),
/// Have ending CR after a version, Waiting for ending LF
HaveCR(usize),
/// Completed string.
Done(usize),
}
impl RemoteVersion {
pub fn new(is_client: bool) -> Self {
RemoteVersion {
storage: [0; MAX_REMOTE_VERSION_LEN],
st: VersPars::Start(0),
num_lines: 0,
is_client,
}
}
/// Returns the parsed version if stored.
pub fn version(&self) -> Option<&[u8]> {
match &self.st {
VersPars::Done(len) => {
let (s, _) = self.storage.split_at(*len);
Some(s)
}
_ => None,
}
}
/// Reads the initial SSH stream to find the version string and returns
/// the number of bytes consumed.
/// Behaviour is undefined if called later after an error.
pub fn consume(&mut self, buf: &[u8]) -> Result<usize> {
// consume input byte by byte, feeding through the states
let mut taken = 0;
for &b in buf {
match self.st {
VersPars::Done(_) => {}
_ => taken += 1,
}
match self.st {
VersPars::Start(ref mut pos) => {
let w =
self.storage.get_mut(*pos).ok_or(error::NoRoom.build())?;
*w = b;
*pos += 1;
// Check if line so far matches SSH-2.0-
let (s, _) = self.storage.split_at(*pos);
if s == SSH_PREFIX {
self.st = VersPars::FillSSH(*pos)
} else if *pos <= SSH_PREFIX.len() {
let (ssh, _) = SSH_PREFIX.split_at(*pos);
if ssh != s {
self.st = VersPars::Discarding
}
} else {
self.st = VersPars::Discarding
}
}
VersPars::Discarding => {
if b == LF {
self.st = VersPars::Start(0);
self.num_lines += 1;
// only client allows extra unknown lines
if !self.is_client || self.num_lines > MAX_LINES {
return Err(Error::NotSSH);
}
}
}
VersPars::FillSSH(ref mut pos) => match b {
CR => {
let (s, _) = self.storage.split_at(*pos);
if !s.is_ascii() {
return Err(Error::NotSSH);
}
self.st = VersPars::HaveCR(*pos);
}
LF => {
return Err(Error::NotSSH);
}
_ => {
let w = self
.storage
.get_mut(*pos)
.ok_or(error::NoRoom.build())?;
*w = b;
*pos += 1;
}
},
VersPars::HaveCR(len) => {
match b {
LF => self.st = VersPars::Done(len),
_ => return Err(Error::NotSSH),
};
}
VersPars::Done(_) => {
break;
}
}
}
// Ran out of input
Ok(taken)
}
}
#[cfg(test)]
#[rustfmt::skip]
mod tests {
use crate::ident;
use crate::error::{Error,Result};
// Tests as a client, allowing leading ignored lines
fn test_version(v: &str, split: usize, expect: &str) -> Result<usize> {
let mut r = ident::RemoteVersion::new(true);
let split = split.min(v.len());
let (a, b) = v.as_bytes().split_at(split);
let taken1 = r.consume(a)?;
let done1 = r.version().is_some();
let taken2 = r.consume(b)?;
let done2 = r.version().is_some();
if done1 {
assert!(done2);
assert!(taken2 == 0);
}
if taken2 > 0 {
assert_eq!(taken1, a.len());
}
let v = core::str::from_utf8(r.version().ok_or(Error::NotSSH)?)?;
assert_eq!(v, expect);
Ok(taken1 + taken2)
}
#[test]
/// check round trip of packet enums is right
fn version() -> Result<()> {
let long = core::str::from_utf8(&[60u8; 300]).unwrap();
// split input at various positions
let splits = [
(0..40).collect(),
vec![200,252,253,254,255,256],
].concat();
for &i in splits.iter() {
test_version("SSH-2.0-@\x0d\x0a", i, "SSH-2.0-@").unwrap();
test_version("SSH-2.0-good something SSH-2.0-trick\x0d\x0azzz", i, "SSH-2.0-good something SSH-2.0-trick").unwrap();
test_version("SSH-2.0-@\x0a\x0d", i, "").unwrap_err();
test_version("SSH-2.0-@\x0a\x0d", i, "").unwrap_err();
test_version("bleh \x0d\x0aSSH-2.0-@\x0d\x0a", i, "SSH-2.0-@").unwrap();
assert_eq!(test_version("SSH-2.0-@\x0d\x0amore", i, "SSH-2.0-@").unwrap(), 11);
assert_eq!(test_version("\x0d\x0aSSH-2.0-@\x0d\x0amore", i, "SSH-2.0-@").unwrap(), 13);
test_version("\x0d\x0aSSH-2.0bleh \x0d\x0aSSH-2.0-@\x0d\x0a", i, "SSH-2.0-@").unwrap();
test_version(&long, i, "").unwrap_err();
test_version(&format!("{long}\x0d\x0aSSH-2.0-works\x0d\x0a"), i, "SSH-2.0-works").unwrap();
test_version(&format!("{long} \x0aSSH-2.0-works\x0d\x0a"), i, "SSH-2.0-works").unwrap();
// a CR by itself is insufficient
test_version(&format!("{long} \x0dSSH-2.0-works\x0d\x0a"), i, "").unwrap_err();
}
Ok(())
}
#[test]
/// check server doesn't allow leading lines
fn version_server_lines() -> Result<()> {
// server instance, is_client false
let mut r = ident::RemoteVersion::new(false);
r.consume(b"SSH-2.0-aaa").unwrap();
let mut r = ident::RemoteVersion::new(false);
// disallow leading lines
r.consume(b"zzz\x0d\x0aSSH-2.0-aaa").unwrap_err();
Ok(())
}
// // TODO: maybe fuzzing would work better.
// // also hits an ICE, perhaps
// // https://github.com/rust-lang/rust/pull/94391
// proptest! {
// #[test]
// fn version_pt(prepa: bool, prepb: bool,
// mut a: [u8; 20],
// mut b: &[u8; 20],
// ) {
// let mut r = ident::RemoteVersion::new();
// // if prepa {
// // a = format!("SSH-2.0-{a}");
// // }
// // if prepb {
// // b = format!("SSH-2.0-{b}");
// // }
// // println!("a {a:?}");
// // println!("b {b:?}");
// let (taken1, done1) = r.consume(&a).unwrap();
// let (taken2, done2) = r.consume(&b).unwrap();
// if done1 {
// assert!(done2);
// assert!(taken2 == 0);
// }
// if taken2 > 0 {
// assert_eq!(taken1, a.len());
// }
// // only allow UTF8 version strings
// if let Some(v) = r.version() {
// let v = core::str::from_utf8(v).unwrap();
// println!("v {v}");
// }
// }
// }
}