From 1e6bebc57343b7896ff92e40796a2e6d6c00f963 Mon Sep 17 00:00:00 2001 From: CanWang Date: Tue, 10 Nov 2026 14:49:36 +0800 Subject: [PATCH] known_hosts: split fields on any whitespace, not a single space `Entry::from_str` split the line with `split_once(' ')`, which only recognises a line whose fields are separated by exactly one ASCII space. OpenSSH's known_hosts format separates fields by any run of whitespace, so three shapes that OpenSSH reads correctly were rejected outright: * tab-separated: `host\tssh-ed25519\t` -> FormatEncoding * indented: ` host ssh-ed25519 ` -> FormatEncoding * runs of spaces: `host ssh-ed25519 ` -> Encoding(Length) Because the error is per-line, `KnownHosts::new(..)` silently yields an `Err` for such a line and a caller that stops at the first error never sees the rest of the file. Reproduced against `ssh-key` 0.7.0-rc.12; OpenSSH 8.2p1 accepts all three shapes (`ssh-keygen -F` matches each of them). The fix splits on `char::is_whitespace` instead, taking the optional marker as a token rather than assuming a single space follows it, and re-joins the remainder with single spaces before handing it to `PublicKey`'s parser -- which does require exactly one space between `keytype` and the base64 body (`decode_segment` in public/ssh_format.rs stops only on `b' '`). `marker` is therefore now built as `format!("@{marker_str}")` from the token with the `@` already stripped. Tests added for all three shapes plus a marker followed by a tab. --- ssh-key/src/known_hosts.rs | 75 +++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/ssh-key/src/known_hosts.rs b/ssh-key/src/known_hosts.rs index 4c265d59..99370839 100644 --- a/ssh-key/src/known_hosts.rs +++ b/ssh-key/src/known_hosts.rs @@ -162,13 +162,30 @@ impl str::FromStr for Entry { // not a reliable indicator of the fields in the line. Instead, // the optional marker field starts with an @, so look for that // and act accordingly. - let (marker, line) = if line.starts_with('@') { - let (marker_str, line) = line.split_once(' ').ok_or(Error::FormatEncoding)?; - (Some(marker_str.parse()?), line) - } else { - (None, line) + // + // Fields are separated by any run of whitespace rather than by exactly + // one space: OpenSSH writes `keytype key` with a single space, but real + // files are hand-edited and commonly use tabs, several spaces, or an + // indented first field. Splitting on whitespace also removes the leading + // whitespace that an indented line would otherwise leave in front of the + // host field, and tolerates a marker followed by a tab. + let mut fields = line.split_whitespace(); + + let first = fields.next().ok_or(Error::FormatEncoding)?; + let (marker, hosts_str) = match first.strip_prefix('@') { + Some(marker_str) => { + let marker = format!("@{marker_str}").parse()?; + (Some(marker), fields.next().ok_or(Error::FormatEncoding)?) + } + None => (None, first), }; - let (hosts_str, public_key_str) = line.split_once(' ').ok_or(Error::FormatEncoding)?; + + // The key parser expects `keytype key` separated by a single space, so + // the remainder is re-joined with single spaces. + let public_key_str = fields.collect::>().join(" "); + if public_key_str.is_empty() { + return Err(Error::FormatEncoding); + } let host_patterns = hosts_str.parse()?; let public_key = public_key_str.parse()?; @@ -327,6 +344,52 @@ mod tests { // closenet,...,192.0.2.53 1024 37 159...93 closenet.example.net // I'm not sure how this one is supposed to work or what it means. + #[test] + fn tab_separated_fields() { + let entry: Entry = "cvs.example.net\tssh-ed25519\tAAAAC3NzaC1lZDI1NTE5AAAAIB9dG4kjRhQTtWTVzd2t27+t0DEHBPW7iOD23TUiYLio" + .parse() + .expect("tab-separated entry"); + assert_eq!(entry.marker(), None); + assert_eq!( + entry.host_patterns(), + &HostPatterns::Patterns(vec!["cvs.example.net".to_string()]) + ); + } + + #[test] + fn leading_whitespace_is_ignored() { + let entry: Entry = " cvs.example.net ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB9dG4kjRhQTtWTVzd2t27+t0DEHBPW7iOD23TUiYLio" + .parse() + .expect("indented entry"); + assert_eq!( + entry.host_patterns(), + &HostPatterns::Patterns(vec!["cvs.example.net".to_string()]) + ); + } + + #[test] + fn several_spaces_between_fields() { + let entry: Entry = "cvs.example.net ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB9dG4kjRhQTtWTVzd2t27+t0DEHBPW7iOD23TUiYLio" + .parse() + .expect("entry with runs of spaces"); + assert_eq!( + entry.host_patterns(), + &HostPatterns::Patterns(vec!["cvs.example.net".to_string()]) + ); + } + + #[test] + fn a_marker_may_be_followed_by_a_tab() { + let entry: Entry = "@revoked\tcvs.example.net\tssh-ed25519\tAAAAC3NzaC1lZDI1NTE5AAAAIB9dG4kjRhQTtWTVzd2t27+t0DEHBPW7iOD23TUiYLio" + .parse() + .expect("tab-separated entry with a marker"); + assert_eq!(entry.marker(), Some(&Marker::Revoked)); + assert_eq!( + entry.host_patterns(), + &HostPatterns::Patterns(vec!["cvs.example.net".to_string()]) + ); + } + #[test] fn single_host_pattern() { assert_eq!(