1use russh::keys::{ssh_key::PublicKey, HashAlg};
36
37use crate::error::AnvilError;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CertAuthority {
46 pub host_pattern: String,
50 pub algorithm: String,
52 pub fingerprint: String,
56 pub openssh: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RevokedEntry {
73 pub host_pattern: String,
75 pub fingerprint: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct DirectHostKey {
85 pub host_pattern: String,
86 pub fingerprint: String,
87}
88
89#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct KnownHostsFile {
95 pub direct: Vec<DirectHostKey>,
96 pub cert_authorities: Vec<CertAuthority>,
97 pub revoked: Vec<RevokedEntry>,
98}
99
100pub fn parse_known_hosts(content: &str) -> Result<KnownHostsFile, AnvilError> {
113 let mut out = KnownHostsFile::default();
114
115 for (idx, raw) in content.lines().enumerate() {
116 let line = raw.trim();
117 if line.is_empty() || line.starts_with('#') {
118 continue;
119 }
120 let line_no = idx + 1;
121
122 if line.starts_with("|1|") {
123 log::debug!(
124 "known_hosts: line {line_no} is a hashed entry; skipping (not yet supported)"
125 );
126 continue;
127 }
128
129 if let Some(rest) = strip_marker_ci(line, "@cert-authority") {
130 parse_cert_authority_line(rest, line_no, &mut out)?;
131 continue;
132 }
133 if let Some(rest) = strip_marker_ci(line, "@revoked") {
134 parse_revoked_line(rest, line_no, &mut out);
135 continue;
136 }
137
138 let mut parts = line.splitn(2, char::is_whitespace);
140 let Some(host_part) = parts.next() else {
141 continue;
142 };
143 let Some(fp_part) = parts.next() else {
144 continue;
145 };
146 let fp = fp_part.trim();
147 if fp.is_empty() {
148 continue;
149 }
150 for host in split_host_patterns(host_part) {
151 out.direct.push(DirectHostKey {
152 host_pattern: host,
153 fingerprint: fp.to_owned(),
154 });
155 }
156 }
157
158 Ok(out)
159}
160
161fn strip_marker_ci<'a>(line: &'a str, marker: &str) -> Option<&'a str> {
165 if line.len() <= marker.len() {
166 return None;
167 }
168 let head = line.get(..marker.len())?;
169 if !head.eq_ignore_ascii_case(marker) {
170 return None;
171 }
172 let rest = &line[marker.len()..];
173 let trimmed = rest.trim_start();
174 if !rest.starts_with(char::is_whitespace) || trimmed.is_empty() {
175 return None;
177 }
178 Some(trimmed)
179}
180
181fn parse_cert_authority_line(
185 rest: &str,
186 line_no: usize,
187 out: &mut KnownHostsFile,
188) -> Result<(), AnvilError> {
189 let mut parts = rest.splitn(2, char::is_whitespace);
190 let Some(host_part) = parts.next() else {
191 return Err(AnvilError::invalid_config(format!(
192 "known_hosts:{line_no}: @cert-authority line missing host pattern",
193 )));
194 };
195 let Some(key_part) = parts.next() else {
196 return Err(AnvilError::invalid_config(format!(
197 "known_hosts:{line_no}: @cert-authority line missing pubkey",
198 )));
199 };
200
201 let key_part = key_part.trim();
202 let pk = PublicKey::from_openssh(key_part).map_err(|e| {
203 AnvilError::invalid_config(format!(
204 "known_hosts:{line_no}: failed to parse @cert-authority pubkey: {e}",
205 ))
206 })?;
207 let algorithm = pk.algorithm().as_str().to_owned();
208 let fingerprint = pk.fingerprint(HashAlg::Sha256).to_string();
209
210 for host in split_host_patterns(host_part) {
211 out.cert_authorities.push(CertAuthority {
212 host_pattern: host,
213 algorithm: algorithm.clone(),
214 fingerprint: fingerprint.clone(),
215 openssh: key_part.to_owned(),
216 });
217 }
218 Ok(())
219}
220
221fn parse_revoked_line(rest: &str, line_no: usize, out: &mut KnownHostsFile) {
224 let mut parts = rest.splitn(2, char::is_whitespace);
225 let Some(host_part) = parts.next() else {
226 log::warn!("known_hosts:{line_no}: @revoked line missing host pattern");
227 return;
228 };
229 let Some(fp_part) = parts.next() else {
230 log::warn!("known_hosts:{line_no}: @revoked line missing fingerprint");
231 return;
232 };
233 let fp = fp_part.trim();
234 if fp.is_empty() {
235 log::warn!("known_hosts:{line_no}: @revoked line has empty fingerprint");
236 return;
237 }
238 for host in split_host_patterns(host_part) {
239 out.revoked.push(RevokedEntry {
240 host_pattern: host,
241 fingerprint: fp.to_owned(),
242 });
243 }
244}
245
246fn split_host_patterns(column: &str) -> Vec<String> {
249 column
250 .split(',')
251 .map(str::trim)
252 .filter(|s| !s.is_empty())
253 .map(str::to_owned)
254 .collect()
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn empty_input_yields_default() {
263 let parsed = parse_known_hosts("").expect("empty");
264 assert_eq!(parsed, KnownHostsFile::default());
265 }
266
267 #[test]
268 fn comments_and_blanks_skipped() {
269 let parsed = parse_known_hosts(
270 "# top comment\n\
271 \n\
272 # another\n",
273 )
274 .expect("parse");
275 assert_eq!(parsed, KnownHostsFile::default());
276 }
277
278 #[test]
279 fn direct_fingerprint_line() {
280 let parsed =
281 parse_known_hosts("github.com SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s\n")
282 .expect("parse");
283 assert_eq!(parsed.direct.len(), 1);
284 assert_eq!(parsed.direct[0].host_pattern, "github.com");
285 assert_eq!(
286 parsed.direct[0].fingerprint,
287 "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s",
288 );
289 assert!(parsed.cert_authorities.is_empty());
290 assert!(parsed.revoked.is_empty());
291 }
292
293 #[test]
294 fn comma_separated_hosts_split_into_multiple_entries() {
295 let parsed =
296 parse_known_hosts("github.com,gitlab.com,codeberg.org SHA256:abcd\n").expect("parse");
297 assert_eq!(parsed.direct.len(), 3);
298 let hosts: Vec<&str> = parsed
299 .direct
300 .iter()
301 .map(|d| d.host_pattern.as_str())
302 .collect();
303 assert_eq!(hosts, vec!["github.com", "gitlab.com", "codeberg.org"]);
304 }
305
306 #[test]
307 fn cert_authority_line_parsed() {
308 let parsed = parse_known_hosts(
312 "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca-key\n",
313 )
314 .expect("parse");
315 assert_eq!(parsed.cert_authorities.len(), 1);
316 let ca = &parsed.cert_authorities[0];
317 assert_eq!(ca.host_pattern, "*.example.com");
318 assert_eq!(ca.algorithm, "ssh-ed25519");
319 assert!(
320 ca.fingerprint.starts_with("SHA256:"),
321 "expected SHA256 fp, got: {}",
322 ca.fingerprint,
323 );
324 assert!(parsed.direct.is_empty());
325 assert!(parsed.revoked.is_empty());
326 }
327
328 #[test]
329 fn cert_authority_marker_case_insensitive() {
330 let parsed = parse_known_hosts(
331 "@CERT-AUTHORITY *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n",
332 )
333 .expect("parse");
334 assert_eq!(parsed.cert_authorities.len(), 1);
335 }
336
337 #[test]
338 fn cert_authority_invalid_pubkey_errors() {
339 let err = parse_known_hosts("@cert-authority *.example.com ssh-ed25519 not-base64-data\n")
340 .expect_err("malformed pubkey");
341 let msg = format!("{err}");
342 assert!(
343 msg.contains("@cert-authority"),
344 "expected error to mention @cert-authority, got: {msg}",
345 );
346 }
347
348 #[test]
349 fn revoked_line_parsed() {
350 let parsed =
351 parse_known_hosts("@revoked example.com SHA256:abcdefghijklmnop\n").expect("parse");
352 assert_eq!(parsed.revoked.len(), 1);
353 assert_eq!(parsed.revoked[0].host_pattern, "example.com");
354 assert_eq!(parsed.revoked[0].fingerprint, "SHA256:abcdefghijklmnop");
355 assert!(parsed.direct.is_empty());
356 assert!(parsed.cert_authorities.is_empty());
357 }
358
359 #[test]
360 fn revoked_marker_case_insensitive() {
361 let parsed = parse_known_hosts("@REVOKED * SHA256:a\n").expect("parse");
362 assert_eq!(parsed.revoked.len(), 1);
363 assert_eq!(parsed.revoked[0].host_pattern, "*");
364 }
365
366 #[test]
367 fn revoked_with_comma_hosts() {
368 let parsed =
369 parse_known_hosts("@revoked a.example.com,b.example.com SHA256:abc\n").expect("parse");
370 assert_eq!(parsed.revoked.len(), 2);
371 assert_eq!(parsed.revoked[0].host_pattern, "a.example.com");
372 assert_eq!(parsed.revoked[1].host_pattern, "b.example.com");
373 }
374
375 #[test]
376 fn revoked_missing_fingerprint_logged_and_skipped() {
377 let parsed = parse_known_hosts("@revoked example.com\n").expect("parse");
381 assert!(parsed.revoked.is_empty());
382 }
383
384 #[test]
385 fn hashed_entry_skipped_silently() {
386 let parsed = parse_known_hosts(
387 "|1|abcdef==|fedcba== ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n",
388 )
389 .expect("parse");
390 assert!(parsed.direct.is_empty());
393 assert!(parsed.cert_authorities.is_empty());
394 }
395
396 #[test]
397 fn mixed_file_three_classes() {
398 let parsed = parse_known_hosts(
399 "# header\n\
400 github.com SHA256:fp1\n\
401 @cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n\
402 @revoked github.com SHA256:bad-fp\n\
403 gitlab.com SHA256:fp2\n",
404 )
405 .expect("parse");
406 assert_eq!(parsed.direct.len(), 2);
407 assert_eq!(parsed.cert_authorities.len(), 1);
408 assert_eq!(parsed.revoked.len(), 1);
409 assert_eq!(parsed.direct[0].host_pattern, "github.com");
410 assert_eq!(parsed.direct[1].host_pattern, "gitlab.com");
411 assert_eq!(parsed.cert_authorities[0].host_pattern, "*.example.com");
412 assert_eq!(parsed.revoked[0].host_pattern, "github.com");
413 }
414
415 #[test]
416 fn marker_without_trailing_space_not_treated_as_marker() {
417 let parsed = parse_known_hosts("@cert-authoritynot-a-marker\n").expect("parse");
421 assert_eq!(parsed, KnownHostsFile::default());
422 }
423
424 #[test]
425 fn whitespace_around_fields_tolerated() {
426 let parsed = parse_known_hosts(" github.com\tSHA256:fp\n").expect("parse");
427 assert_eq!(parsed.direct.len(), 1);
428 assert_eq!(parsed.direct[0].host_pattern, "github.com");
429 assert_eq!(parsed.direct[0].fingerprint, "SHA256:fp");
430 }
431}