use super::format::{HostSpec, Marker};
use super::{KnownHosts, LookupResult};
struct TestTempDir {
path: std::path::PathBuf,
}
impl TestTempDir {
fn new(prefix: &str) -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!("puressh-kh-{prefix}-{pid}-{nanos}"));
std::fs::create_dir_all(&path).expect("create tempdir");
Self { path }
}
fn child(&self, name: &str) -> std::path::PathBuf {
self.path.join(name)
}
}
impl Drop for TestTempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn ed25519_blob(seed: u8) -> Vec<u8> {
let mut v = vec![0u8; 32];
v[0] = seed;
v[31] = seed.wrapping_add(0xA5);
v
}
#[test]
fn parse_then_save_roundtrips_lines_verbatim() {
let src = b"# header comment\n\
\n\
example.com ssh-ed25519 AAAA my-comment\n\
[example.com]:2222 ssh-rsa AAAB\n\
|1|salt+salt+salt+salt+salt+s=|hash+hash+hash+hash+hash+ha= ssh-ed25519 AAAC\n\
# tail comment\n";
let kh = KnownHosts::from_bytes(src);
let out = kh.to_bytes();
let out_str = String::from_utf8(out).unwrap();
assert!(out_str.contains("# header comment"));
assert!(out_str.contains("# tail comment"));
assert!(out_str.contains("example.com ssh-ed25519 AAAA my-comment"));
assert!(out_str.contains("[example.com]:2222 ssh-rsa AAAB"));
assert!(
out_str.contains(
"|1|salt+salt+salt+salt+salt+s=|hash+hash+hash+hash+hash+ha= ssh-ed25519 AAAC"
)
);
}
#[test]
fn lookup_plain_match_mismatch_unknown() {
let mut kh = KnownHosts::new();
let good = ed25519_blob(1);
let other = ed25519_blob(2);
kh.add("example.com", 22, "ssh-ed25519", &good, false);
match kh.lookup("example.com", 22, "ssh-ed25519", &good) {
LookupResult::Match => {}
r => panic!("expected Match, got {r:?}"),
}
match kh.lookup("example.com", 22, "ssh-ed25519", &other) {
LookupResult::Mismatch { expected } => {
assert_eq!(expected, vec![("ssh-ed25519".to_string(), good.clone())]);
}
r => panic!("expected Mismatch, got {r:?}"),
}
match kh.lookup("other.invalid", 22, "ssh-ed25519", &good) {
LookupResult::Unknown => {}
r => panic!("expected Unknown, got {r:?}"),
}
}
#[test]
fn lookup_honours_port_in_bracketed_entries() {
let mut kh = KnownHosts::new();
let key = ed25519_blob(3);
kh.add("example.com", 2222, "ssh-ed25519", &key, false);
assert!(matches!(
kh.lookup("example.com", 22, "ssh-ed25519", &key),
LookupResult::Unknown
));
assert!(matches!(
kh.lookup("example.com", 2222, "ssh-ed25519", &key),
LookupResult::Match
));
}
#[test]
fn multiple_keys_per_host_all_accepted() {
let mut kh = KnownHosts::new();
let k1 = ed25519_blob(11);
let k2 = ed25519_blob(22);
kh.add("example.com", 22, "ssh-ed25519", &k1, false);
kh.add("example.com", 22, "ssh-rsa", &k2, false);
assert!(matches!(
kh.lookup("example.com", 22, "ssh-ed25519", &k1),
LookupResult::Match
));
assert!(matches!(
kh.lookup("example.com", 22, "ssh-rsa", &k2),
LookupResult::Match
));
let k3 = ed25519_blob(33);
match kh.lookup("example.com", 22, "ssh-ed25519", &k3) {
LookupResult::Mismatch { expected } => {
assert_eq!(expected.len(), 2);
}
r => panic!("expected Mismatch, got {r:?}"),
}
}
#[test]
fn revoked_marker_refuses_matching_key() {
let src = b"@revoked example.com ssh-ed25519 AAAA\n";
let kh = KnownHosts::from_bytes(src);
let blob = crate::key::base64::decode(b"AAAA").unwrap();
assert!(matches!(
kh.lookup("example.com", 22, "ssh-ed25519", &blob),
LookupResult::Revoked
));
}
#[test]
fn revoked_marker_outranks_plain_entry_and_is_host_scoped() {
let src = b"example.com ssh-ed25519 AAAA\n\
@revoked example.com ssh-ed25519 AAAA\n\
other.example.com ssh-ed25519 AAAA\n";
let kh = KnownHosts::from_bytes(src);
let blob = crate::key::base64::decode(b"AAAA").unwrap();
assert!(matches!(
kh.lookup("example.com", 22, "ssh-ed25519", &blob),
LookupResult::Revoked
));
assert!(matches!(
kh.lookup("other.example.com", 22, "ssh-ed25519", &blob),
LookupResult::Match
));
assert!(matches!(
kh.lookup("example.com", 22, "ssh-ed25519", &ed25519_blob(9)),
LookupResult::Mismatch { .. }
));
}
#[test]
fn cert_authority_marker_is_ignored_for_plain_host_keys() {
let src = b"@cert-authority *.example.com ssh-ed25519 AAAA\n";
let kh = KnownHosts::from_bytes(src);
let ca_blob = crate::key::base64::decode(b"AAAA").unwrap();
let other = ed25519_blob(42);
assert!(matches!(
kh.lookup("host.example.com", 22, "ssh-ed25519", &other),
LookupResult::Unknown
));
assert!(matches!(
kh.lookup("host.example.com", 22, "ssh-ed25519", &ca_blob),
LookupResult::Unknown
));
assert_eq!(kh.find("host.example.com", 22).len(), 1);
}
#[test]
fn plain_entry_beside_cert_authority_wildcard_decides_alone() {
let src = b"@cert-authority *.example.com ssh-ed25519 AAAA\n\
host.example.com ssh-ed25519 AAAB\n";
let kh = KnownHosts::from_bytes(src);
let plain = crate::key::base64::decode(b"AAAB").unwrap();
assert!(matches!(
kh.lookup("host.example.com", 22, "ssh-ed25519", &plain),
LookupResult::Match
));
match kh.lookup("host.example.com", 22, "ssh-ed25519", &ed25519_blob(7)) {
LookupResult::Mismatch { expected } => {
assert_eq!(expected.len(), 1, "CA key must not be reported as expected");
assert_eq!(expected[0].1, plain);
}
r => panic!("expected Mismatch, got {r:?}"),
}
}
#[test]
fn remove_exact_spares_shared_and_marker_lines() {
let src = b"@cert-authority *.example.com ssh-ed25519 AAAA\n\
@revoked host.example.com ssh-ed25519 AAAB\n\
*.example.com ssh-ed25519 AAAC\n\
host.example.com,alias.example.com ssh-ed25519 AAAD\n\
!bad.example.com,host.example.com ssh-ed25519 AAAE\n\
HOST.example.com ssh-ed25519 AAAF\n\
host.example.com ssh-rsa AAAG\n\
[host.example.com]:2222 ssh-ed25519 AAAH\n";
let mut kh = KnownHosts::from_bytes(src);
assert_eq!(kh.find("host.example.com", 22).len(), 7);
assert_eq!(kh.remove_exact("host.example.com", 22), 2);
let out = String::from_utf8(kh.to_bytes()).unwrap();
assert!(out.contains("@cert-authority *.example.com"));
assert!(out.contains("@revoked host.example.com"));
assert!(out.contains("*.example.com ssh-ed25519 AAAC"));
assert!(out.contains("host.example.com,alias.example.com"));
assert!(out.contains("!bad.example.com,host.example.com"));
assert!(out.contains("[host.example.com]:2222"));
assert!(!out.contains("HOST.example.com ssh-ed25519 AAAF"));
assert!(!out.contains("host.example.com ssh-rsa AAAG"));
let revoked = crate::key::base64::decode(b"AAAB").unwrap();
assert!(matches!(
kh.lookup("host.example.com", 22, "ssh-ed25519", &revoked),
LookupResult::Revoked
));
let mut kh = KnownHosts::new();
kh.add(
"host.example.com",
22,
"ssh-ed25519",
&ed25519_blob(1),
true,
);
kh.add(
"host.example.com",
2222,
"ssh-ed25519",
&ed25519_blob(2),
true,
);
kh.add(
"other.example.com",
22,
"ssh-ed25519",
&ed25519_blob(3),
true,
);
assert_eq!(kh.remove_exact("host.example.com", 22), 1);
assert!(matches!(
kh.lookup("host.example.com", 22, "ssh-ed25519", &ed25519_blob(1)),
LookupResult::Unknown
));
assert!(matches!(
kh.lookup("host.example.com", 2222, "ssh-ed25519", &ed25519_blob(2)),
LookupResult::Match
));
assert!(matches!(
kh.lookup("other.example.com", 22, "ssh-ed25519", &ed25519_blob(3)),
LookupResult::Match
));
}
#[test]
fn add_then_save_then_load_roundtrip() {
let dir = TestTempDir::new("save-load");
let path = dir.child("known_hosts");
let key = ed25519_blob(7);
let mut kh = KnownHosts::new();
kh.add("example.com", 22, "ssh-ed25519", &key, false);
kh.add("alt.example.com", 2222, "ssh-rsa", &ed25519_blob(8), false);
kh.save(&path).expect("save");
let kh2 = KnownHosts::load(&path).expect("load");
assert!(matches!(
kh2.lookup("example.com", 22, "ssh-ed25519", &key),
LookupResult::Match
));
assert!(matches!(
kh2.lookup("alt.example.com", 2222, "ssh-rsa", &ed25519_blob(8)),
LookupResult::Match
));
}
#[test]
fn load_missing_file_returns_empty_store() {
let dir = TestTempDir::new("missing");
let path = dir.child("nope");
let kh = KnownHosts::load(&path).expect("missing → empty");
assert!(matches!(
kh.lookup("anywhere", 22, "ssh-ed25519", b"x"),
LookupResult::Unknown
));
}
#[test]
fn add_hashed_then_lookup_works() {
let key = ed25519_blob(9);
let mut kh = KnownHosts::new();
kh.add("example.com", 22, "ssh-ed25519", &key, true);
assert!(matches!(
kh.lookup("example.com", 22, "ssh-ed25519", &key),
LookupResult::Match
));
assert!(matches!(
kh.lookup("evil.example", 22, "ssh-ed25519", &key),
LookupResult::Unknown
));
let entries = kh.find("example.com", 22);
assert_eq!(entries.len(), 1);
match &entries[0].host_spec {
HostSpec::Hashed(token) => assert!(token.starts_with("|1|")),
_ => panic!("expected hashed host_spec"),
}
}
#[test]
fn remove_drops_matching_entries() {
let mut kh = KnownHosts::new();
kh.add("a.example.com", 22, "ssh-ed25519", &ed25519_blob(1), false);
kh.add("b.example.com", 22, "ssh-ed25519", &ed25519_blob(2), false);
kh.add("a.example.com", 22, "ssh-rsa", &ed25519_blob(3), false);
assert_eq!(kh.remove("a.example.com", 22), 2);
assert!(matches!(
kh.lookup("b.example.com", 22, "ssh-ed25519", &ed25519_blob(2)),
LookupResult::Match
));
assert!(matches!(
kh.lookup("a.example.com", 22, "ssh-ed25519", &ed25519_blob(1)),
LookupResult::Unknown
));
}
#[test]
fn hash_in_place_replaces_plain_entries_with_hashed_tokens() {
let mut kh = KnownHosts::new();
let key = ed25519_blob(15);
kh.add("example.com", 22, "ssh-ed25519", &key, false);
kh.add("alt.example.com", 2222, "ssh-rsa", &ed25519_blob(16), false);
kh.hash_in_place();
assert!(matches!(
kh.lookup("example.com", 22, "ssh-ed25519", &key),
LookupResult::Match
));
assert!(matches!(
kh.lookup("alt.example.com", 2222, "ssh-rsa", &ed25519_blob(16)),
LookupResult::Match
));
let bytes = kh.to_bytes();
let text = String::from_utf8(bytes).unwrap();
for line in text.lines() {
if line.is_empty() || line.starts_with('#') {
continue;
}
assert!(
line.starts_with("|1|"),
"expected hashed line, got: {line:?}"
);
}
}
#[test]
fn hashed_entry_parses_and_matches_known_host() {
let key = ed25519_blob(21);
let mut kh = KnownHosts::new();
kh.add("example.com", 22, "ssh-ed25519", &key, true);
let bytes = kh.to_bytes();
let kh2 = KnownHosts::from_bytes(&bytes);
assert!(matches!(
kh2.lookup("example.com", 22, "ssh-ed25519", &key),
LookupResult::Match
));
}
#[test]
fn marker_round_trips_through_save_load() {
let dir = TestTempDir::new("marker");
let path = dir.child("known_hosts");
let blob = crate::key::base64::decode(b"AAAA").unwrap();
let src = b"@cert-authority *.example.com ssh-ed25519 AAAA\n\
@revoked bad.example.com ssh-ed25519 AAAA\n";
std::fs::write(&path, src).unwrap();
let kh = KnownHosts::load(&path).expect("load");
assert!(matches!(
kh.lookup("host.example.com", 22, "ssh-ed25519", &blob),
LookupResult::Unknown
));
assert!(matches!(
kh.lookup("bad.example.com", 22, "ssh-ed25519", &blob),
LookupResult::Revoked
));
kh.save(&path).unwrap();
let kh2 = KnownHosts::load(&path).unwrap();
let markers: Vec<_> = kh2
.find("bad.example.com", 22)
.iter()
.map(|e| e.marker)
.collect();
assert!(markers.contains(&Some(Marker::Revoked)));
assert!(markers.contains(&Some(Marker::CertAuthority)));
assert!(matches!(
kh2.lookup("bad.example.com", 22, "ssh-ed25519", &blob),
LookupResult::Revoked
));
}
#[test]
fn lookup_ipv6_default_port_matches_bare_pattern() {
let mut kh = KnownHosts::new();
let key = ed25519_blob(20);
kh.add("2001:db8::1", 22, "ssh-ed25519", &key, false);
assert!(matches!(
kh.lookup("2001:db8::1", 22, "ssh-ed25519", &key),
LookupResult::Match
));
}
#[test]
fn lookup_ipv6_non_default_port_matches_bracketed_pattern() {
let mut kh = KnownHosts::new();
let key = ed25519_blob(21);
kh.add("2001:db8::1", 2222, "ssh-ed25519", &key, false);
assert!(matches!(
kh.lookup("2001:db8::1", 2222, "ssh-ed25519", &key),
LookupResult::Match
));
assert!(matches!(
kh.lookup("2001:db8::1", 22, "ssh-ed25519", &key),
LookupResult::Unknown
));
}
#[test]
fn ipv6_save_load_roundtrip_preserves_match() {
let dir = TestTempDir::new("v6-roundtrip");
let path = dir.child("known_hosts");
let k1 = ed25519_blob(30);
let k2 = ed25519_blob(31);
let mut kh = KnownHosts::new();
kh.add("2001:db8::1", 22, "ssh-ed25519", &k1, false);
kh.add("2001:db8::2", 2222, "ssh-ed25519", &k2, false);
kh.save(&path).expect("save");
let kh2 = KnownHosts::load(&path).expect("load");
assert!(matches!(
kh2.lookup("2001:db8::1", 22, "ssh-ed25519", &k1),
LookupResult::Match
));
assert!(matches!(
kh2.lookup("2001:db8::2", 2222, "ssh-ed25519", &k2),
LookupResult::Match
));
}
#[test]
fn ipv6_hash_in_place_then_lookup_works() {
let mut kh = KnownHosts::new();
let key = ed25519_blob(32);
kh.add("2001:db8::1", 2222, "ssh-ed25519", &key, false);
kh.hash_in_place();
assert!(matches!(
kh.lookup("2001:db8::1", 2222, "ssh-ed25519", &key),
LookupResult::Match
));
let entries = kh.find("2001:db8::1", 2222);
assert_eq!(entries.len(), 1);
match &entries[0].host_spec {
HostSpec::Hashed(token) => assert!(token.starts_with("|1|")),
_ => panic!("expected hashed host_spec after hash_in_place"),
}
}
#[test]
fn known_hosts_file_with_bracketed_v6_line_loads_and_matches() {
let dir = TestTempDir::new("v6-handwritten");
let path = dir.child("known_hosts");
let blob = crate::key::base64::decode(b"AAAA").unwrap();
let src = b"2001:db8::1 ssh-ed25519 AAAA\n\
[2001:db8::2]:2222 ssh-ed25519 AAAA\n";
std::fs::write(&path, src).unwrap();
let kh = KnownHosts::load(&path).expect("load");
assert!(matches!(
kh.lookup("2001:db8::1", 22, "ssh-ed25519", &blob),
LookupResult::Match
));
assert!(matches!(
kh.lookup("2001:db8::2", 2222, "ssh-ed25519", &blob),
LookupResult::Match
));
}
#[cfg(unix)]
#[test]
fn save_produces_mode_0600_file() {
use std::os::unix::fs::PermissionsExt as _;
let dir = TestTempDir::new("perms");
let path = dir.child("known_hosts");
let mut kh = KnownHosts::new();
kh.add("example.com", 22, "ssh-ed25519", &ed25519_blob(1), false);
kh.save(&path).expect("save");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"known_hosts must be owner-only (0o600), got {mode:o}"
);
}
#[test]
fn non_default_port_falls_back_to_bare_host_key() {
let mut kh = KnownHosts::new();
let key = ed25519_blob(5);
let other = ed25519_blob(6);
kh.add("example.com", 22, "ssh-ed25519", &key, false);
assert!(matches!(
kh.lookup("example.com", 2222, "ssh-ed25519", &key),
LookupResult::Match
));
assert!(matches!(
kh.lookup("example.com", 2222, "ssh-ed25519", &other),
LookupResult::Unknown
));
kh.add("example.com", 2222, "ssh-ed25519", &other, false);
assert!(matches!(
kh.lookup("example.com", 2222, "ssh-ed25519", &key),
LookupResult::Mismatch { .. }
));
}
#[test]
fn revoked_host_keys_list_and_krl() {
use crate::known_hosts::RevokedHostKeys;
let a = ed25519_blob(7);
let b = ed25519_blob(8);
let text = format!(
"# revoked\n\nssh-ed25519 {} old host\n",
crate::key::base64::encode(&a)
);
let r = RevokedHostKeys::parse(text.as_bytes()).unwrap();
assert!(r.is_revoked_key(&a));
assert!(!r.is_revoked_key(&b));
assert!(RevokedHostKeys::parse(b"garbage\n").is_err());
assert!(!RevokedHostKeys::parse(b"").unwrap().is_revoked_key(&a));
}
#[test]
fn rsa_entry_under_a_signature_name_is_an_rsa_key() {
let kh = KnownHosts::from_bytes(b"example.com rsa-sha2-512 AAAB\n");
match kh.lookup("example.com", 22, "ssh-rsa", &[0, 0, 1]) {
LookupResult::Match => {}
r => panic!("expected Match, got {r:?}"),
}
}
#[test]
fn host_keys_update_plans_new_and_obsolete_keys() {
let (a, b, c) = (ed25519_blob(1), ed25519_blob(2), ed25519_blob(3));
let mut kh = KnownHosts::new();
kh.add("h", 22, "ssh-ed25519", &a, false);
kh.add("h", 22, "ssh-ed25519", &b, false);
kh.add("other", 22, "ssh-ed25519", &c, false);
let d = ed25519_blob(4);
let plan = kh.host_keys_update("h", 22, &[a.clone(), d.clone()]);
assert_eq!(plan.new, vec![1]);
assert_eq!(plan.old, vec![b.clone()]);
assert_eq!(plan.blocked, None);
kh.update_host_keys(
"h",
22,
&plan.old,
&[("ssh-ed25519".into(), d.clone())],
false,
);
assert!(matches!(
kh.lookup("h", 22, "ssh-ed25519", &d),
LookupResult::Match
));
assert!(matches!(
kh.lookup("h", 22, "ssh-ed25519", &b),
LookupResult::Mismatch { .. }
));
assert!(matches!(
kh.lookup("other", 22, "ssh-ed25519", &c),
LookupResult::Match
));
assert!(kh.host_keys_update("h", 22, &[c]).blocked.is_some());
}
#[test]
fn add_refuses_fields_that_would_inject_lines() {
let mut kh = KnownHosts::new();
kh.add("victim.example", 22, "ssh-ed25519", &ed25519_blob(1), false);
let evil = "ssh-ed25519 AAAA\n* ssh-ed25519 YXR0YWNrZXJrZXk=\n#";
kh.add("malicious.example", 22, evil, b"whatever", false);
for (host, key_type) in [
("h", "ssh-ed25519\r"),
("h", "ssh-ed25519 x"),
("h", "ssh-ed25519,ssh-rsa"),
("h", "@cert-authority"),
("h", "#c"),
("h", ""),
("a\nb", "ssh-ed25519"),
("a b", "ssh-ed25519"),
("a,*", "ssh-ed25519"),
("@revoked", "ssh-ed25519"),
("|1|x", "ssh-ed25519"),
("#x", "ssh-ed25519"),
("", "ssh-ed25519"),
] {
assert!(
kh.try_add(host, 22, key_type, &ed25519_blob(2), false)
.is_err(),
"{host:?} {key_type:?}"
);
}
let out = kh.to_bytes();
assert_eq!(out.iter().filter(|&&b| b == b'\n').count(), 1);
let reloaded = KnownHosts::from_bytes(&out);
assert!(matches!(
reloaded.lookup("victim.example", 22, "ssh-ed25519", b"attackerkey"),
LookupResult::Mismatch { .. }
));
kh.try_add("2001:db8::1", 2222, "ssh-ed25519", &ed25519_blob(3), true)
.unwrap();
}
#[test]
fn non_utf8_lines_preserved_and_revoked_still_revokes() {
let key = ed25519_blob(9);
let b64 = crate::key::base64::encode(&key);
let mut data = Vec::new();
data.extend_from_slice(format!("@revoked * ssh-ed25519 {b64} caf").as_bytes());
data.push(0xE9); data.push(b'\n');
data.extend_from_slice(b"junk \xff\xfe\n");
data.extend_from_slice(format!("host.example ssh-ed25519 {b64}\n").as_bytes());
let kh = KnownHosts::from_bytes(&data);
assert_eq!(kh.to_bytes(), data, "raw lines kept verbatim");
assert!(matches!(
kh.lookup("host.example", 22, "ssh-ed25519", &key),
LookupResult::Revoked
));
}