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
10pub fn resolve_ssh_dir(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
14 paths.map(crate::runtime::env::Paths::ssh_dir)
15}
16
17#[derive(Debug, Clone)]
19pub struct SshKeyInfo {
20 pub name: String,
22 pub display_path: String,
24 pub key_type: String,
26 pub bits: String,
28 pub fingerprint: String,
30 pub comment: String,
32 pub linked_hosts: Vec<String>,
34 pub bishop_art: String,
38 pub strength_score: u8,
42 pub encrypted: bool,
46 pub agent_loaded: bool,
48 pub is_certificate: bool,
52 pub mtime_ts: Option<u64>,
58}
59
60impl SshKeyInfo {
61 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 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
81const 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
90pub 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
107pub 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
139pub fn bishop_char(counter: u8) -> char {
141 let idx = counter.min(BISHOP_E_INDEX) as usize;
142 BISHOP_CHARS[idx] as char
143}
144
145pub 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
156pub 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
178pub 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
221fn 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
246fn 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
264fn strength_score_for(key_type: &str, bits: &str, encrypted: bool) -> u8 {
269 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
299fn 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
320fn 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
341fn is_public_key_file(entry: &std::fs::DirEntry) -> bool {
343 let name = entry.file_name();
344 let name = name.to_string_lossy();
345
346 if !name.ends_with(".pub") {
348 return false;
349 }
350
351 let skip = ["authorized_keys.pub", "known_hosts.pub"];
353 if skip.contains(&name.as_ref()) {
354 return false;
355 }
356
357 std::fs::metadata(entry.path())
361 .map(|m| m.is_file())
362 .unwrap_or(false)
363}
364
365fn 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 let (bits, fingerprint, comment, key_type) = parse_keygen_output(first_line)?;
391
392 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 let private_path = ssh_dir.join(&name);
401
402 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 let linked_hosts = find_linked_hosts(&private_path, &display_path, hosts);
413
414 let bishop_art = parse_bishop_block(&stdout);
416
417 let is_certificate = detect_certificate(&pub_name, &key_type);
418
419 let encrypted = if is_certificate {
422 false
423 } else {
424 private_key_encrypted(&private_path)
425 };
426
427 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
451fn 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
467fn 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
478fn 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 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
501fn find_linked_hosts(full_path: &Path, display_path: &str, hosts: &[HostEntry]) -> Vec<String> {
504 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 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 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 assert!(!detect_certificate("my-cert-backup.pub", "RSA"));
707 }
708
709 #[test]
710 fn drunken_bishop_matches_openssh_canonical_17x9() {
711 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 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 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 #[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}