Skip to main content

sequel_mcp/sql/
known_hosts.rs

1//! SSH known_hosts parsing, matching and verification (`sshHostKey.ts`
2//! port): plain, wildcard, `[host]:port`, hashed `|1|` entries,
3//! `@cert-authority`/`@revoked` markers, SHA-256 fingerprints.
4
5use base64::Engine;
6use hmac;
7use sha1;
8use sha2::{Digest, Sha256};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct KnownHostEntry {
12    pub host_patterns: Vec<String>,
13    pub key_type: String,
14    pub key_base64: String,
15    pub marker: Option<&'static str>, // "@cert-authority" | "@revoked"
16    pub hashed: Option<(Vec<u8>, Vec<u8>)>, // (salt, expected hmac)
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct VerifyResult {
21    pub fingerprint: String,
22    pub matched: bool,
23    pub had_matching_host_entry: bool,
24    pub revoked: bool,
25}
26
27pub fn parse_known_hosts(content: &str) -> Vec<KnownHostEntry> {
28    let mut entries = Vec::new();
29    for raw_line in content.split('\n') {
30        let line = raw_line.trim_end_matches('\r').trim();
31        if line.is_empty() || line.starts_with('#') {
32            continue;
33        }
34        let parts: Vec<&str> = line.split_whitespace().collect();
35        let mut cursor = 0usize;
36        let mut marker = None;
37        if parts[0] == "@cert-authority" || parts[0] == "@revoked" {
38            marker = Some(match parts[0] {
39                "@cert-authority" => "@cert-authority",
40                _ => "@revoked",
41            });
42            cursor = 1;
43        }
44        if parts.len() < cursor + 3 {
45            continue;
46        }
47        let host_field = parts[cursor];
48        let key_type = parts[cursor + 1].to_string();
49        let key_base64 = parts[cursor + 2].to_string();
50
51        let mut hashed = None;
52        let mut host_patterns = Vec::new();
53        if let Some(rest) = host_field.strip_prefix("|1|") {
54            let segments: Vec<&str> = rest.split('|').collect();
55            if segments.len() >= 2 {
56                let salt = b64_decode(segments[0]);
57                let hash = b64_decode(segments[1]);
58                if let (Some(salt), Some(hash)) = (salt, hash) {
59                    hashed = Some((salt, hash));
60                }
61            }
62        } else {
63            host_patterns = host_field
64                .split(',')
65                .map(str::trim)
66                .filter(|s| !s.is_empty())
67                .map(str::to_string)
68                .collect();
69        }
70        entries.push(KnownHostEntry {
71            host_patterns,
72            key_type,
73            key_base64,
74            marker,
75            hashed,
76        });
77    }
78    entries
79}
80
81fn b64_decode(s: &str) -> Option<Vec<u8>> {
82    base64::engine::general_purpose::STANDARD.decode(s).ok()
83}
84
85fn matches_pattern(pattern: &str, host: &str, port: u16) -> bool {
86    let mut p = pattern.to_string();
87    let mut port_match = true;
88    if p.starts_with('[') {
89        if let Some(close) = p.find("]:") {
90            let port_part = p[close + 2..].to_string();
91            p = p[1..close].to_string();
92            port_match = port_part == port.to_string();
93        }
94    } else if port != 22 {
95        port_match = false;
96    }
97    if !port_match {
98        return false;
99    }
100    if p == host {
101        return true;
102    }
103    if p.contains('*') || p.contains('?') {
104        let mut re = String::from("^");
105        for c in p.chars() {
106            match c {
107                '*' => re.push_str(".*"),
108                '?' => re.push('.'),
109                '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' => {
110                    re.push('\\');
111                    re.push(c);
112                }
113                other => re.push(other),
114            }
115        }
116        re.push('$');
117        // Shell-style glob patterns are ASCII; use a manual match to avoid
118        // pulling a regex dependency.
119        glob_match(&re[1..re.len() - 1], host)
120    } else {
121        false
122    }
123}
124
125/// Tiny glob matcher for the wildcard translation (`.*` / `.`).
126fn glob_match(pattern: &str, text: &str) -> bool {
127    let p: Vec<char> = pattern.chars().collect();
128    let t: Vec<char> = text.chars().collect();
129    fn rec(p: &[char], t: &[char]) -> bool {
130        if p.is_empty() {
131            return t.is_empty();
132        }
133        if p[0] == '.' && p.len() > 1 && p[1] == '*' {
134            for i in 0..=t.len() {
135                if rec(&p[2..], &t[i..]) {
136                    return true;
137                }
138            }
139            return false;
140        }
141        if t.is_empty() {
142            return false;
143        }
144        if p[0] == '.' && p.len() > 1 && p[1] == '\\' {
145            return p.len() > 2 && t[0] == p[2] && rec(&p[2..], &t[1..]);
146        }
147        if p[0] == '\\' && p.len() > 1 {
148            return t[0] == p[1] && rec(&p[2..], &t[1..]);
149        }
150        (p[0] == t[0]) && rec(&p[1..], &t[1..])
151    }
152    rec(&p, &t)
153}
154
155fn hashed_host_matches(entry: &KnownHostEntry, host: &str, port: u16) -> bool {
156    let Some((salt, expected)) = &entry.hashed else {
157        return false;
158    };
159    let probes: Vec<String> = if port == 22 {
160        vec![host.to_string()]
161    } else {
162        vec![format!("[{host}]:{port}")]
163    };
164    for probe in probes {
165        // OpenSSH hashed known_hosts format: HMAC-SHA1(salt, hostname).
166        // Retained deliberately — it is the file format's definition, used
167        // only for hostname matching (not secret protection).
168        let tag = hmac_sha1(salt, probe.as_bytes());
169        if tag.as_slice() == expected.as_slice() {
170            return true;
171        }
172    }
173    false
174}
175
176pub fn match_host<'a>(
177    entries: &'a [KnownHostEntry],
178    host: &str,
179    port: u16,
180) -> Vec<&'a KnownHostEntry> {
181    entries
182        .iter()
183        .filter(|e| {
184            if e.hashed.is_some() {
185                hashed_host_matches(e, host, port)
186            } else {
187                e.host_patterns
188                    .iter()
189                    .any(|p| matches_pattern(p, host, port))
190            }
191        })
192        .collect()
193}
194
195pub fn fingerprint_sha256(raw_key: &[u8]) -> String {
196    let digest = Sha256::digest(raw_key);
197    let b64 = base64::engine::general_purpose::STANDARD.encode(digest);
198    format!("SHA256:{}", b64.trim_end_matches('='))
199}
200
201pub fn key_matches_entry(raw_key: &[u8], entry: &KnownHostEntry) -> bool {
202    let Some(entry_key) = b64_decode(&entry.key_base64) else {
203        return false;
204    };
205    if entry_key.len() != raw_key.len() {
206        // Length mismatch resisted without an early-content oracle.
207        let mut diff: u8 = 1;
208        for (a, b) in entry_key.iter().zip(raw_key.iter()) {
209            diff |= a ^ b;
210        }
211        let _ = diff;
212        return false;
213    }
214    let mut diff: u8 = 0;
215    for (a, b) in entry_key.iter().zip(raw_key.iter()) {
216        diff |= a ^ b;
217    }
218    diff == 0
219}
220
221pub fn verify_host_key(
222    raw_key: &[u8],
223    host: &str,
224    port: u16,
225    entries: &[KnownHostEntry],
226) -> VerifyResult {
227    let fingerprint = fingerprint_sha256(raw_key);
228    let candidates = match_host(entries, host, port);
229    let mut matched = false;
230    let mut revoked = false;
231    for e in &candidates {
232        if key_matches_entry(raw_key, e) {
233            if e.marker == Some("@revoked") {
234                revoked = true;
235            } else {
236                matched = true;
237            }
238        }
239    }
240    VerifyResult {
241        fingerprint,
242        matched,
243        had_matching_host_entry: !candidates.is_empty(),
244        revoked,
245    }
246}
247
248pub fn load_known_hosts(file_path: Option<&std::path::Path>) -> Vec<KnownHostEntry> {
249    let target = file_path
250        .map(std::path::PathBuf::from)
251        .unwrap_or_else(|| crate::app::paths::expand_tilde("~/.ssh/known_hosts"));
252    match std::fs::read_to_string(target) {
253        Ok(content) => parse_known_hosts(&content),
254        Err(_) => Vec::new(),
255    }
256}
257
258/// Fail-closed loader: an EXPLICITLY configured known_hosts file that
259/// cannot be read, or that contains no parseable entry at all, is a
260/// deny (in every policy mode) rather than a silently-empty host set.
261/// An absent DEFAULT path (~/.ssh/known_hosts) stays an empty set —
262/// strict then rejects unknown hosts anyway, lenient TOFU-accepts.
263pub fn load_known_hosts_checked(
264    file_path: Option<&std::path::Path>,
265) -> Result<Vec<KnownHostEntry>, String> {
266    let explicit = file_path.is_some();
267    let target = match file_path {
268        Some(p) => {
269            // Expand a leading `~` for explicit paths too (validation
270            // accepts absolute or `~`-prefixed; relative paths are
271            // rejected at config time — they would resolve against a
272            // client-controllable CWD).
273            let s = p.to_string_lossy();
274            if s.starts_with('~') {
275                crate::app::paths::expand_tilde(&s)
276            } else {
277                p.to_path_buf()
278            }
279        }
280        None => crate::app::paths::expand_tilde("~/.ssh/known_hosts"),
281    };
282    match std::fs::read_to_string(&target) {
283        Err(e) if explicit => Err(format!(
284            "known_hosts file {} is unreadable: {e}",
285            target.display()
286        )),
287        Err(_) => Ok(Vec::new()),
288        Ok(content) => {
289            if content.trim().is_empty() {
290                return Ok(Vec::new());
291            }
292            let entries = parse_known_hosts(&content);
293            if entries.is_empty() {
294                Err(format!(
295                    "known_hosts file {} is malformed: no parseable entries",
296                    target.display()
297                ))
298            } else {
299                Ok(entries)
300            }
301        }
302    }
303}
304
305/// Decision for `check_server_key` in the SSH client handler.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum HostKeyDecision {
308    Accept,
309    Reject,
310}
311
312pub fn decide_host_key(
313    policy: crate::config::SshHostKeyPolicy,
314    host: &str,
315    port: u16,
316    entries: &[KnownHostEntry],
317    raw_key: &[u8],
318    log: &mut impl FnMut(String),
319) -> HostKeyDecision {
320    let r = verify_host_key(raw_key, host, port, entries);
321    log(format!(
322        "SSH host {host}:{port} fingerprint={}",
323        r.fingerprint
324    ));
325    if r.revoked {
326        log(format!(
327            "SSH host key REVOKED for {host}:{port} — rejecting"
328        ));
329        return HostKeyDecision::Reject;
330    }
331    if policy == crate::config::SshHostKeyPolicy::Strict {
332        if !r.had_matching_host_entry {
333            log(format!(
334                "SSH host {host}:{port} not in known_hosts (strict mode — rejecting). Fingerprint={}",
335                r.fingerprint
336            ));
337            return HostKeyDecision::Reject;
338        }
339        if !r.matched {
340            log(format!(
341                "SSH host key MISMATCH for {host}:{port} (strict mode — rejecting). Got={}",
342                r.fingerprint
343            ));
344            return HostKeyDecision::Reject;
345        }
346        return HostKeyDecision::Accept;
347    }
348    // lenient (migration compatibility ONLY, warned loudly). A MISMATCH
349    // means the server identity changed or is being impersonated — that
350    // is rejected in EVERY mode. Only a genuinely unknown host may be
351    // accepted under lenient, with a high-visibility warning.
352    if r.had_matching_host_entry && !r.matched {
353        log(format!(
354            "SSH host key MISMATCH for {host}:{port} (lenient mode — REJECTING). Got={}",
355            r.fingerprint
356        ));
357        return HostKeyDecision::Reject;
358    }
359    if !r.had_matching_host_entry {
360        log(format!(
361            "SSH host {host}:{port} not in known_hosts (lenient mode — accepting, migration compatibility only). Add {} to enable strict mode.",
362            r.fingerprint
363        ));
364    }
365    HostKeyDecision::Accept
366}
367
368/// HMAC-SHA1 over `data` with key `salt`, used ONLY to match OpenSSH's
369/// hashed known_hosts entry format (`|1|salt|hash`). SHA-1 here is a
370/// file-format compatibility requirement, not a chosen cryptographic
371/// primitive — it is never used for signatures, password hashing, or any
372/// secret-protection purpose.
373fn hmac_sha1(key: &[u8], data: &[u8]) -> [u8; 20] {
374    // hmac 0.13: key construction lives on `KeyInit`, not `Mac`.
375    use hmac::{KeyInit, Mac};
376    type HmacSha1 = hmac::Hmac<sha1::Sha1>;
377    let mut mac = HmacSha1::new_from_slice(key).expect("HMAC accepts any key length");
378    mac.update(data);
379    mac.finalize().into_bytes().into()
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    const PUB: &[u8] = b"AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterial0123456789abcdefghijklm";
387    const OTHER: &[u8] = b"AAAAC3NzaC1lZDI1NTE5AAAAIDifferentKeyMaterial9876543210mlkjihgfe";
388
389    fn hosts() -> Vec<KnownHostEntry> {
390        let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
391        parse_known_hosts(&format!(
392            "# comment\n\
393             \n\
394             host-a.example.invalid ssh-ed25519 {}\n\
395             host-a.example.invalid,alias ssh-ed25519 {}\n\
396             [host-a.example.invalid]:2222 ssh-ed25519 {}\n\
397             *.wild.example.invalid ssh-ed25519 {}\n\
398             @revoked host-a.example.invalid ssh-ed25519 {}\n\
399             @cert-authority *.ca.example.invalid ssh-ed25519 {}\n\
400             host-b.example.invalid ssh-ed25519 {}\n\
401             garbage-line\n\
402             host-c.example.invalid\n",
403            b64(PUB),
404            b64(PUB),
405            b64(PUB),
406            b64(PUB),
407            b64(PUB),
408            b64(PUB),
409            b64(OTHER),
410        ))
411    }
412
413    #[test]
414    fn parses_entries() {
415        let h = hosts();
416        // malformed + comment + blank lines skipped
417        assert_eq!(h.len(), 7);
418        assert_eq!(
419            h[0].host_patterns,
420            vec!["host-a.example.invalid".to_string()]
421        );
422        assert_eq!(h[1].host_patterns.len(), 2);
423        assert_eq!(h[4].marker, Some("@revoked"));
424        assert_eq!(h[5].marker, Some("@cert-authority"));
425    }
426
427    #[test]
428    fn matches_hostnames() {
429        let h = hosts();
430        assert_eq!(match_host(&h, "host-a.example.invalid", 22).len(), 3); // plain, alias list, revoked
431        assert_eq!(match_host(&h, "host-a.example.invalid", 2222).len(), 1);
432        assert_eq!(match_host(&h, "anything.wild.example.invalid", 22).len(), 1);
433        assert_eq!(match_host(&h, "nope.example.invalid", 22).len(), 0);
434        assert_eq!(match_host(&h, "sub.ca.example.invalid", 22).len(), 1);
435    }
436
437    #[test]
438    fn fingerprints() {
439        let f = fingerprint_sha256(PUB);
440        assert!(f.starts_with("SHA256:"));
441        assert!(!f.ends_with('='));
442        assert_ne!(f, fingerprint_sha256(OTHER));
443    }
444
445    #[test]
446    fn verify_outcomes() {
447        let h = hosts();
448        // matched known host
449        let r = verify_host_key(PUB, "host-a.example.invalid", 22, &h);
450        assert!(r.matched && r.had_matching_host_entry);
451        // changed key on known host (MitM signal)
452        let r = verify_host_key(OTHER, "host-a.example.invalid", 22, &h);
453        assert!(!r.matched && r.had_matching_host_entry);
454        // unknown host
455        let r = verify_host_key(PUB, "nope.example.invalid", 22, &h);
456        assert!(!r.had_matching_host_entry);
457        // revoked key
458        let r = verify_host_key(PUB, "host-a.example.invalid", 22, &h);
459        // revoked entry also matches the same key
460        assert!(r.revoked);
461    }
462
463    #[test]
464    fn length_mismatch_resists_oracle() {
465        let h = hosts();
466        assert!(!key_matches_entry(b"short", &h[0]));
467    }
468
469    #[test]
470    fn strict_and_lenient_decisions() {
471        use crate::config::SshHostKeyPolicy;
472        let h = hosts();
473        let mut logs = Vec::new();
474        let mut log = |m: String| logs.push(m);
475
476        // strict: unknown host rejected
477        assert_eq!(
478            decide_host_key(
479                SshHostKeyPolicy::Strict,
480                "nope.example.invalid",
481                22,
482                &h,
483                PUB,
484                &mut log
485            ),
486            HostKeyDecision::Reject
487        );
488        // strict: matching host accepted
489        let h_no_revoke: Vec<KnownHostEntry> =
490            h.iter().filter(|e| e.marker.is_none()).cloned().collect();
491        assert_eq!(
492            decide_host_key(
493                SshHostKeyPolicy::Strict,
494                "host-a.example.invalid",
495                22,
496                &h_no_revoke,
497                PUB,
498                &mut log
499            ),
500            HostKeyDecision::Accept
501        );
502        // strict: mismatch rejected
503        assert_eq!(
504            decide_host_key(
505                SshHostKeyPolicy::Strict,
506                "host-a.example.invalid",
507                22,
508                &h_no_revoke,
509                OTHER,
510                &mut log
511            ),
512            HostKeyDecision::Reject
513        );
514        // lenient: unknown accepted (warned)
515        assert_eq!(
516            decide_host_key(
517                SshHostKeyPolicy::Lenient,
518                "nope.example.invalid",
519                22,
520                &h,
521                PUB,
522                &mut log
523            ),
524            HostKeyDecision::Accept
525        );
526        // lenient: MISMATCH REJECTED (server identity change is never
527        // acceptable, even in migration-compatibility mode)
528        assert_eq!(
529            decide_host_key(
530                SshHostKeyPolicy::Lenient,
531                "host-a.example.invalid",
532                22,
533                &h_no_revoke,
534                OTHER,
535                &mut log
536            ),
537            HostKeyDecision::Reject
538        );
539        // revoked rejected even lenient
540        assert_eq!(
541            decide_host_key(
542                SshHostKeyPolicy::Lenient,
543                "host-a.example.invalid",
544                22,
545                &h,
546                PUB,
547                &mut log
548            ),
549            HostKeyDecision::Reject
550        );
551        assert!(!logs.is_empty());
552    }
553
554    #[test]
555    fn hashed_entries_self_consistent() {
556        // Build a hashed entry like OpenSSH would, then match it.
557        let salt = vec![0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef];
558        let probe = "hashed.example.invalid";
559        let tag = hmac_sha1(&salt, probe.as_bytes());
560        let b64 = base64::engine::general_purpose::STANDARD;
561        let line = format!(
562            "|1|{}|{} ssh-ed25519 {}",
563            b64.encode(&salt),
564            b64.encode(tag),
565            b64.encode(PUB)
566        );
567        let entries = parse_known_hosts(&line);
568        assert_eq!(entries.len(), 1);
569        assert!(entries[0].hashed.is_some());
570        assert_eq!(match_host(&entries, probe, 22).len(), 1);
571        assert_eq!(match_host(&entries, "other.example.invalid", 22).len(), 0);
572        assert!(key_matches_entry(PUB, &entries[0]));
573    }
574
575    #[test]
576    fn hmac_sha1_matches_reference_vectors() {
577        // RFC 2202 test vectors.
578        assert_eq!(
579            hmac_sha1(&[0x0b; 20], b"Hi There").to_vec(),
580            hex_decode("b617318655057264e28bc0b6fb378c8ef146be00")
581        );
582        assert_eq!(
583            hmac_sha1(b"Jefe", b"what do ya want for nothing?").to_vec(),
584            hex_decode("effcdf6ae5eb2fa2d27416d5f184df9c259a7c79")
585        );
586    }
587
588    fn hex_decode(s: &str) -> Vec<u8> {
589        (0..s.len())
590            .step_by(2)
591            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
592            .collect()
593    }
594
595    #[test]
596    fn multiple_algorithms_for_same_host() {
597        // Two entries for one host with different key types: either key
598        // is acceptable; a third, unseen key is a mismatch.
599        let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
600        let entries = parse_known_hosts(&format!(
601            "multi.example.invalid ssh-ed25519 {}\n             multi.example.invalid ssh-rsa {}",
602            b64(PUB),
603            b64(OTHER)
604        ));
605        assert_eq!(entries.len(), 2);
606        let mut log = |_: String| {};
607        assert_eq!(
608            decide_host_key(
609                crate::config::SshHostKeyPolicy::Strict,
610                "multi.example.invalid",
611                22,
612                &entries,
613                OTHER,
614                &mut log
615            ),
616            HostKeyDecision::Accept
617        );
618        let third = b"AAAAC3NzaC1lZDI1NTE5AAAAIThirdKeyNeverSeenBefore0000000000000";
619        assert_eq!(
620            decide_host_key(
621                crate::config::SshHostKeyPolicy::Strict,
622                "multi.example.invalid",
623                22,
624                &entries,
625                third,
626                &mut log
627            ),
628            HostKeyDecision::Reject
629        );
630    }
631
632    #[test]
633    fn checked_loader_fail_closed() {
634        let dir = tempfile::TempDir::new().unwrap();
635        let good = dir.path().join("good");
636        std::fs::write(
637            &good,
638            "h.example.invalid ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture
639",
640        )
641        .unwrap();
642        assert!(load_known_hosts_checked(Some(&good)).is_ok());
643
644        // Explicit path that does not exist: deny.
645        let missing = dir.path().join("missing");
646        let err = load_known_hosts_checked(Some(&missing)).unwrap_err();
647        assert!(err.contains("unreadable"), "{err}");
648
649        // Non-empty file with zero parseable entries: deny. (A 3-part
650        // garbage line still parses, mirroring OpenSSH's tolerant
651        // reader — such lines simply never match; only lines with no
652        // key field at all are structurally unparseable.)
653        let malformed = dir.path().join("garbage");
654        std::fs::write(&malformed, "two-parts-only\n???\nno-key-here either-way\n").unwrap();
655        let err = load_known_hosts_checked(Some(&malformed)).unwrap_err();
656        assert!(err.contains("malformed"), "{err}");
657
658        // Empty file is an empty set (not an error).
659        let empty = dir.path().join("empty");
660        std::fs::write(&empty, "\n").unwrap();
661        assert!(load_known_hosts_checked(Some(&empty)).unwrap().is_empty());
662    }
663
664    /// Differential check against the legacy fixtures.
665    #[test]
666    fn matches_legacy_known_hosts_fixtures() {
667        let path = concat!(
668            env!("CARGO_MANIFEST_DIR"),
669            "/tests/fixtures/legacy/known-hosts.json"
670        );
671        // Untracked corpus generated from the legacy checkout; skip on
672        // fresh CI checkouts where it is absent.
673        let Ok(raw) = std::fs::read_to_string(path) else {
674            eprintln!("skipping: legacy fixture corpus not present ({path})");
675            return;
676        };
677        let data: serde_json::Value = serde_json::from_str(&raw).unwrap();
678        let parsed = parse_known_hosts(data["content"].as_str().unwrap());
679        assert_eq!(parsed.len(), data["parsedCount"].as_u64().unwrap() as usize);
680
681        // Fixture key material is synthesized there as b64 strings; verify
682        // our fingerprint matches the legacy-computed one for the same bytes.
683        let b64 = base64::engine::general_purpose::STANDARD;
684        let pub_bytes = b64
685            .decode(
686                parsed
687                    .iter()
688                    .find(|e| {
689                        e.host_patterns
690                            .first()
691                            .map(|p| p == "host-a.example.invalid")
692                            .unwrap_or(false)
693                    })
694                    .map(|e| e.key_base64.clone())
695                    .unwrap(),
696            )
697            .unwrap();
698        assert_eq!(
699            fingerprint_sha256(&pub_bytes),
700            data["fingerprint"].as_str().unwrap()
701        );
702
703        for m in data["matches"].as_array().unwrap() {
704            let host = m["host"].as_str().unwrap();
705            let port = m["port"].as_u64().unwrap() as u16;
706            assert_eq!(
707                match_host(&parsed, host, port).len() as u64,
708                m["matchedHosts"].as_u64().unwrap(),
709                "match count for {host}:{port}"
710            );
711            let verify = verify_host_key(&pub_bytes, host, port, &parsed);
712            assert_eq!(verify.matched, m["verifyPub"]["matched"].as_bool().unwrap());
713            assert_eq!(
714                verify.had_matching_host_entry,
715                m["verifyPub"]["hadMatchingHostEntry"].as_bool().unwrap()
716            );
717            assert_eq!(verify.revoked, m["verifyPub"]["revoked"].as_bool().unwrap());
718        }
719
720        // Hashed-entry differential.
721        let h = parse_known_hosts(data["hashedEntry"]["line"].as_str().unwrap());
722        assert_eq!(
723            h.len() as u64,
724            data["hashedEntry"]["parsedCount"].as_u64().unwrap()
725        );
726        assert_eq!(
727            match_host(&h, "hashed.example.invalid", 22).len() as u64,
728            data["hashedEntry"]["matchHost_22"].as_u64().unwrap()
729        );
730        assert_eq!(
731            match_host(&h, "other.example.invalid", 22).len() as u64,
732            data["hashedEntry"]["matchHost_other"].as_u64().unwrap()
733        );
734    }
735}