Skip to main content

purple_ssh/
ssh_keys.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{Instant, UNIX_EPOCH};
5
6use log::debug;
7
8use crate::ssh_config::model::HostEntry;
9
10mod native;
11
12/// Resolve the SSH directory (`~/.ssh`) from the injected paths. Returns
13/// `None` when the home directory is unknown, so callers can short-circuit
14/// cleanly. Tests pass a sandboxed `Paths` instead of touching the real `~/.ssh`.
15pub fn resolve_ssh_dir(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
16    paths.map(crate::runtime::env::Paths::ssh_dir)
17}
18
19/// Information about an SSH key found on disk.
20#[derive(Debug, Clone)]
21pub struct SshKeyInfo {
22    /// Display name (filename without path, e.g. "id_ed25519")
23    pub name: String,
24    /// Display path with tilde (e.g. "~/.ssh/id_ed25519")
25    pub display_path: String,
26    /// Key type (e.g. "ED25519", "RSA", "sk-ED25519")
27    pub key_type: String,
28    /// Key bits (e.g. "256", "4096")
29    pub bits: String,
30    /// SHA256 fingerprint
31    pub fingerprint: String,
32    /// Comment from the public key
33    pub comment: String,
34    /// Host aliases that reference this key via IdentityFile
35    pub linked_hosts: Vec<String>,
36    /// Drunken Bishop visual fingerprint in the `ssh-keygen -lv` layout.
37    /// 11 lines (top border + 9 content + bottom border), joined with `\n`.
38    /// Empty when no art block could be produced.
39    pub bishop_art: String,
40    /// Strength score 0..=100. Composed of algorithm strength, key size and
41    /// on-disk encryption. Hardware-bound `sk-*` keys score highest;
42    /// deprecated DSA and short RSA score lowest.
43    pub strength_score: u8,
44    /// Private key on disk is passphrase-encrypted. Read from the key file
45    /// header, or from the `ssh-keygen -y -P "" -f <key>` exit status for
46    /// other formats. False when the private key is missing or not a
47    /// regular file.
48    pub encrypted: bool,
49    /// Public key fingerprint matches an entry returned by `ssh-add -l`.
50    pub agent_loaded: bool,
51    /// File is an OpenSSH user certificate. Detected via `-cert.pub`
52    /// filename suffix or a `-cert` substring in the ssh-keygen-reported
53    /// key type (see `detect_certificate`).
54    pub is_certificate: bool,
55    /// File mtime of the private key (or pubkey when private is missing),
56    /// expressed as seconds since UNIX epoch. None when the file system
57    /// cannot report a timestamp. Powers the `Modified` field on the
58    /// Keys tab hero panel; mtime is the most portable proxy because
59    /// birthtime is not exposed by every supported filesystem.
60    pub mtime_ts: Option<u64>,
61}
62
63impl SshKeyInfo {
64    /// Format type with bits (e.g. "ED25519" or "RSA 4096").
65    pub fn type_display(&self) -> String {
66        if self.bits.is_empty() {
67            self.key_type.clone()
68        } else {
69            format!("{} {}", self.key_type, self.bits)
70        }
71    }
72
73    /// Drunken Bishop art split into rendering-ready lines. Empty Vec when
74    /// `bishop_art` is empty (e.g. when ssh-keygen failed at discovery).
75    pub fn bishop_lines(&self) -> Vec<&str> {
76        if self.bishop_art.is_empty() {
77            Vec::new()
78        } else {
79            self.bishop_art.lines().collect()
80        }
81    }
82}
83
84/// Character ladder for the Drunken Bishop random-art. Index 0 is the
85/// unvisited cell; counter values 1..=14 map to ascending visit density;
86/// 15 marks the bishop's start position (S) and 16 the end position (E).
87const BISHOP_CHARS: &[u8] = b" .o+=*BOX@%&#/^SE";
88
89const BISHOP_COUNTER_CAP: u8 = 14;
90const BISHOP_S_INDEX: u8 = 15;
91const BISHOP_E_INDEX: u8 = 16;
92
93/// Decode an OpenSSH `SHA256:<base64>` fingerprint string into its raw
94/// hash bytes. OpenSSH emits unpadded base64 with potentially non-zero
95/// trailing bits (synthetic fingerprints from demo fixtures sometimes
96/// fall in this bucket), so we configure a lenient engine that accepts
97/// both padded and unpadded input. Returns `None` when the `SHA256:`
98/// prefix is missing or the body is not decodable.
99pub fn decode_fingerprint(fp_str: &str) -> Option<Vec<u8>> {
100    use base64::Engine;
101    use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
102    let b64 = fp_str.strip_prefix("SHA256:")?;
103    let config = GeneralPurposeConfig::new()
104        .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent)
105        .with_decode_allow_trailing_bits(true);
106    let engine = GeneralPurpose::new(&base64::alphabet::STANDARD, config);
107    engine.decode(b64).ok()
108}
109
110/// Generate the Drunken Bishop visit grid from a fingerprint. Mirrors the
111/// `key_fingerprint_randomart` walk in OpenSSH `sshkey.c`: starting at
112/// the grid centre, the bishop steps diagonally based on 2-bit pairs read
113/// LSB-first from each fingerprint byte. Each visited cell increments its
114/// counter (capped so unique cells stay distinguishable), and the start
115/// and end positions are tagged with `S` and `E` markers.
116///
117/// `cols` and `rows` are the interior cell dimensions. Use 17×9 to match
118/// the canonical OpenSSH output, or scale up for a more prominent visual.
119pub fn drunken_bishop_grid(fp_bytes: &[u8], cols: usize, rows: usize) -> Vec<Vec<u8>> {
120    let mut grid = vec![vec![0u8; cols]; rows];
121    let mut x = cols / 2;
122    let mut y = rows / 2;
123    let start = (x, y);
124    for &byte in fp_bytes {
125        let mut b = byte;
126        for _ in 0..4 {
127            let dx: isize = if b & 0x1 == 0 { -1 } else { 1 };
128            let dy: isize = if b & 0x2 == 0 { -1 } else { 1 };
129            x = (x as isize + dx).clamp(0, cols as isize - 1) as usize;
130            y = (y as isize + dy).clamp(0, rows as isize - 1) as usize;
131            if grid[y][x] < BISHOP_COUNTER_CAP {
132                grid[y][x] += 1;
133            }
134            b >>= 2;
135        }
136    }
137    grid[start.1][start.0] = BISHOP_S_INDEX;
138    grid[y][x] = BISHOP_E_INDEX;
139    grid
140}
141
142/// Map a Drunken Bishop counter value to its display character.
143pub fn bishop_char(counter: u8) -> char {
144    let idx = counter.min(BISHOP_E_INDEX) as usize;
145    BISHOP_CHARS[idx] as char
146}
147
148/// Translate a UI-space selection index into an `app.keys.list` index,
149/// honoring an active search filter. Returns `None` when the selection
150/// is out of range for the current filter. Centralised here so every
151/// call site (copy, push, detail-pane render) maps selection back to
152/// `app.keys.list` through one code path; a divergent implementation in any
153/// of them would silently point at the wrong key.
154pub fn resolve_selection(keys: &[SshKeyInfo], query: Option<&str>, sel: usize) -> Option<usize> {
155    let filtered = filtered_key_indices(keys, query);
156    filtered.get(sel).copied()
157}
158
159/// Indices into `keys` whose `name` or `comment` contains `query`
160/// (case-insensitive substring). Returns all indices when the query is
161/// empty or `None`, so callers can render the unfiltered list using the
162/// same code path. Pure function so the search handler can call it
163/// repeatedly per keystroke without touching the App.
164pub fn filtered_key_indices(keys: &[SshKeyInfo], query: Option<&str>) -> Vec<usize> {
165    match query {
166        None | Some("") => (0..keys.len()).collect(),
167        Some(q) => {
168            let needle = q.to_ascii_lowercase();
169            keys.iter()
170                .enumerate()
171                .filter(|(_, k)| {
172                    k.name.to_ascii_lowercase().contains(&needle)
173                        || k.comment.to_ascii_lowercase().contains(&needle)
174                })
175                .map(|(i, _)| i)
176                .collect()
177        }
178    }
179}
180
181/// Discover SSH keys in the given directory and cross-reference with host entries.
182///
183/// Runs `ssh-add -l` once at the start so each key knows whether its
184/// fingerprint is currently loaded in the agent. The result is a snapshot:
185/// keys added to or removed from the agent after this call will not show
186/// up until the next discover_keys() invocation (host reload).
187pub fn discover_keys(
188    paths: Option<&crate::runtime::env::Paths>,
189    ssh_dir: &Path,
190    hosts: &[HostEntry],
191) -> Vec<SshKeyInfo> {
192    let entries = match std::fs::read_dir(ssh_dir) {
193        Ok(entries) => entries,
194        Err(_) => return Vec::new(),
195    };
196
197    let started = Instant::now();
198    let home = paths.map(|p| p.home().to_path_buf());
199    let agent_fingerprints = agent_loaded_fingerprints();
200
201    let mut keys: Vec<SshKeyInfo> = entries
202        .filter_map(|e| e.ok())
203        .filter(is_public_key_file)
204        .filter_map(|e| {
205            read_key_info(
206                ssh_dir,
207                &e.path(),
208                home.as_deref(),
209                hosts,
210                &agent_fingerprints,
211            )
212        })
213        .collect();
214
215    keys.sort_by(|a, b| a.name.cmp(&b.name));
216    debug!(
217        "[purple] discover_keys: found {} key(s) in {}, {} loaded in agent, took {}ms",
218        keys.len(),
219        ssh_dir.display(),
220        agent_fingerprints.len(),
221        started.elapsed().as_millis()
222    );
223    keys
224}
225
226/// Fingerprints (SHA256 form, including the `SHA256:` prefix) of every key
227/// currently loaded in the running ssh-agent. Empty when the agent has no
228/// identities, is not reachable, or `ssh-add` is missing. Each failure
229/// path emits one debug line so a user reporting "agent column always
230/// reads `not loaded`" has a trace pointing at the cause.
231fn agent_loaded_fingerprints() -> HashSet<String> {
232    let output = Command::new("ssh-add").arg("-l").output();
233    match output {
234        Ok(o) if o.status.success() => parse_agent_list(&String::from_utf8_lossy(&o.stdout)),
235        Ok(o) => {
236            let code = o.status.code().unwrap_or(-1);
237            let stderr = String::from_utf8_lossy(&o.stderr);
238            log::debug!(
239                "[external] ssh-add -l non-zero exit={code} stderr={}",
240                stderr.trim().lines().next().unwrap_or("<empty>"),
241            );
242            HashSet::new()
243        }
244        Err(e) => {
245            log::debug!("[external] ssh-add spawn failed: {e}");
246            HashSet::new()
247        }
248    }
249}
250
251/// Parse `ssh-add -l` stdout into a fingerprint set. Each line has the
252/// format `<bits> SHA256:<hash> <comment> (<TYPE>)`; we extract column 2.
253/// Lines that do not start with a numeric bit count are skipped (covers
254/// the "The agent has no identities." string and any future banner).
255fn parse_agent_list(stdout: &str) -> HashSet<String> {
256    stdout
257        .lines()
258        .filter_map(|line| {
259            let parts: Vec<&str> = line.splitn(3, ' ').collect();
260            if parts.len() >= 2 && parts[1].starts_with("SHA256:") {
261                Some(parts[1].to_string())
262            } else {
263                None
264            }
265        })
266        .collect()
267}
268
269/// Compute the strength score for a key. Pure function so we can unit-test
270/// every algorithm/bit combo without subprocess calls. Hardware-bound `sk-*`
271/// keys are floored at 90 since the private material never leaves the token;
272/// deprecated DSA and short RSA collapse to single-digit scores.
273fn strength_score_for(key_type: &str, bits: &str, encrypted: bool) -> u8 {
274    // OpenSSH spells hardware-key types as `sk-ed25519` / `sk-ecdsa-...`,
275    // but ssh-keygen output sometimes uppercases the prefix. One
276    // case-insensitive prefix check covers both.
277    let is_sk = key_type.to_ascii_lowercase().starts_with("sk-");
278    let base: i16 = if is_sk {
279        95
280    } else {
281        match key_type.to_ascii_uppercase().as_str() {
282            "DSA" => 5,
283            "RSA" => match bits.parse::<u32>().unwrap_or(0) {
284                0..=1023 => 5,
285                1024..=2047 => 15,
286                2048..=3071 => 55,
287                3072..=4095 => 75,
288                _ => 80,
289            },
290            "ECDSA" => match bits.parse::<u32>().unwrap_or(0) {
291                256 => 70,
292                384 => 80,
293                521 => 85,
294                _ => 60,
295            },
296            "ED25519" => 90,
297            _ => 50,
298        }
299    };
300    let modifier: i16 = if encrypted { 5 } else { -10 };
301    (base + modifier).clamp(0, 100) as u8
302}
303
304/// Detect whether a private key file is passphrase-encrypted. Known formats
305/// are read from the file header. Other formats fall back to deriving the
306/// public key with an empty passphrase: success means unencrypted, failure
307/// means encrypted (or unreadable). Returns false when the private key is
308/// absent or not a regular file, so unbacked .pub files are not flagged as
309/// encrypted and a FIFO or device cannot block the scan.
310fn private_key_encrypted(private_path: &Path) -> bool {
311    if !private_path.is_file() {
312        return false;
313    }
314    if let Some(encrypted) = native::private_key_encrypted(private_path) {
315        return encrypted;
316    }
317    debug!(
318        "[purple] key scan: ssh-keygen fallback for encryption check of {}",
319        private_path.display()
320    );
321    keygen_private_key_encrypted(private_path)
322}
323
324fn keygen_private_key_encrypted(private_path: &Path) -> bool {
325    let output = Command::new("ssh-keygen")
326        .arg("-y")
327        .args(["-P", ""])
328        .arg("-f")
329        .arg(private_path)
330        .output();
331    match output {
332        Ok(o) => !o.status.success(),
333        Err(_) => false,
334    }
335}
336
337/// Extract the Drunken Bishop ASCII block from `ssh-keygen -lv` stdout.
338/// Returns the 11 art lines joined with `\n`, or an empty string when the
339/// expected `+--...--+` border + 9 content rows + closing border are not
340/// all present. The filter matches any line that opens AND closes with
341/// either `+` (border) or `|` (content), which is robust to header
342/// variations across OpenSSH versions.
343fn parse_bishop_block(stdout: &str) -> String {
344    let art_lines: Vec<&str> = stdout
345        .lines()
346        .filter(|l| {
347            let t = l.trim_end();
348            (t.starts_with('+') && t.ends_with('+')) || (t.starts_with('|') && t.ends_with('|'))
349        })
350        .collect();
351    if art_lines.len() == 11 {
352        art_lines.join("\n")
353    } else {
354        String::new()
355    }
356}
357
358/// Check if a directory entry looks like a public key file.
359fn is_public_key_file(entry: &std::fs::DirEntry) -> bool {
360    let name = entry.file_name();
361    let name = name.to_string_lossy();
362
363    // Must end in .pub
364    if !name.ends_with(".pub") {
365        return false;
366    }
367
368    // Skip known non-key files
369    let skip = ["authorized_keys.pub", "known_hosts.pub"];
370    if skip.contains(&name.as_ref()) {
371        return false;
372    }
373
374    // Use std::fs::metadata, not DirEntry::file_type or DirEntry::metadata:
375    // both of those use lstat and report the symlink itself (is_file = false).
376    // std::fs::metadata uses stat, follows the chain, and reports the target.
377    std::fs::metadata(entry.path())
378        .map(|m| m.is_file())
379        .unwrap_or(false)
380}
381
382/// What `ssh-keygen -lv -E sha256` reports about a public key file.
383#[derive(Debug, Clone, PartialEq, Eq)]
384struct PublicKeyFacts {
385    bits: String,
386    fingerprint: String,
387    comment: String,
388    key_type: String,
389    bishop_art: String,
390}
391
392/// Read the public key facts in-process. Formats the in-process reader
393/// does not cover go through `ssh-keygen -lv` instead.
394fn public_key_facts(pub_path: &Path) -> Option<PublicKeyFacts> {
395    if let Some(facts) = native::inspect_public_key(pub_path) {
396        return Some(facts);
397    }
398    debug!(
399        "[purple] key scan: ssh-keygen fallback for {}",
400        pub_path.display()
401    );
402    keygen_public_key_facts(pub_path)
403}
404
405fn keygen_public_key_facts(pub_path: &Path) -> Option<PublicKeyFacts> {
406    let output = Command::new("ssh-keygen")
407        .arg("-lv")
408        .arg("-f")
409        .arg(pub_path)
410        .args(["-E", "sha256"])
411        .output()
412        .ok()?;
413
414    if !output.status.success() {
415        return None;
416    }
417
418    let stdout = String::from_utf8_lossy(&output.stdout);
419    let first_line = stdout.lines().next()?.trim();
420
421    // Format: "<bits> <fingerprint> <comment> (<type>)"
422    let (bits, fingerprint, comment, key_type) = parse_keygen_output(first_line)?;
423    Some(PublicKeyFacts {
424        bits,
425        fingerprint,
426        comment,
427        key_type,
428        bishop_art: parse_bishop_block(&stdout),
429    })
430}
431
432/// Read key metadata (fingerprint + Drunken Bishop) and cross-reference
433/// with hosts, agent state and on-disk encryption.
434fn read_key_info(
435    ssh_dir: &Path,
436    pub_path: &Path,
437    home: Option<&Path>,
438    hosts: &[HostEntry],
439    agent_fingerprints: &HashSet<String>,
440) -> Option<SshKeyInfo> {
441    let PublicKeyFacts {
442        bits,
443        fingerprint,
444        comment,
445        key_type,
446        bishop_art,
447    } = public_key_facts(pub_path)?;
448
449    // Derive the private key name (strip .pub)
450    let pub_name = pub_path.file_name()?.to_string_lossy();
451    let name = pub_name
452        .strip_suffix(".pub")
453        .unwrap_or(&pub_name)
454        .to_string();
455
456    // Private key path (without .pub extension)
457    let private_path = ssh_dir.join(&name);
458
459    // Display path: use ~ if ssh_dir is under home
460    let display_path = match home {
461        Some(home) if ssh_dir.starts_with(home) => {
462            let relative = ssh_dir.strip_prefix(home).unwrap();
463            format!("~/{}/{}", relative.display(), name)
464        }
465        _ => private_path.display().to_string(),
466    };
467
468    // Find hosts that reference this key
469    let linked_hosts = find_linked_hosts(&private_path, &display_path, hosts);
470
471    let is_certificate = detect_certificate(&pub_name, &key_type);
472
473    // Probe encryption status via empty-passphrase pubkey derivation.
474    // Cert files have no encrypted-private-key counterpart, so skip.
475    let encrypted = if is_certificate {
476        false
477    } else {
478        private_key_encrypted(&private_path)
479    };
480
481    // Agent match by fingerprint (already SHA256-prefixed in both sides).
482    let agent_loaded = agent_fingerprints.contains(&fingerprint);
483
484    let strength_score = strength_score_for(&key_type, &bits, encrypted);
485
486    let mtime_ts = file_mtime_ts(&private_path, pub_path);
487
488    Some(SshKeyInfo {
489        name,
490        display_path,
491        key_type,
492        bits,
493        fingerprint,
494        comment,
495        linked_hosts,
496        bishop_art,
497        strength_score,
498        encrypted,
499        agent_loaded,
500        is_certificate,
501        mtime_ts,
502    })
503}
504
505/// File mtime of the private key, falling back to the pub key when the
506/// private file is missing or unreadable. Returns seconds since UNIX
507/// epoch. mtime is the most portable proxy for "key created" on Unix;
508/// btime exists on some filesystems but Rust's stable std cannot read
509/// it portably without a third-party crate.
510fn file_mtime_ts(private_path: &Path, pub_path: &Path) -> Option<u64> {
511    let from = |p: &Path| {
512        std::fs::metadata(p)
513            .ok()
514            .and_then(|m| m.modified().ok())
515            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
516            .map(|d| d.as_secs())
517    };
518    from(private_path).or_else(|| from(pub_path))
519}
520
521/// Detect whether a `.pub` file holds an OpenSSH user certificate.
522///
523/// Two paths trigger detection:
524/// 1. Filename ends in `-cert.pub` (the convention `ssh-keygen -s` emits).
525/// 2. `ssh-keygen -l` reported a cert variant in the type column, e.g.
526///    `ED25519-CERT-V01@openssh.com`. This branch catches Vault SSH certs
527///    and other signed pub keys the user renamed away from `-cert.pub`.
528fn detect_certificate(pub_name: &str, key_type: &str) -> bool {
529    pub_name.ends_with("-cert.pub") || key_type.to_ascii_lowercase().contains("-cert")
530}
531
532/// Parse ssh-keygen -lf output line into (bits, fingerprint, comment, type).
533fn parse_keygen_output(line: &str) -> Option<(String, String, String, String)> {
534    let parts: Vec<&str> = line.splitn(3, ' ').collect();
535    if parts.len() < 3 {
536        return None;
537    }
538
539    let bits = parts[0].to_string();
540    let fingerprint = parts[1].to_string();
541
542    // The rest is "<comment> (<type>)". Extract type from the end.
543    let rest = parts[2];
544    let (comment, key_type) = if let Some(paren_start) = rest.rfind('(') {
545        let comment = rest[..paren_start].trim().to_string();
546        let key_type = rest[paren_start + 1..].trim_end_matches(')').to_string();
547        (comment, key_type)
548    } else {
549        (rest.to_string(), String::new())
550    };
551
552    Some((bits, fingerprint, comment, key_type))
553}
554
555/// Find host aliases that reference a given key path via IdentityFile.
556/// Hosts without an explicit IdentityFile are linked to all keys (SSH tries them all).
557fn find_linked_hosts(full_path: &Path, display_path: &str, hosts: &[HostEntry]) -> Vec<String> {
558    // Only count explicit IdentityFile matches. SSH technically falls
559    // back to trying every available key when no IdentityFile is set,
560    // but rendering that as "this host is linked to every key" pollutes
561    // every key's Linked Hosts grid with the same untargeted hosts.
562    hosts
563        .iter()
564        .filter(|h| {
565            if h.identity_file.is_empty() {
566                return false;
567            }
568            h.identity_file == display_path || Path::new(&h.identity_file) == full_path
569        })
570        .map(|h| h.alias.clone())
571        .collect()
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn test_parse_keygen_output_ed25519() {
580        let line = "256 SHA256:abcdef1234567890 user@host (ED25519)";
581        let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
582        assert_eq!(bits, "256");
583        assert_eq!(fp, "SHA256:abcdef1234567890");
584        assert_eq!(comment, "user@host");
585        assert_eq!(key_type, "ED25519");
586    }
587
588    #[test]
589    fn test_parse_keygen_output_rsa() {
590        let line = "4096 SHA256:xyz9876543210 deploy@prod.example.com (RSA)";
591        let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
592        assert_eq!(bits, "4096");
593        assert_eq!(fp, "SHA256:xyz9876543210");
594        assert_eq!(comment, "deploy@prod.example.com");
595        assert_eq!(key_type, "RSA");
596    }
597
598    #[test]
599    fn test_parse_keygen_output_no_comment() {
600        let line = "256 SHA256:fingerprint (ED25519)";
601        let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
602        assert_eq!(bits, "256");
603        assert_eq!(fp, "SHA256:fingerprint");
604        assert_eq!(comment, "");
605        assert_eq!(key_type, "ED25519");
606    }
607
608    #[test]
609    fn test_parse_keygen_output_comment_with_spaces() {
610        let line = "256 SHA256:fingerprint eko@MacBook Pro (ED25519)";
611        let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
612        assert_eq!(bits, "256");
613        assert_eq!(fp, "SHA256:fingerprint");
614        assert_eq!(comment, "eko@MacBook Pro");
615        assert_eq!(key_type, "ED25519");
616    }
617
618    #[test]
619    fn test_parse_keygen_output_no_type_parens() {
620        let line = "256 SHA256:fingerprint user@host";
621        let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
622        assert_eq!(bits, "256");
623        assert_eq!(fp, "SHA256:fingerprint");
624        assert_eq!(comment, "user@host");
625        assert_eq!(key_type, "");
626    }
627
628    #[test]
629    fn test_parse_keygen_output_too_short() {
630        assert!(parse_keygen_output("256 SHA256:fp").is_none());
631        assert!(parse_keygen_output("").is_none());
632    }
633
634    #[test]
635    fn test_find_linked_hosts_display_path() {
636        let hosts = vec![
637            HostEntry {
638                alias: "prod".to_string(),
639                identity_file: "~/.ssh/id_ed25519".to_string(),
640                ..Default::default()
641            },
642            HostEntry {
643                alias: "staging".to_string(),
644                identity_file: "~/.ssh/other_key".to_string(),
645                ..Default::default()
646            },
647        ];
648        let linked = find_linked_hosts(
649            Path::new("/home/user/.ssh/id_ed25519"),
650            "~/.ssh/id_ed25519",
651            &hosts,
652        );
653        assert_eq!(linked, vec!["prod"]);
654    }
655
656    #[test]
657    fn test_find_linked_hosts_full_path() {
658        let hosts = vec![HostEntry {
659            alias: "server".to_string(),
660            identity_file: "/home/user/.ssh/deploy_key".to_string(),
661            ..Default::default()
662        }];
663        let linked = find_linked_hosts(
664            Path::new("/home/user/.ssh/deploy_key"),
665            "~/.ssh/deploy_key",
666            &hosts,
667        );
668        assert_eq!(linked, vec!["server"]);
669    }
670
671    #[test]
672    fn test_find_linked_hosts_no_identity_file_does_not_link() {
673        // Hosts without an explicit IdentityFile are excluded so the
674        // Linked Hosts grid stays accurate per key instead of showing
675        // every untargeted host under every key.
676        let hosts = vec![HostEntry {
677            alias: "server".to_string(),
678            identity_file: String::new(),
679            ..Default::default()
680        }];
681        let linked =
682            find_linked_hosts(Path::new("/home/user/.ssh/id_rsa"), "~/.ssh/id_rsa", &hosts);
683        assert!(linked.is_empty());
684    }
685
686    #[test]
687    fn test_find_linked_hosts_wrong_identity_file() {
688        let hosts = vec![HostEntry {
689            alias: "server".to_string(),
690            identity_file: "~/.ssh/other_key".to_string(),
691            ..Default::default()
692        }];
693        let linked =
694            find_linked_hosts(Path::new("/home/user/.ssh/id_rsa"), "~/.ssh/id_rsa", &hosts);
695        assert!(linked.is_empty());
696    }
697
698    fn sample_key() -> SshKeyInfo {
699        SshKeyInfo {
700            name: "id_ed25519".to_string(),
701            display_path: "~/.ssh/id_ed25519".to_string(),
702            key_type: "ED25519".to_string(),
703            bits: "256".to_string(),
704            fingerprint: "SHA256:8x2k7HhPqQfvN5jJrUvWxTsXmnQ4LpBkEoYzNcAdGhI".to_string(),
705            comment: "eric@MacBook".to_string(),
706            linked_hosts: Vec::new(),
707            bishop_art: String::new(),
708            strength_score: 95,
709            encrypted: true,
710            agent_loaded: true,
711            is_certificate: false,
712            mtime_ts: None,
713        }
714    }
715
716    #[test]
717    fn test_type_display() {
718        let key = sample_key();
719        assert_eq!(key.type_display(), "ED25519 256");
720
721        let key2 = SshKeyInfo {
722            bits: String::new(),
723            ..key
724        };
725        assert_eq!(key2.type_display(), "ED25519");
726    }
727
728    #[test]
729    fn detect_certificate_via_filename_suffix() {
730        assert!(detect_certificate("id_ed25519-cert.pub", "ED25519"));
731    }
732
733    #[test]
734    fn detect_certificate_via_key_type_full_oid() {
735        // ssh-keygen emits this form when a signed pub key is fed in even
736        // though the filename omits the conventional `-cert.pub` suffix.
737        assert!(detect_certificate(
738            "id_ed25519-vault.pub",
739            "ED25519-CERT-V01@openssh.com"
740        ));
741    }
742
743    #[test]
744    fn detect_certificate_via_key_type_short() {
745        assert!(detect_certificate(
746            "id_ed25519-breakglass.pub",
747            "ED25519-CERT"
748        ));
749    }
750
751    #[test]
752    fn detect_certificate_rejects_plain_key() {
753        assert!(!detect_certificate("id_ed25519.pub", "ED25519"));
754    }
755
756    #[test]
757    fn detect_certificate_rejects_unrelated_dash_cert_in_name() {
758        // A filename containing `cert` but not the `-cert.pub` suffix and a
759        // non-cert key_type must not be flagged as a certificate.
760        assert!(!detect_certificate("my-cert-backup.pub", "RSA"));
761    }
762
763    #[test]
764    fn drunken_bishop_matches_openssh_canonical_17x9() {
765        // Fingerprint generated with `ssh-keygen -t ed25519`; the bishop
766        // block below is the exact `ssh-keygen -lv -E sha256` output for
767        // that key. Locks the algorithm against OpenSSH's reference impl.
768        let fp = decode_fingerprint("SHA256:1LayGj+CVIvJfOnQqADAT52DoJHhSa30feF/23wbRuE")
769            .expect("decode fingerprint");
770        let grid = drunken_bishop_grid(&fp, 17, 9);
771        let rendered: Vec<String> = grid
772            .iter()
773            .map(|row| row.iter().map(|&c| bishop_char(c)).collect())
774            .collect();
775        assert_eq!(
776            rendered,
777            vec![
778                "+=o o .          ",
779                "*+.+ + . .       ",
780                "+o= . o o o    . ",
781                ".. o ..+ . .  . .",
782                ".  o *.oS .    E ",
783                ".   O =  + .  .  ",
784                " . o =. . . +  o ",
785                "  . . o+.  . o...",
786                "      ....    ...",
787            ]
788        );
789    }
790
791    #[test]
792    fn drunken_bishop_scales_to_larger_grid() {
793        // At a larger grid the walk still starts at center and produces a
794        // sparser pattern (same step count over more cells). Just sanity-
795        // check that the dimensions match the request and that the center
796        // tile carries the S marker as expected.
797        let fp = decode_fingerprint("SHA256:1LayGj+CVIvJfOnQqADAT52DoJHhSa30feF/23wbRuE")
798            .expect("decode fingerprint");
799        let grid = drunken_bishop_grid(&fp, 25, 13);
800        assert_eq!(grid.len(), 13);
801        assert!(grid.iter().all(|row| row.len() == 25));
802        assert_eq!(grid[6][12], BISHOP_S_INDEX);
803    }
804
805    #[test]
806    fn decode_fingerprint_rejects_other_hash_prefixes() {
807        assert!(decode_fingerprint("MD5:abcd").is_none());
808        assert!(decode_fingerprint("plain-text").is_none());
809    }
810
811    #[test]
812    fn test_bishop_lines_empty() {
813        let key = SshKeyInfo {
814            bishop_art: String::new(),
815            ..sample_key()
816        };
817        assert!(key.bishop_lines().is_empty());
818    }
819
820    #[test]
821    fn test_bishop_lines_split() {
822        let key = SshKeyInfo {
823            bishop_art: "+--[ED25519 256]--+\n|       .o*+      |\n+----[SHA256]-----+".to_string(),
824            ..sample_key()
825        };
826        assert_eq!(key.bishop_lines().len(), 3);
827        assert_eq!(key.bishop_lines()[1], "|       .o*+      |");
828    }
829
830    #[test]
831    fn test_parse_agent_list_two_keys() {
832        let stdout = "256 SHA256:abc1 eric@host (ED25519)\n4096 SHA256:def2 work@laptop (RSA)\n";
833        let set = parse_agent_list(stdout);
834        assert_eq!(set.len(), 2);
835        assert!(set.contains("SHA256:abc1"));
836        assert!(set.contains("SHA256:def2"));
837    }
838
839    #[test]
840    fn test_parse_agent_list_empty_agent() {
841        let stdout = "The agent has no identities.\n";
842        let set = parse_agent_list(stdout);
843        assert!(set.is_empty());
844    }
845
846    #[test]
847    fn test_parse_agent_list_banner_skipped() {
848        let stdout = "Could not open a connection to your authentication agent.\n";
849        let set = parse_agent_list(stdout);
850        assert!(set.is_empty());
851    }
852
853    #[test]
854    fn test_strength_score_ed25519() {
855        assert_eq!(strength_score_for("ED25519", "256", true), 95);
856        assert_eq!(strength_score_for("ED25519", "256", false), 80);
857    }
858
859    #[test]
860    fn test_strength_score_sk_ed25519() {
861        assert_eq!(strength_score_for("sk-ED25519", "256", true), 100);
862        assert_eq!(strength_score_for("sk-ED25519", "256", false), 85);
863    }
864
865    #[test]
866    fn test_strength_score_rsa_buckets() {
867        assert_eq!(strength_score_for("RSA", "1024", true), 20);
868        assert_eq!(strength_score_for("RSA", "2048", true), 60);
869        assert_eq!(strength_score_for("RSA", "3072", true), 80);
870        assert_eq!(strength_score_for("RSA", "4096", true), 85);
871        assert_eq!(strength_score_for("RSA", "8192", true), 85);
872    }
873
874    #[test]
875    fn test_strength_score_dsa_is_low() {
876        assert_eq!(strength_score_for("DSA", "1024", true), 10);
877        assert_eq!(strength_score_for("DSA", "1024", false), 0);
878    }
879
880    #[test]
881    fn test_strength_score_ecdsa_buckets() {
882        assert_eq!(strength_score_for("ECDSA", "256", true), 75);
883        assert_eq!(strength_score_for("ECDSA", "384", true), 85);
884        assert_eq!(strength_score_for("ECDSA", "521", true), 90);
885    }
886
887    #[test]
888    fn test_strength_score_unknown_type() {
889        assert_eq!(strength_score_for("WEIRD", "256", true), 55);
890        assert_eq!(strength_score_for("", "0", false), 40);
891    }
892
893    #[test]
894    fn test_parse_bishop_block_typical_output() {
895        let stdout = "\
896256 SHA256:abc eric@host (ED25519)
897+--[ED25519 256]--+
898|                 |
899|                 |
900|      . .  . ... |
901|       o o..ooo.o|
902|      . S =.oo+==|
903|     . o   B +E*B|
904|      . . O =.=.+|
905|     ..  = B o.oo|
906|      .oo.+.=o.. |
907+----[SHA256]-----+
908";
909        let art = parse_bishop_block(stdout);
910        assert_eq!(art.lines().count(), 11);
911        assert!(art.starts_with("+--[ED25519 256]--+"));
912        assert!(art.ends_with("+----[SHA256]-----+"));
913    }
914
915    #[test]
916    fn test_parse_bishop_block_missing_returns_empty() {
917        let stdout = "256 SHA256:abc eric@host (ED25519)\n";
918        assert!(parse_bishop_block(stdout).is_empty());
919    }
920
921    #[test]
922    fn test_parse_bishop_block_truncated_returns_empty() {
923        let stdout = "+--[ED25519 256]--+\n|   |\n+--+\n";
924        assert!(parse_bishop_block(stdout).is_empty());
925    }
926
927    fn search_corpus() -> Vec<SshKeyInfo> {
928        vec![
929            SshKeyInfo {
930                name: "id_ed25519".into(),
931                comment: "eric@mac".into(),
932                ..sample_key()
933            },
934            SshKeyInfo {
935                name: "yubikey_work".into(),
936                comment: "yubi@work".into(),
937                ..sample_key()
938            },
939            SshKeyInfo {
940                name: "customer-x".into(),
941                comment: "eric@customer".into(),
942                ..sample_key()
943            },
944        ]
945    }
946
947    #[test]
948    fn filtered_key_indices_none_returns_all() {
949        let keys = search_corpus();
950        let idx = filtered_key_indices(&keys, None);
951        assert_eq!(idx, vec![0, 1, 2]);
952    }
953
954    #[test]
955    fn filtered_key_indices_empty_returns_all() {
956        let keys = search_corpus();
957        let idx = filtered_key_indices(&keys, Some(""));
958        assert_eq!(idx, vec![0, 1, 2]);
959    }
960
961    #[test]
962    fn filtered_key_indices_matches_name() {
963        let keys = search_corpus();
964        let idx = filtered_key_indices(&keys, Some("yubi"));
965        assert_eq!(idx, vec![1]);
966    }
967
968    #[test]
969    fn filtered_key_indices_matches_comment() {
970        let keys = search_corpus();
971        let idx = filtered_key_indices(&keys, Some("eric"));
972        assert_eq!(idx, vec![0, 2]);
973    }
974
975    #[test]
976    fn filtered_key_indices_case_insensitive() {
977        let keys = search_corpus();
978        let idx = filtered_key_indices(&keys, Some("ERIC"));
979        assert_eq!(idx, vec![0, 2]);
980    }
981
982    #[test]
983    fn filtered_key_indices_no_match() {
984        let keys = search_corpus();
985        let idx = filtered_key_indices(&keys, Some("nonexistent"));
986        assert!(idx.is_empty());
987    }
988
989    #[test]
990    fn resolve_selection_unfiltered_is_identity() {
991        let keys = search_corpus();
992        assert_eq!(resolve_selection(&keys, None, 0), Some(0));
993        assert_eq!(resolve_selection(&keys, None, 2), Some(2));
994        assert_eq!(resolve_selection(&keys, None, 99), None);
995    }
996
997    #[test]
998    fn resolve_selection_filtered_maps_back_to_underlying() {
999        let keys = search_corpus();
1000        // "eric" matches indices 0 (id_ed25519, eric@mac) and 2 (customer-x, eric@customer).
1001        assert_eq!(resolve_selection(&keys, Some("eric"), 0), Some(0));
1002        assert_eq!(resolve_selection(&keys, Some("eric"), 1), Some(2));
1003        assert_eq!(resolve_selection(&keys, Some("eric"), 2), None);
1004    }
1005
1006    #[test]
1007    fn resolve_selection_no_match_returns_none() {
1008        let keys = search_corpus();
1009        assert_eq!(resolve_selection(&keys, Some("xyzzy"), 0), None);
1010    }
1011
1012    #[cfg(unix)]
1013    fn read_only_entry(dir: &Path, name: &str) -> std::fs::DirEntry {
1014        std::fs::read_dir(dir)
1015            .expect("read_dir")
1016            .filter_map(Result::ok)
1017            .find(|e| e.file_name() == name)
1018            .expect("entry not found")
1019    }
1020
1021    #[cfg(unix)]
1022    #[test]
1023    fn test_is_public_key_file_accepts_regular_pub_file() {
1024        let dir = tempfile::tempdir().unwrap();
1025        let path = dir.path().join("id_ed25519.pub");
1026        std::fs::write(&path, b"ssh-ed25519 AAAA").unwrap();
1027        let entry = read_only_entry(dir.path(), "id_ed25519.pub");
1028        assert!(is_public_key_file(&entry));
1029    }
1030
1031    #[cfg(unix)]
1032    #[test]
1033    fn test_is_public_key_file_accepts_symlink_to_regular_pub_file() {
1034        use std::os::unix::fs::symlink;
1035        let target_dir = tempfile::tempdir().unwrap();
1036        let link_dir = tempfile::tempdir().unwrap();
1037        let target = target_dir.path().join("id_ed25519.pub");
1038        std::fs::write(&target, b"ssh-ed25519 AAAA").unwrap();
1039        let link = link_dir.path().join("id_ed25519.pub");
1040        symlink(&target, &link).unwrap();
1041        let entry = read_only_entry(link_dir.path(), "id_ed25519.pub");
1042        assert!(is_public_key_file(&entry));
1043    }
1044
1045    #[cfg(unix)]
1046    #[test]
1047    fn test_is_public_key_file_rejects_broken_symlink() {
1048        use std::os::unix::fs::symlink;
1049        let dir = tempfile::tempdir().unwrap();
1050        let link = dir.path().join("id_ed25519.pub");
1051        symlink(dir.path().join("does_not_exist.pub"), &link).unwrap();
1052        let entry = read_only_entry(dir.path(), "id_ed25519.pub");
1053        assert!(!is_public_key_file(&entry));
1054    }
1055
1056    #[cfg(unix)]
1057    #[test]
1058    fn test_is_public_key_file_rejects_symlink_to_directory() {
1059        use std::os::unix::fs::symlink;
1060        let dir = tempfile::tempdir().unwrap();
1061        let real_dir = dir.path().join("realdir");
1062        std::fs::create_dir(&real_dir).unwrap();
1063        let link = dir.path().join("id_ed25519.pub");
1064        symlink(&real_dir, &link).unwrap();
1065        let entry = read_only_entry(dir.path(), "id_ed25519.pub");
1066        assert!(!is_public_key_file(&entry));
1067    }
1068
1069    // --- file_mtime_ts coverage (added during code review) ---
1070
1071    #[test]
1072    fn file_created_ts_returns_private_key_mtime_when_present() {
1073        let dir = tempfile::tempdir().unwrap();
1074        let priv_path = dir.path().join("id_ed25519");
1075        let pub_path = dir.path().join("id_ed25519.pub");
1076        std::fs::write(&priv_path, b"PRIVATE").unwrap();
1077        std::fs::write(&pub_path, b"ssh-ed25519 AAAA").unwrap();
1078        let ts = file_mtime_ts(&priv_path, &pub_path).expect("private mtime");
1079        assert!(ts > 0);
1080    }
1081
1082    #[test]
1083    fn file_created_ts_falls_back_to_pubkey_when_private_missing() {
1084        let dir = tempfile::tempdir().unwrap();
1085        let priv_path = dir.path().join("does_not_exist");
1086        let pub_path = dir.path().join("id_ed25519.pub");
1087        std::fs::write(&pub_path, b"ssh-ed25519 AAAA").unwrap();
1088        let ts = file_mtime_ts(&priv_path, &pub_path).expect("pubkey mtime");
1089        assert!(ts > 0);
1090    }
1091
1092    #[test]
1093    fn file_created_ts_returns_none_when_both_missing() {
1094        let dir = tempfile::tempdir().unwrap();
1095        let priv_path = dir.path().join("nope_priv");
1096        let pub_path = dir.path().join("nope_pub.pub");
1097        assert!(file_mtime_ts(&priv_path, &pub_path).is_none());
1098    }
1099}