1use crate::{Error, PublicKey, Result};
4use alloc::{
5 string::{String, ToString},
6 vec::Vec,
7};
8use core::{
9 fmt::{self, Debug},
10 str,
11};
12use encoding::base64::{Base64, Encoding};
13
14#[cfg(feature = "std")]
15use std::{fs, path::Path};
16
17const COMMENT_DELIMITER: char = '#';
19const MAGIC_HASH_PREFIX: &str = "|1|";
21
22pub struct KnownHosts<'a> {
49 lines: str::Lines<'a>,
51}
52
53impl<'a> KnownHosts<'a> {
54 #[must_use]
56 pub fn new(input: &'a str) -> Self {
57 Self {
58 lines: input.lines(),
59 }
60 }
61
62 #[cfg(feature = "std")]
68 pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<Entry>> {
69 let input = fs::read_to_string(path)?;
71 KnownHosts::new(&input).collect()
72 }
73
74 fn next_line_trimmed(&mut self) -> Option<&'a str> {
76 loop {
77 let mut line = self.lines.next()?;
78
79 if let Some((l, _)) = line.split_once(COMMENT_DELIMITER) {
81 line = l;
82 }
83
84 line = line.trim_end();
86
87 if !line.is_empty() {
88 return Some(line);
89 }
90 }
91 }
92}
93
94impl Debug for KnownHosts<'_> {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.debug_struct("KnownHosts").finish_non_exhaustive()
97 }
98}
99
100impl Iterator for KnownHosts<'_> {
101 type Item = Result<Entry>;
102
103 fn next(&mut self) -> Option<Result<Entry>> {
104 self.next_line_trimmed().map(str::parse)
105 }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct Entry {
111 marker: Option<Marker>,
113
114 host_patterns: HostPatterns,
116
117 public_key: PublicKey,
119}
120
121impl Entry {
122 #[must_use]
124 pub fn marker(&self) -> Option<&Marker> {
125 self.marker.as_ref()
126 }
127
128 #[must_use]
130 pub fn host_patterns(&self) -> &HostPatterns {
131 &self.host_patterns
132 }
133
134 #[must_use]
136 pub fn public_key(&self) -> &PublicKey {
137 &self.public_key
138 }
139}
140impl From<Entry> for Option<Marker> {
141 fn from(entry: Entry) -> Option<Marker> {
142 entry.marker
143 }
144}
145impl From<Entry> for HostPatterns {
146 fn from(entry: Entry) -> HostPatterns {
147 entry.host_patterns
148 }
149}
150impl From<Entry> for PublicKey {
151 fn from(entry: Entry) -> PublicKey {
152 entry.public_key
153 }
154}
155
156impl str::FromStr for Entry {
157 type Err = Error;
158
159 fn from_str(line: &str) -> Result<Self> {
160 let (marker, line) = if line.starts_with('@') {
166 let (marker_str, line) = line.split_once(' ').ok_or(Error::FormatEncoding)?;
167 (Some(marker_str.parse()?), line)
168 } else {
169 (None, line)
170 };
171 let (hosts_str, public_key_str) = line.split_once(' ').ok_or(Error::FormatEncoding)?;
172
173 let host_patterns = hosts_str.parse()?;
174 let public_key = public_key_str.parse()?;
175
176 Ok(Self {
177 marker,
178 host_patterns,
179 public_key,
180 })
181 }
182}
183
184#[allow(clippy::to_string_trait_impl)]
185impl ToString for Entry {
186 fn to_string(&self) -> String {
187 let mut s = String::new();
188
189 if let Some(marker) = &self.marker {
190 s.push_str(marker.as_str());
191 s.push(' ');
192 }
193
194 s.push_str(&self.host_patterns.to_string());
195 s.push(' ');
196
197 s.push_str(&self.public_key.to_string());
198 s
199 }
200}
201
202#[derive(Clone, Copy, Debug, Eq, PartialEq)]
206pub enum Marker {
207 CertAuthority,
209
210 Revoked,
213}
214
215impl Marker {
216 #[must_use]
218 pub fn as_str(&self) -> &str {
219 match self {
220 Self::CertAuthority => "@cert-authority",
221 Self::Revoked => "@revoked",
222 }
223 }
224}
225
226impl AsRef<str> for Marker {
227 fn as_ref(&self) -> &str {
228 self.as_str()
229 }
230}
231
232impl str::FromStr for Marker {
233 type Err = Error;
234
235 fn from_str(s: &str) -> Result<Self> {
236 Ok(match s {
237 "@cert-authority" => Marker::CertAuthority,
238 "@revoked" => Marker::Revoked,
239 _ => return Err(Error::FormatEncoding),
240 })
241 }
242}
243
244impl fmt::Display for Marker {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 f.write_str(self.as_str())
247 }
248}
249
250#[derive(Clone, Debug, Eq, PartialEq)]
257pub enum HostPatterns {
258 Patterns(Vec<String>),
260 HashedName {
262 salt: Vec<u8>,
264 hash: [u8; 20],
266 },
267}
268
269impl str::FromStr for HostPatterns {
270 type Err = Error;
271
272 fn from_str(s: &str) -> Result<Self> {
273 if let Some(s) = s.strip_prefix(MAGIC_HASH_PREFIX) {
274 let mut hash = [0; 20];
275 let (salt, hash_str) = s.split_once('|').ok_or(Error::FormatEncoding)?;
276
277 let salt = Base64::decode_vec(salt)?;
278 Base64::decode(hash_str, &mut hash)?;
279
280 Ok(HostPatterns::HashedName { salt, hash })
281 } else if !s.is_empty() {
282 Ok(HostPatterns::Patterns(
283 s.split_terminator(',').map(str::to_string).collect(),
284 ))
285 } else {
286 Err(Error::FormatEncoding)
287 }
288 }
289}
290
291#[allow(clippy::to_string_trait_impl)]
292impl ToString for HostPatterns {
293 fn to_string(&self) -> String {
294 match &self {
295 HostPatterns::Patterns(patterns) => patterns.join(","),
296 HostPatterns::HashedName { salt, hash } => {
297 let salt = Base64::encode_string(salt);
298 let hash = Base64::encode_string(hash);
299 format!("|1|{salt}|{hash}")
300 }
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use alloc::string::ToString;
308 use core::str::FromStr;
309
310 use super::Entry;
311 use super::HostPatterns;
312 use super::Marker;
313
314 #[test]
315 fn simple_markers() {
316 assert_eq!(Ok(Marker::CertAuthority), "@cert-authority".parse());
317 assert_eq!(Ok(Marker::Revoked), "@revoked".parse());
318 assert!(Marker::from_str("@gibberish").is_err());
319 }
320
321 #[test]
322 fn empty_host_patterns() {
323 assert!(HostPatterns::from_str("").is_err());
324 }
325
326 #[test]
331 fn single_host_pattern() {
332 assert_eq!(
333 Ok(HostPatterns::Patterns(vec!["cvs.example.net".to_string()])),
334 "cvs.example.net".parse()
335 );
336 }
337 #[test]
338 fn multiple_host_patterns() {
339 assert_eq!(
340 Ok(HostPatterns::Patterns(vec![
341 "cvs.example.net".to_string(),
342 "!test.example.???".to_string(),
343 "[*.example.net]:999".to_string(),
344 ])),
345 "cvs.example.net,!test.example.???,[*.example.net]:999".parse()
346 );
347 }
348 #[test]
349 fn single_hashed_host() {
350 assert_eq!(
351 Ok(HostPatterns::HashedName {
352 salt: vec![
353 37, 242, 147, 116, 24, 123, 172, 214, 215, 145, 80, 16, 9, 26, 120, 57, 10, 15,
354 126, 98
355 ],
356 hash: [
357 81, 33, 2, 175, 116, 150, 127, 82, 84, 62, 201, 172, 228, 10, 159, 15, 148, 31,
358 198, 67
359 ],
360 }),
361 "|1|JfKTdBh7rNbXkVAQCRp4OQoPfmI=|USECr3SWf1JUPsms5AqfD5QfxkM=".parse()
362 );
363 }
364
365 #[test]
366 fn full_line_hashed() {
367 let line = "@revoked |1|lcY/In3lsGnkJikLENb0DM70B/I=|Qs4e9Nr7mM6avuEv02fw2uFnwQo= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB9dG4kjRhQTtWTVzd2t27+t0DEHBPW7iOD23TUiYLio comment";
368 let entry = Entry::from_str(line).expect("Valid entry");
369 assert_eq!(entry.marker(), Some(&Marker::Revoked));
370 assert_eq!(
371 entry.host_patterns(),
372 &HostPatterns::HashedName {
373 salt: vec![
374 149, 198, 63, 34, 125, 229, 176, 105, 228, 38, 41, 11, 16, 214, 244, 12, 206,
375 244, 7, 242
376 ],
377 hash: [
378 66, 206, 30, 244, 218, 251, 152, 206, 154, 190, 225, 47, 211, 103, 240, 218,
379 225, 103, 193, 10
380 ],
381 }
382 );
383 }
385}