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