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