diff --git a/src/host_file.rs b/src/host_file.rs index 4453bfb..fbc2376 100644 --- a/src/host_file.rs +++ b/src/host_file.rs @@ -1,8 +1,9 @@ //! Support for OpenSSH-compatible `known_hosts` file. use guard::guard; -use bytes::Bytes; +use bytes::{Bytes, BytesMut}; use hmac::Mac as _; +use rand::RngCore as _; use std::str; use crate::pubkey::Pubkey; @@ -16,14 +17,20 @@ use crate::pubkey::Pubkey; /// You can iterate over all entries using [`entries()`][Self::entries()], or you can use /// [`match_hostname_key()`][Self::match_hostname_key()]/[`match_host_port_key()`][Self::match_host_port_key()] /// to lookup the entries that either accept or revoke a given combination of host and key. +/// +/// You can also append new entries to the file using [`append_entry()`][Self::append_entry()]. In +/// this way, it is possible to update the `known_hosts` file with new keys, without touching the +/// previous entries (all previous lines will be preserved verbatim, including comments and invalid +/// lines). #[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] pub struct File { lines: Vec, } #[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] struct Line { - #[allow(dead_code)] // be ready for writing files bytes: Bytes, content: LineContent, } @@ -32,7 +39,7 @@ struct Line { #[cfg_attr(test, derive(PartialEq))] enum LineContent { Comment, - Entry(Entry), + Entry(Box), Error(&'static str), } @@ -49,7 +56,18 @@ pub struct Entry { key_comment: Option, } +/// Builder that can be used to add [entries][Entry] to a [`File`]. +/// +/// You can obtain an instance of the builder using [`File::entry_builder()`]. #[derive(Debug, Clone)] +pub struct EntryBuilder { + is_revoked: bool, + hashed_hostnames: Vec, + plaintext_hostnames: Vec, + keys: Vec, +} + +#[derive(Debug, Copy, Clone)] #[cfg_attr(test, derive(PartialEq))] enum Marker { CertAuthority, @@ -74,6 +92,7 @@ struct HashedPattern { struct PlaintextPattern { is_negated: bool, regex: regex::Regex, + pattern: String, } /// A match returned by [`File::match_hostname_key()`]. @@ -97,8 +116,13 @@ impl File { /// Parses a file in OpenSSH `known_hosts` format. /// /// This function never fails: invalid lines are silently ignored. - pub fn parse(data: Bytes) -> Self { - parse(data) + pub fn decode(data: Bytes) -> Self { + decode_file(data) + } + + /// Creates a new empty [`File`]. + pub fn new() -> Self { + Self { lines: Vec::new() } } /// Iterates through all entries in the file. @@ -106,7 +130,7 @@ impl File { /// Comments and invalid lines are not returned by this method. pub fn entries(&self) -> impl Iterator { self.lines.iter().filter_map(|line| match &line.content { - LineContent::Entry(entry) => Some(entry), + LineContent::Entry(entry) => Some(&*entry as &Entry), LineContent::Comment | LineContent::Error(_) => None, }) } @@ -164,6 +188,43 @@ impl File { format!("[{}]:{}", host, port) } } + + /// Creates an [`EntryBuilder`], which can be used to add an entry (or a set of entries) to the + /// file (see [`append_entry()`][Self::append_entry()]). + pub fn entry_builder() -> EntryBuilder { + EntryBuilder::new() + } + + /// Appends all entries from the [`EntryBuilder`] to this file. + pub fn append_entry(&mut self, builder: &EntryBuilder) { + for entry in builder.build(self.lines.len()) { + self.lines.push(Line { + bytes: encode_entry(&entry).into(), + content: LineContent::Entry(Box::new(entry)), + }); + } + } + + /// Encodes this file into an OpenSSH-compatible `known_hosts` file. + /// + /// If the file was created using [`decode()`][Self::decode()], the original lines will be + /// preserved verbatim, including comments and invalid lines. Entries added using + /// [`append_entry()`][Self::append_entry()] will be appended as new lines at the end of the + /// file. + pub fn encode(&self) -> BytesMut { + let mut bytes = BytesMut::new(); + for line in self.lines.iter() { + bytes.extend_from_slice(&line.bytes); + bytes.extend_from_slice("\n".as_bytes()); + } + bytes + } +} + +impl Default for File { + fn default() -> Self { + Self::new() + } } impl Entry { @@ -198,6 +259,105 @@ impl Entry { } } +impl EntryBuilder { + fn new() -> Self { + EntryBuilder { + is_revoked: false, + hashed_hostnames: Vec::new(), + plaintext_hostnames: Vec::new(), + keys: Vec::new(), + } + } + + /// Marks the entries as revoked (using `@revoked`). + /// + /// This means that the given hostnames must not use the given keys. + pub fn revoke(&mut self) -> &mut Self { + self.is_revoked = true; self + } + + /// Adds a given hostname in plaintext. + /// + /// See [`File::host_port_to_hostname()`] for the format of the `hostname`; you can use + /// [`plaintext_host_port()`][Self::plaintext_host_port()] to add a `(host, port)` pair. + /// + /// The hostname will be added in plaintext, so anybody who has access to `known_hosts` can see + /// which hostnames you connected to. See [`hostname()`][Self::hostname()] if you want to hide + /// the hostname. + pub fn plaintext_hostname(&mut self, hostname: &str) -> &mut Self { + self.plaintext_hostnames.push(hostname.into()); self + } + + /// Adds a given host and port in plaintext. + /// + /// The host and port will be added in plaintext, so anybody who has access to `known_hosts` + /// can see which hostnames you connected to. See [`hostname()`][Self::hostname()] if you want + /// to hide the hostname. + pub fn plaintext_host_port(&mut self, host: &str, port: u16) -> &mut Self { + self.plaintext_hostnames.push(File::host_port_to_hostname(host, port)); self + } + + /// Adds a given hostname in a hashed form. + /// + /// See [`File::host_port_to_hostname()`] for the format of the `hostname`; you can use + /// [`plaintext_host_port()`][Self::plaintext_host_port()] to add a `(host, port)` pair. + /// + /// The hostname will be stored in the file as a HMAC-SHA1 hash with a random salt. This hides + /// the hostname if the file is disclosed. + pub fn hostname(&mut self, hostname: &str) -> &mut Self { + self.hashed_hostnames.push(hostname.into()); self + } + + /// Adds a given host and port in a hashed form. + /// + /// The host and port will be stored in the file as a HMAC-SHA1 hash with a random salt. This + /// hides the host and port if the file is disclosed. + pub fn host_port(&mut self, host: &str, port: u16) -> &mut Self { + self.hashed_hostnames.push(File::host_port_to_hostname(host, port)); self + } + + /// Adds a public key. + /// + /// We will generate an entry for every combination of a hostname (added by + /// [`plaintext_hostname()`][Self::plaintext_hostname()], [`hostname()`][Self::hostname()] and + /// other methods) and a public key added by this method. + pub fn key(&mut self, pubkey: Pubkey) -> &mut Self { + self.keys.push(pubkey); self + } + + fn build(&self, mut line_i: usize) -> Vec { + let marker = if self.is_revoked { Some(Marker::Revoked) } else { None }; + + let mut entries = Vec::new(); + for key in self.keys.iter() { + for hostname in self.hashed_hostnames.iter() { + entries.push(Entry { + line_i, + marker, + pattern: Pattern::Hashed(build_hashed_pattern(hostname)), + key: key.clone(), + key_comment: None, + }); + line_i += 1; + } + + if !self.plaintext_hostnames.is_empty() { + entries.push(Entry { + line_i, + marker, + pattern: Pattern::List(self.plaintext_hostnames.iter() + .map(build_plaintext_pattern) + .collect()), + key: key.clone(), + key_comment: None, + }); + line_i += 1; + } + } + entries + } +} + fn pattern_matches(pattern: &Pattern, hostname: &str) -> bool { match pattern { Pattern::Hashed(pattern) => { @@ -223,22 +383,27 @@ fn pattern_matches(pattern: &Pattern, hostname: &str) -> bool { } } -fn parse(data: Bytes) -> File { - let lines = data.split(|&b| b == b'\n') +fn decode_file(data: Bytes) -> File { + let mut lines = data.split(|&b| b == b'\n') .enumerate() .map(|(line_i, bytes)| { let bytes = data.slice_ref(bytes); - let content = match parse_line(&bytes, line_i) { + let content = match decode_line(&bytes, line_i) { Ok(content) => content, Err(msg) => LineContent::Error(msg), }; Line { bytes, content } }) - .collect(); + .collect::>(); + if let Some(last_line) = lines.last() { + if last_line.bytes.is_empty() { + lines.pop(); + } + } File { lines } } -fn parse_line(mut bytes: &[u8], line_i: usize) -> Result { +fn decode_line(mut bytes: &[u8], line_i: usize) -> Result { // empty lines are treated as comments guard!{let Some(first_field) = read_field(&mut bytes) else { return Ok(LineContent::Comment) @@ -246,7 +411,7 @@ fn parse_line(mut bytes: &[u8], line_i: usize) -> Result Result Result Result { +fn encode_entry(entry: &Entry) -> String { + let mut output = String::new(); + if let Some(marker) = entry.marker { + encode_marker(marker, &mut output); + output.push(' '); + } + + encode_pattern(&entry.pattern, &mut output); + output.push(' '); + output.push_str(&entry.key.type_str()); + output.push(' '); + output.push_str(&base64::encode(&entry.key.encode())); + + if let Some(comment) = &entry.key_comment { + output.push(' '); + output.push_str(comment); + } + + output +} + +fn decode_marker(bytes: &[u8]) -> Result { match bytes { b"@cert-authority" => Ok(Marker::CertAuthority), b"@revoked" => Ok(Marker::Revoked), @@ -288,15 +474,29 @@ fn parse_marker(bytes: &[u8]) -> Result { } } -fn parse_pattern(bytes: &[u8]) -> Result { +fn encode_marker(marker: Marker, output: &mut String) { + match marker { + Marker::CertAuthority => output.push_str("@cert-authority"), + Marker::Revoked => output.push_str("@revoked"), + } +} + +fn decode_pattern(bytes: &[u8]) -> Result { if let Some(bytes) = bytes.strip_prefix(b"|1|") { - parse_hashed_pattern(bytes).map(Pattern::Hashed) + decode_hashed_pattern(bytes).map(Pattern::Hashed) } else { - parse_list_pattern(bytes).map(Pattern::List) + decode_list_pattern(bytes).map(Pattern::List) + } +} + +fn encode_pattern(pattern: &Pattern, output: &mut String) { + match pattern { + Pattern::Hashed(pattern) => encode_hashed_pattern(pattern, output), + Pattern::List(pattern) => encode_list_pattern(pattern, output), } } -fn parse_hashed_pattern(bytes: &[u8]) -> Result { +fn decode_hashed_pattern(bytes: &[u8]) -> Result { let mut parts = bytes.splitn(2, |&b| b == b'|'); let salt_base64 = parts.next().ok_or("invalid format of hashed pattern")?; let hash_base64 = parts.next().ok_or("expected a pipe '|' in the hashed pattern")?; @@ -306,18 +506,43 @@ fn parse_hashed_pattern(bytes: &[u8]) -> Result { Ok(HashedPattern { salt, hash }) } -fn parse_list_pattern(bytes: &[u8]) -> Result, &'static str> { +fn encode_hashed_pattern(pattern: &HashedPattern, output: &mut String) { + output.push_str("|1|"); + output.push_str(&base64::encode(&pattern.salt)); + output.push('|'); + output.push_str(&base64::encode(&pattern.hash)); +} + +fn build_hashed_pattern(hostname: &str) -> HashedPattern { + let mut salt = vec![0; 20]; + rand::rngs::OsRng.fill_bytes(&mut salt); + let mut hmac = hmac::Hmac::::new_from_slice(&salt).unwrap(); + hmac.update(hostname.as_bytes()); + let hash = hmac.finalize().into_bytes().as_slice().into(); + HashedPattern { salt, hash } +} + +fn decode_list_pattern(bytes: &[u8]) -> Result, &'static str> { bytes.split(|&b| b == b',') .filter(|bs| !bs.is_empty()) - .map(parse_plaintext_pattern) + .map(decode_plaintext_pattern) .collect() } -fn parse_plaintext_pattern(bytes: &[u8]) -> Result { +fn encode_list_pattern(patterns: &[PlaintextPattern], output: &mut String) { + for (i, pattern) in patterns.iter().enumerate() { + if i != 0 { + output.push(','); + } + encode_plaintext_pattern(pattern, output); + } +} + +fn decode_plaintext_pattern(bytes: &[u8]) -> Result { let mut pattern = str::from_utf8(bytes).map_err(|_| "host pattern is not valid utf-8")?; let mut is_negated = false; - if let Some(p) = pattern.strip_prefix("!") { + if let Some(p) = pattern.strip_prefix('!') { pattern = p; is_negated = true; } @@ -327,7 +552,7 @@ fn parse_plaintext_pattern(bytes: &[u8]) -> Result regex.push_str(".*"), - '?' => regex.push_str("."), + '?' => regex.push('.'), c if regex_syntax::is_meta_character(c) => { regex.push('\\'); regex.push(c); @@ -338,7 +563,23 @@ fn parse_plaintext_pattern(bytes: &[u8]) -> Result PlaintextPattern { + let regex = format!("^{}$", regex_syntax::escape(hostname)); + let regex = regex::Regex::new(®ex).unwrap(); + let pattern = hostname.clone(); + PlaintextPattern { is_negated: false, regex, pattern } } fn read_field<'b>(bytes: &mut &'b [u8]) -> Option<&'b [u8]> { @@ -379,15 +620,16 @@ mod tests { impl std::cmp::PartialEq for PlaintextPattern { fn eq(&self, other: &Self) -> bool { self.is_negated == other.is_negated && - self.regex.as_str() == other.regex.as_str() + self.regex.as_str() == other.regex.as_str() && + self.pattern == other.pattern } } #[test] - fn test_parse_hashed_pattern() { + fn test_decode_hashed_pattern() { fn check(pattern: &str, salt: &[u8], hash: &[u8]) { assert_eq!( - parse_hashed_pattern(pattern.as_bytes()).unwrap(), + decode_hashed_pattern(pattern.as_bytes()).unwrap(), HashedPattern { salt: salt.into(), hash: hash.into(), @@ -409,54 +651,67 @@ mod tests { &hex!("b19ef40e4f5ebaaec805d760ba36e7814696c1e7"), ); - assert!(parse_hashed_pattern("".as_bytes()).is_err()); - assert!(parse_hashed_pattern("deadbeef".as_bytes()).is_err()); - assert!(parse_hashed_pattern("invalid|sZ70Dk9euq7IBddgujbngUaWwec=".as_bytes()).is_err()); + assert!(decode_hashed_pattern("".as_bytes()).is_err()); + assert!(decode_hashed_pattern("deadbeef".as_bytes()).is_err()); + assert!(decode_hashed_pattern("invalid|sZ70Dk9euq7IBddgujbngUaWwec=".as_bytes()).is_err()); } #[test] - fn test_parse_plaintext_pattern() { - fn check(text: &str, is_negated: bool, regex: &str) { + fn test_decode_plaintext_pattern() { + fn check(text: &str, is_negated: bool, regex: &str, pattern: &str) { assert_eq!( - parse_plaintext_pattern(text.as_bytes()).unwrap(), + decode_plaintext_pattern(text.as_bytes()).unwrap(), PlaintextPattern { is_negated, regex: Regex::new(regex).unwrap(), + pattern: pattern.into(), } ); } - check("example.com", false, r"^example\.com$"); - check("!example.com", true, r"^example\.com$"); - check("1.203.45.67", false, r"^1\.203\.45\.67$"); - check("*.example.com", false, r"^.*\.example\.com$"); - check("*.exam?le.com", false, r"^.*\.exam.le\.com$"); - check("example.*.??", false, r"^example\..*\...$"); - check("[8.8.4.4]:1234", false, r"^\[8\.8\.4\.4\]:1234$"); + check("example.com", false, r"^example\.com$", "example.com"); + check("!example.com", true, r"^example\.com$", "example.com"); + check("1.203.45.67", false, r"^1\.203\.45\.67$", "1.203.45.67"); + check("*.example.com", false, r"^.*\.example\.com$", "*.example.com"); + check("*.exam?le.com", false, r"^.*\.exam.le\.com$", "*.exam?le.com"); + check("example.*.??", false, r"^example\..*\...$", "example.*.??"); + check("[8.8.4.4]:1234", false, r"^\[8\.8\.4\.4\]:1234$", "[8.8.4.4]:1234"); - assert!(parse_plaintext_pattern(&b"\xff"[..]).is_err()); + assert!(decode_plaintext_pattern(&b"\xff"[..]).is_err()); } #[test] - fn test_parse_list_pattern() { - fn check(text: &str, patterns: &[(bool, &str)]) { + fn test_decode_list_pattern() { + fn check(text: &str, patterns: &[(bool, &str, &str)]) { assert_eq!( - parse_list_pattern(text.as_bytes()).unwrap(), - patterns.iter().map(|&(is_negated, regex)| { - PlaintextPattern { is_negated, regex: Regex::new(regex).unwrap() } + decode_list_pattern(text.as_bytes()).unwrap(), + patterns.iter().map(|&(is_negated, regex, pattern)| { + PlaintextPattern { + is_negated, + regex: Regex::new(regex).unwrap(), + pattern: pattern.into(), + } }).collect::>(), ); } - check("topsecret.maralagoclub.com", &[(false, r"^topsecret\.maralagoclub\.com$")]); - check("github.com,gitlab.org", &[(false, r"^github\.com$"), (false, r"^gitlab\.org$")]); - check("*.com,!github.com", &[(false, r"^.*\.com$"), (true, r"^github\.com$")]); + check("topsecret.maralagoclub.com", &[ + (false, r"^topsecret\.maralagoclub\.com$", "topsecret.maralagoclub.com"), + ]); + check("github.com,gitlab.org", &[ + (false, r"^github\.com$", "github.com"), + (false, r"^gitlab\.org$", "gitlab.org"), + ]); + check("*.com,!github.com", &[ + (false, r"^.*\.com$", "*.com"), + (true, r"^github\.com$", "github.com"), + ]); } #[test] - fn test_parse_pattern() { + fn test_decode_pattern() { assert_eq!( - parse_pattern("|1|kRjF0OC+k0NXr8wZhiz/+2qYE+M=|8/wJhcR4K2kE/vz6LJH7m06YQTM=".as_bytes()).unwrap(), + decode_pattern("|1|kRjF0OC+k0NXr8wZhiz/+2qYE+M=|8/wJhcR4K2kE/vz6LJH7m06YQTM=".as_bytes()).unwrap(), Pattern::Hashed(HashedPattern { salt: hex!("9118c5d0e0be934357afcc19862cfffb6a9813e3").into(), hash: hex!("f3fc0985c4782b6904fefcfa2c91fb9b4e984133").into(), @@ -464,32 +719,34 @@ mod tests { ); assert_eq!( - parse_pattern("!exampl?.com,*.com".as_bytes()).unwrap(), + decode_pattern("!exampl?.com,*.com".as_bytes()).unwrap(), Pattern::List(vec![ PlaintextPattern { is_negated: true, regex: Regex::new(r"^exampl.\.com$").unwrap(), + pattern: "exampl?.com".into(), }, PlaintextPattern { is_negated: false, regex: Regex::new(r"^.*\.com$").unwrap(), + pattern: "*.com".into(), }, ]), ); } #[test] - fn test_parse_line() { + fn test_decode_line() { fn check_error(text: &str) { - assert!(parse_line(text.as_bytes(), 42).is_err()); + assert!(decode_line(text.as_bytes(), 42).is_err()); } fn check_comment(text: &str) { - assert_eq!(parse_line(text.as_bytes(), 42).unwrap(), LineContent::Comment); + assert_eq!(decode_line(text.as_bytes(), 42).unwrap(), LineContent::Comment); } fn check_entry(text: String, entry: Entry) { - assert_eq!(parse_line(text.as_bytes(), 42).unwrap(), LineContent::Entry(entry)); + assert_eq!(decode_line(text.as_bytes(), 42).unwrap(), LineContent::Entry(Box::new(entry))); } check_comment(""); @@ -507,6 +764,7 @@ mod tests { pattern: Pattern::List(vec![PlaintextPattern { is_negated: false, regex: Regex::new(r"^example\.com$").unwrap(), + pattern: "example.com".into(), }]), key: pubkey.clone(), key_comment: Some("edward".into()), @@ -518,6 +776,7 @@ mod tests { pattern: Pattern::List(vec![PlaintextPattern { is_negated: false, regex: Regex::new(r"^example\.com$").unwrap(), + pattern: "example.com".into(), }]), key: pubkey.clone(), key_comment: None, @@ -531,7 +790,7 @@ mod tests { #[test] fn test_pattern_matches() { fn check(pattern_text: &str, examples: &[(&str, bool)]) { - let pattern = parse_pattern(pattern_text.as_bytes()).unwrap(); + let pattern = decode_pattern(pattern_text.as_bytes()).unwrap(); for (hostname, should_match) in examples.iter() { assert_eq!(pattern_matches(&pattern, hostname), *should_match, "{:?} {:?}", pattern_text, hostname); @@ -573,35 +832,35 @@ mod tests { ]); } - #[test] - fn test_file() { - fn check_accepted(file: &File, host: &str, port: u16, pubkey: &Pubkey, checks: Vec) { - match file.match_host_port_key(host, port, pubkey) { - KeyMatch::Accepted(entries) => { - assert_eq!(entries.len(), checks.len()); - for (entry, check) in entries.iter().zip(checks.iter().copied()) { - check(entry); - } - }, - res => panic!("expected Accepted, got: {:?}", res), - } + fn check_accepted(file: &File, host: &str, port: u16, pubkey: &Pubkey, checks: Vec) { + match file.match_host_port_key(host, port, pubkey) { + KeyMatch::Accepted(entries) => { + assert_eq!(entries.len(), checks.len()); + for (entry, check) in entries.iter().zip(checks.iter().copied()) { + check(entry); + } + }, + res => panic!("expected Accepted, got: {:?}", res), } + } - fn check_revoked(file: &File, host: &str, port: u16, pubkey: &Pubkey, check: fn(&Entry)) { - match file.match_host_port_key(host, port, pubkey) { - KeyMatch::Revoked(entry) => check(entry), - res => panic!("expected Revoked, got {:?}", res), - } + fn check_revoked(file: &File, host: &str, port: u16, pubkey: &Pubkey, check: fn(&Entry)) { + match file.match_host_port_key(host, port, pubkey) { + KeyMatch::Revoked(entry) => check(entry), + res => panic!("expected Revoked, got {:?}", res), } + } - fn check_not_found(file: &File, host: &str, port: u16, pubkey: &Pubkey) { - match file.match_host_port_key(host, port, pubkey) { - KeyMatch::NotFound => (), - res => panic!("expected NotFound, got {:?}", res), - } + fn check_not_found(file: &File, host: &str, port: u16, pubkey: &Pubkey) { + match file.match_host_port_key(host, port, pubkey) { + KeyMatch::NotFound => (), + res => panic!("expected NotFound, got {:?}", res), } + } - let file = File::parse(concat!( + #[test] + fn test_file() { + let file = File::decode(concat!( // line 1 "# this is an example comment\n", // line 2 @@ -661,6 +920,91 @@ mod tests { ); } + #[test] + fn test_mutate_file() { + let mut file = File::decode(concat!( + "*.gitlab.org ssh-ed25519 ", + "AAAAC3NzaC1lZDI1NTE5AAAAIAklXWCTvkbJ2y9Ib9CpRvIfVykSdgOBHiDC/dv1hZKz alice\n", + ).into()); + + let edward = keys::edward_ed25519().pubkey(); + let alice = keys::alice_ed25519().pubkey(); + + file.append_entry(File::entry_builder() + .plaintext_host_port("secure.gitlab.com", 22) + .key(edward.clone())); + + file.append_entry(File::entry_builder() + .revoke() + .host_port("insecure.gitlab.com", 2222) + .key(edward.clone())); + + check_accepted(&file, "www.gitlab.org", 22, &alice, vec![ + |e| assert_eq!(e.line(), 1), + ]); + check_accepted(&file, "secure.gitlab.com", 22, &edward, vec![ + |e| assert_eq!(e.line(), 2), + ]); + check_revoked(&file, "insecure.gitlab.com", 2222, &edward, + |e| assert_eq!(e.line(), 3), + ); + } + + #[test] + fn test_encode_file() { + let mut file = File::decode(concat!( + "# this is a comment\n", + "syntax error\n", + "*.gitlab.org ssh-ed25519 ", + "AAAAC3NzaC1lZDI1NTE5AAAAIAklXWCTvkbJ2y9Ib9CpRvIfVykSdgOBHiDC/dv1hZKz alice\n", + ).into()); + + file.append_entry(File::entry_builder() + .plaintext_host_port("secure.gitlab.org", 22) + .plaintext_host_port("github.com", 2222) + .key(keys::alice_ed25519().pubkey()) + .key(keys::edward_ed25519().pubkey())); + + let file_bytes = file.encode(); + assert_eq!(str::from_utf8(&file_bytes).unwrap(), concat!( + "# this is a comment\n", + "syntax error\n", + "*.gitlab.org ssh-ed25519 ", + "AAAAC3NzaC1lZDI1NTE5AAAAIAklXWCTvkbJ2y9Ib9CpRvIfVykSdgOBHiDC/dv1hZKz alice\n", + "secure.gitlab.org,[github.com]:2222 ssh-ed25519 ", + "AAAAC3NzaC1lZDI1NTE5AAAAIAklXWCTvkbJ2y9Ib9CpRvIfVykSdgOBHiDC/dv1hZKz\n", + "secure.gitlab.org,[github.com]:2222 ssh-ed25519 ", + "AAAAC3NzaC1lZDI1NTE5AAAAIPJUmxF+H42aRAqDYOHqs9Wh2JDecL51WgYygy1hxswl\n", + )); + } + + #[test] + fn test_encode_decode_file() { + let mut file1 = File::decode(concat!( + "# this is a comment\n", + "syntax error\n", + "*.gitlab.org ssh-ed25519 ", + "AAAAC3NzaC1lZDI1NTE5AAAAIAklXWCTvkbJ2y9Ib9CpRvIfVykSdgOBHiDC/dv1hZKz alice\n", + ).into()); + + file1.append_entry(File::entry_builder() + .plaintext_host_port("github.com", 22) + .host_port("gitlab.org", 22) + .key(keys::edward_ed25519().pubkey()) + .key(keys::eda_ecdsa_p256().pubkey())); + + file1.append_entry(File::entry_builder() + .host_port("localhost", 2222) + .key(keys::ruth_rsa_2048().pubkey())); + + let bytes2 = file1.encode().freeze(); + let file3 = File::decode(bytes2.clone()); + let bytes4 = file3.encode().freeze(); + + assert_eq!(file1, file3); + assert_eq!(bytes2, bytes4); + } + #[allow(dead_code)] mod keys { mod makiko {