1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{Instant, UNIX_EPOCH};
5
6use log::debug;
7
8use crate::ssh_config::model::HostEntry;
9
10mod native;
11
12pub fn resolve_ssh_dir(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
16 paths.map(crate::runtime::env::Paths::ssh_dir)
17}
18
19#[derive(Debug, Clone)]
21pub struct SshKeyInfo {
22 pub name: String,
24 pub display_path: String,
26 pub key_type: String,
28 pub bits: String,
30 pub fingerprint: String,
32 pub comment: String,
34 pub linked_hosts: Vec<String>,
36 pub bishop_art: String,
40 pub strength_score: u8,
44 pub encrypted: bool,
49 pub agent_loaded: bool,
51 pub is_certificate: bool,
55 pub mtime_ts: Option<u64>,
61}
62
63impl SshKeyInfo {
64 pub fn type_display(&self) -> String {
66 if self.bits.is_empty() {
67 self.key_type.clone()
68 } else {
69 format!("{} {}", self.key_type, self.bits)
70 }
71 }
72
73 pub fn bishop_lines(&self) -> Vec<&str> {
76 if self.bishop_art.is_empty() {
77 Vec::new()
78 } else {
79 self.bishop_art.lines().collect()
80 }
81 }
82}
83
84const BISHOP_CHARS: &[u8] = b" .o+=*BOX@%&#/^SE";
88
89const BISHOP_COUNTER_CAP: u8 = 14;
90const BISHOP_S_INDEX: u8 = 15;
91const BISHOP_E_INDEX: u8 = 16;
92
93pub fn decode_fingerprint(fp_str: &str) -> Option<Vec<u8>> {
100 use base64::Engine;
101 use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
102 let b64 = fp_str.strip_prefix("SHA256:")?;
103 let config = GeneralPurposeConfig::new()
104 .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent)
105 .with_decode_allow_trailing_bits(true);
106 let engine = GeneralPurpose::new(&base64::alphabet::STANDARD, config);
107 engine.decode(b64).ok()
108}
109
110pub fn drunken_bishop_grid(fp_bytes: &[u8], cols: usize, rows: usize) -> Vec<Vec<u8>> {
120 let mut grid = vec![vec![0u8; cols]; rows];
121 let mut x = cols / 2;
122 let mut y = rows / 2;
123 let start = (x, y);
124 for &byte in fp_bytes {
125 let mut b = byte;
126 for _ in 0..4 {
127 let dx: isize = if b & 0x1 == 0 { -1 } else { 1 };
128 let dy: isize = if b & 0x2 == 0 { -1 } else { 1 };
129 x = (x as isize + dx).clamp(0, cols as isize - 1) as usize;
130 y = (y as isize + dy).clamp(0, rows as isize - 1) as usize;
131 if grid[y][x] < BISHOP_COUNTER_CAP {
132 grid[y][x] += 1;
133 }
134 b >>= 2;
135 }
136 }
137 grid[start.1][start.0] = BISHOP_S_INDEX;
138 grid[y][x] = BISHOP_E_INDEX;
139 grid
140}
141
142pub fn bishop_char(counter: u8) -> char {
144 let idx = counter.min(BISHOP_E_INDEX) as usize;
145 BISHOP_CHARS[idx] as char
146}
147
148pub fn resolve_selection(keys: &[SshKeyInfo], query: Option<&str>, sel: usize) -> Option<usize> {
155 let filtered = filtered_key_indices(keys, query);
156 filtered.get(sel).copied()
157}
158
159pub fn filtered_key_indices(keys: &[SshKeyInfo], query: Option<&str>) -> Vec<usize> {
165 match query {
166 None | Some("") => (0..keys.len()).collect(),
167 Some(q) => {
168 let needle = q.to_ascii_lowercase();
169 keys.iter()
170 .enumerate()
171 .filter(|(_, k)| {
172 k.name.to_ascii_lowercase().contains(&needle)
173 || k.comment.to_ascii_lowercase().contains(&needle)
174 })
175 .map(|(i, _)| i)
176 .collect()
177 }
178 }
179}
180
181pub fn discover_keys(
188 paths: Option<&crate::runtime::env::Paths>,
189 ssh_dir: &Path,
190 hosts: &[HostEntry],
191) -> Vec<SshKeyInfo> {
192 let entries = match std::fs::read_dir(ssh_dir) {
193 Ok(entries) => entries,
194 Err(_) => return Vec::new(),
195 };
196
197 let started = Instant::now();
198 let home = paths.map(|p| p.home().to_path_buf());
199 let agent_fingerprints = agent_loaded_fingerprints();
200
201 let mut keys: Vec<SshKeyInfo> = entries
202 .filter_map(|e| e.ok())
203 .filter(is_public_key_file)
204 .filter_map(|e| {
205 read_key_info(
206 ssh_dir,
207 &e.path(),
208 home.as_deref(),
209 hosts,
210 &agent_fingerprints,
211 )
212 })
213 .collect();
214
215 keys.sort_by(|a, b| a.name.cmp(&b.name));
216 debug!(
217 "[purple] discover_keys: found {} key(s) in {}, {} loaded in agent, took {}ms",
218 keys.len(),
219 ssh_dir.display(),
220 agent_fingerprints.len(),
221 started.elapsed().as_millis()
222 );
223 keys
224}
225
226fn agent_loaded_fingerprints() -> HashSet<String> {
232 let output = Command::new("ssh-add").arg("-l").output();
233 match output {
234 Ok(o) if o.status.success() => parse_agent_list(&String::from_utf8_lossy(&o.stdout)),
235 Ok(o) => {
236 let code = o.status.code().unwrap_or(-1);
237 let stderr = String::from_utf8_lossy(&o.stderr);
238 log::debug!(
239 "[external] ssh-add -l non-zero exit={code} stderr={}",
240 stderr.trim().lines().next().unwrap_or("<empty>"),
241 );
242 HashSet::new()
243 }
244 Err(e) => {
245 log::debug!("[external] ssh-add spawn failed: {e}");
246 HashSet::new()
247 }
248 }
249}
250
251fn parse_agent_list(stdout: &str) -> HashSet<String> {
256 stdout
257 .lines()
258 .filter_map(|line| {
259 let parts: Vec<&str> = line.splitn(3, ' ').collect();
260 if parts.len() >= 2 && parts[1].starts_with("SHA256:") {
261 Some(parts[1].to_string())
262 } else {
263 None
264 }
265 })
266 .collect()
267}
268
269fn strength_score_for(key_type: &str, bits: &str, encrypted: bool) -> u8 {
274 let is_sk = key_type.to_ascii_lowercase().starts_with("sk-");
278 let base: i16 = if is_sk {
279 95
280 } else {
281 match key_type.to_ascii_uppercase().as_str() {
282 "DSA" => 5,
283 "RSA" => match bits.parse::<u32>().unwrap_or(0) {
284 0..=1023 => 5,
285 1024..=2047 => 15,
286 2048..=3071 => 55,
287 3072..=4095 => 75,
288 _ => 80,
289 },
290 "ECDSA" => match bits.parse::<u32>().unwrap_or(0) {
291 256 => 70,
292 384 => 80,
293 521 => 85,
294 _ => 60,
295 },
296 "ED25519" => 90,
297 _ => 50,
298 }
299 };
300 let modifier: i16 = if encrypted { 5 } else { -10 };
301 (base + modifier).clamp(0, 100) as u8
302}
303
304fn private_key_encrypted(private_path: &Path) -> bool {
311 if !private_path.is_file() {
312 return false;
313 }
314 if let Some(encrypted) = native::private_key_encrypted(private_path) {
315 return encrypted;
316 }
317 debug!(
318 "[purple] key scan: ssh-keygen fallback for encryption check of {}",
319 private_path.display()
320 );
321 keygen_private_key_encrypted(private_path)
322}
323
324fn keygen_private_key_encrypted(private_path: &Path) -> bool {
325 let output = Command::new("ssh-keygen")
326 .arg("-y")
327 .args(["-P", ""])
328 .arg("-f")
329 .arg(private_path)
330 .output();
331 match output {
332 Ok(o) => !o.status.success(),
333 Err(_) => false,
334 }
335}
336
337fn parse_bishop_block(stdout: &str) -> String {
344 let art_lines: Vec<&str> = stdout
345 .lines()
346 .filter(|l| {
347 let t = l.trim_end();
348 (t.starts_with('+') && t.ends_with('+')) || (t.starts_with('|') && t.ends_with('|'))
349 })
350 .collect();
351 if art_lines.len() == 11 {
352 art_lines.join("\n")
353 } else {
354 String::new()
355 }
356}
357
358fn is_public_key_file(entry: &std::fs::DirEntry) -> bool {
360 let name = entry.file_name();
361 let name = name.to_string_lossy();
362
363 if !name.ends_with(".pub") {
365 return false;
366 }
367
368 let skip = ["authorized_keys.pub", "known_hosts.pub"];
370 if skip.contains(&name.as_ref()) {
371 return false;
372 }
373
374 std::fs::metadata(entry.path())
378 .map(|m| m.is_file())
379 .unwrap_or(false)
380}
381
382#[derive(Debug, Clone, PartialEq, Eq)]
384struct PublicKeyFacts {
385 bits: String,
386 fingerprint: String,
387 comment: String,
388 key_type: String,
389 bishop_art: String,
390}
391
392fn public_key_facts(pub_path: &Path) -> Option<PublicKeyFacts> {
395 if let Some(facts) = native::inspect_public_key(pub_path) {
396 return Some(facts);
397 }
398 debug!(
399 "[purple] key scan: ssh-keygen fallback for {}",
400 pub_path.display()
401 );
402 keygen_public_key_facts(pub_path)
403}
404
405fn keygen_public_key_facts(pub_path: &Path) -> Option<PublicKeyFacts> {
406 let output = Command::new("ssh-keygen")
407 .arg("-lv")
408 .arg("-f")
409 .arg(pub_path)
410 .args(["-E", "sha256"])
411 .output()
412 .ok()?;
413
414 if !output.status.success() {
415 return None;
416 }
417
418 let stdout = String::from_utf8_lossy(&output.stdout);
419 let first_line = stdout.lines().next()?.trim();
420
421 let (bits, fingerprint, comment, key_type) = parse_keygen_output(first_line)?;
423 Some(PublicKeyFacts {
424 bits,
425 fingerprint,
426 comment,
427 key_type,
428 bishop_art: parse_bishop_block(&stdout),
429 })
430}
431
432fn read_key_info(
435 ssh_dir: &Path,
436 pub_path: &Path,
437 home: Option<&Path>,
438 hosts: &[HostEntry],
439 agent_fingerprints: &HashSet<String>,
440) -> Option<SshKeyInfo> {
441 let PublicKeyFacts {
442 bits,
443 fingerprint,
444 comment,
445 key_type,
446 bishop_art,
447 } = public_key_facts(pub_path)?;
448
449 let pub_name = pub_path.file_name()?.to_string_lossy();
451 let name = pub_name
452 .strip_suffix(".pub")
453 .unwrap_or(&pub_name)
454 .to_string();
455
456 let private_path = ssh_dir.join(&name);
458
459 let display_path = match home {
461 Some(home) if ssh_dir.starts_with(home) => {
462 let relative = ssh_dir.strip_prefix(home).unwrap();
463 format!("~/{}/{}", relative.display(), name)
464 }
465 _ => private_path.display().to_string(),
466 };
467
468 let linked_hosts = find_linked_hosts(&private_path, &display_path, hosts);
470
471 let is_certificate = detect_certificate(&pub_name, &key_type);
472
473 let encrypted = if is_certificate {
476 false
477 } else {
478 private_key_encrypted(&private_path)
479 };
480
481 let agent_loaded = agent_fingerprints.contains(&fingerprint);
483
484 let strength_score = strength_score_for(&key_type, &bits, encrypted);
485
486 let mtime_ts = file_mtime_ts(&private_path, pub_path);
487
488 Some(SshKeyInfo {
489 name,
490 display_path,
491 key_type,
492 bits,
493 fingerprint,
494 comment,
495 linked_hosts,
496 bishop_art,
497 strength_score,
498 encrypted,
499 agent_loaded,
500 is_certificate,
501 mtime_ts,
502 })
503}
504
505fn file_mtime_ts(private_path: &Path, pub_path: &Path) -> Option<u64> {
511 let from = |p: &Path| {
512 std::fs::metadata(p)
513 .ok()
514 .and_then(|m| m.modified().ok())
515 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
516 .map(|d| d.as_secs())
517 };
518 from(private_path).or_else(|| from(pub_path))
519}
520
521fn detect_certificate(pub_name: &str, key_type: &str) -> bool {
529 pub_name.ends_with("-cert.pub") || key_type.to_ascii_lowercase().contains("-cert")
530}
531
532fn parse_keygen_output(line: &str) -> Option<(String, String, String, String)> {
534 let parts: Vec<&str> = line.splitn(3, ' ').collect();
535 if parts.len() < 3 {
536 return None;
537 }
538
539 let bits = parts[0].to_string();
540 let fingerprint = parts[1].to_string();
541
542 let rest = parts[2];
544 let (comment, key_type) = if let Some(paren_start) = rest.rfind('(') {
545 let comment = rest[..paren_start].trim().to_string();
546 let key_type = rest[paren_start + 1..].trim_end_matches(')').to_string();
547 (comment, key_type)
548 } else {
549 (rest.to_string(), String::new())
550 };
551
552 Some((bits, fingerprint, comment, key_type))
553}
554
555fn find_linked_hosts(full_path: &Path, display_path: &str, hosts: &[HostEntry]) -> Vec<String> {
558 hosts
563 .iter()
564 .filter(|h| {
565 if h.identity_file.is_empty() {
566 return false;
567 }
568 h.identity_file == display_path || Path::new(&h.identity_file) == full_path
569 })
570 .map(|h| h.alias.clone())
571 .collect()
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn test_parse_keygen_output_ed25519() {
580 let line = "256 SHA256:abcdef1234567890 user@host (ED25519)";
581 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
582 assert_eq!(bits, "256");
583 assert_eq!(fp, "SHA256:abcdef1234567890");
584 assert_eq!(comment, "user@host");
585 assert_eq!(key_type, "ED25519");
586 }
587
588 #[test]
589 fn test_parse_keygen_output_rsa() {
590 let line = "4096 SHA256:xyz9876543210 deploy@prod.example.com (RSA)";
591 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
592 assert_eq!(bits, "4096");
593 assert_eq!(fp, "SHA256:xyz9876543210");
594 assert_eq!(comment, "deploy@prod.example.com");
595 assert_eq!(key_type, "RSA");
596 }
597
598 #[test]
599 fn test_parse_keygen_output_no_comment() {
600 let line = "256 SHA256:fingerprint (ED25519)";
601 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
602 assert_eq!(bits, "256");
603 assert_eq!(fp, "SHA256:fingerprint");
604 assert_eq!(comment, "");
605 assert_eq!(key_type, "ED25519");
606 }
607
608 #[test]
609 fn test_parse_keygen_output_comment_with_spaces() {
610 let line = "256 SHA256:fingerprint eko@MacBook Pro (ED25519)";
611 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
612 assert_eq!(bits, "256");
613 assert_eq!(fp, "SHA256:fingerprint");
614 assert_eq!(comment, "eko@MacBook Pro");
615 assert_eq!(key_type, "ED25519");
616 }
617
618 #[test]
619 fn test_parse_keygen_output_no_type_parens() {
620 let line = "256 SHA256:fingerprint user@host";
621 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
622 assert_eq!(bits, "256");
623 assert_eq!(fp, "SHA256:fingerprint");
624 assert_eq!(comment, "user@host");
625 assert_eq!(key_type, "");
626 }
627
628 #[test]
629 fn test_parse_keygen_output_too_short() {
630 assert!(parse_keygen_output("256 SHA256:fp").is_none());
631 assert!(parse_keygen_output("").is_none());
632 }
633
634 #[test]
635 fn test_find_linked_hosts_display_path() {
636 let hosts = vec![
637 HostEntry {
638 alias: "prod".to_string(),
639 identity_file: "~/.ssh/id_ed25519".to_string(),
640 ..Default::default()
641 },
642 HostEntry {
643 alias: "staging".to_string(),
644 identity_file: "~/.ssh/other_key".to_string(),
645 ..Default::default()
646 },
647 ];
648 let linked = find_linked_hosts(
649 Path::new("/home/user/.ssh/id_ed25519"),
650 "~/.ssh/id_ed25519",
651 &hosts,
652 );
653 assert_eq!(linked, vec!["prod"]);
654 }
655
656 #[test]
657 fn test_find_linked_hosts_full_path() {
658 let hosts = vec![HostEntry {
659 alias: "server".to_string(),
660 identity_file: "/home/user/.ssh/deploy_key".to_string(),
661 ..Default::default()
662 }];
663 let linked = find_linked_hosts(
664 Path::new("/home/user/.ssh/deploy_key"),
665 "~/.ssh/deploy_key",
666 &hosts,
667 );
668 assert_eq!(linked, vec!["server"]);
669 }
670
671 #[test]
672 fn test_find_linked_hosts_no_identity_file_does_not_link() {
673 let hosts = vec![HostEntry {
677 alias: "server".to_string(),
678 identity_file: String::new(),
679 ..Default::default()
680 }];
681 let linked =
682 find_linked_hosts(Path::new("/home/user/.ssh/id_rsa"), "~/.ssh/id_rsa", &hosts);
683 assert!(linked.is_empty());
684 }
685
686 #[test]
687 fn test_find_linked_hosts_wrong_identity_file() {
688 let hosts = vec![HostEntry {
689 alias: "server".to_string(),
690 identity_file: "~/.ssh/other_key".to_string(),
691 ..Default::default()
692 }];
693 let linked =
694 find_linked_hosts(Path::new("/home/user/.ssh/id_rsa"), "~/.ssh/id_rsa", &hosts);
695 assert!(linked.is_empty());
696 }
697
698 fn sample_key() -> SshKeyInfo {
699 SshKeyInfo {
700 name: "id_ed25519".to_string(),
701 display_path: "~/.ssh/id_ed25519".to_string(),
702 key_type: "ED25519".to_string(),
703 bits: "256".to_string(),
704 fingerprint: "SHA256:8x2k7HhPqQfvN5jJrUvWxTsXmnQ4LpBkEoYzNcAdGhI".to_string(),
705 comment: "eric@MacBook".to_string(),
706 linked_hosts: Vec::new(),
707 bishop_art: String::new(),
708 strength_score: 95,
709 encrypted: true,
710 agent_loaded: true,
711 is_certificate: false,
712 mtime_ts: None,
713 }
714 }
715
716 #[test]
717 fn test_type_display() {
718 let key = sample_key();
719 assert_eq!(key.type_display(), "ED25519 256");
720
721 let key2 = SshKeyInfo {
722 bits: String::new(),
723 ..key
724 };
725 assert_eq!(key2.type_display(), "ED25519");
726 }
727
728 #[test]
729 fn detect_certificate_via_filename_suffix() {
730 assert!(detect_certificate("id_ed25519-cert.pub", "ED25519"));
731 }
732
733 #[test]
734 fn detect_certificate_via_key_type_full_oid() {
735 assert!(detect_certificate(
738 "id_ed25519-vault.pub",
739 "ED25519-CERT-V01@openssh.com"
740 ));
741 }
742
743 #[test]
744 fn detect_certificate_via_key_type_short() {
745 assert!(detect_certificate(
746 "id_ed25519-breakglass.pub",
747 "ED25519-CERT"
748 ));
749 }
750
751 #[test]
752 fn detect_certificate_rejects_plain_key() {
753 assert!(!detect_certificate("id_ed25519.pub", "ED25519"));
754 }
755
756 #[test]
757 fn detect_certificate_rejects_unrelated_dash_cert_in_name() {
758 assert!(!detect_certificate("my-cert-backup.pub", "RSA"));
761 }
762
763 #[test]
764 fn drunken_bishop_matches_openssh_canonical_17x9() {
765 let fp = decode_fingerprint("SHA256:1LayGj+CVIvJfOnQqADAT52DoJHhSa30feF/23wbRuE")
769 .expect("decode fingerprint");
770 let grid = drunken_bishop_grid(&fp, 17, 9);
771 let rendered: Vec<String> = grid
772 .iter()
773 .map(|row| row.iter().map(|&c| bishop_char(c)).collect())
774 .collect();
775 assert_eq!(
776 rendered,
777 vec![
778 "+=o o . ",
779 "*+.+ + . . ",
780 "+o= . o o o . ",
781 ".. o ..+ . . . .",
782 ". o *.oS . E ",
783 ". O = + . . ",
784 " . o =. . . + o ",
785 " . . o+. . o...",
786 " .... ...",
787 ]
788 );
789 }
790
791 #[test]
792 fn drunken_bishop_scales_to_larger_grid() {
793 let fp = decode_fingerprint("SHA256:1LayGj+CVIvJfOnQqADAT52DoJHhSa30feF/23wbRuE")
798 .expect("decode fingerprint");
799 let grid = drunken_bishop_grid(&fp, 25, 13);
800 assert_eq!(grid.len(), 13);
801 assert!(grid.iter().all(|row| row.len() == 25));
802 assert_eq!(grid[6][12], BISHOP_S_INDEX);
803 }
804
805 #[test]
806 fn decode_fingerprint_rejects_other_hash_prefixes() {
807 assert!(decode_fingerprint("MD5:abcd").is_none());
808 assert!(decode_fingerprint("plain-text").is_none());
809 }
810
811 #[test]
812 fn test_bishop_lines_empty() {
813 let key = SshKeyInfo {
814 bishop_art: String::new(),
815 ..sample_key()
816 };
817 assert!(key.bishop_lines().is_empty());
818 }
819
820 #[test]
821 fn test_bishop_lines_split() {
822 let key = SshKeyInfo {
823 bishop_art: "+--[ED25519 256]--+\n| .o*+ |\n+----[SHA256]-----+".to_string(),
824 ..sample_key()
825 };
826 assert_eq!(key.bishop_lines().len(), 3);
827 assert_eq!(key.bishop_lines()[1], "| .o*+ |");
828 }
829
830 #[test]
831 fn test_parse_agent_list_two_keys() {
832 let stdout = "256 SHA256:abc1 eric@host (ED25519)\n4096 SHA256:def2 work@laptop (RSA)\n";
833 let set = parse_agent_list(stdout);
834 assert_eq!(set.len(), 2);
835 assert!(set.contains("SHA256:abc1"));
836 assert!(set.contains("SHA256:def2"));
837 }
838
839 #[test]
840 fn test_parse_agent_list_empty_agent() {
841 let stdout = "The agent has no identities.\n";
842 let set = parse_agent_list(stdout);
843 assert!(set.is_empty());
844 }
845
846 #[test]
847 fn test_parse_agent_list_banner_skipped() {
848 let stdout = "Could not open a connection to your authentication agent.\n";
849 let set = parse_agent_list(stdout);
850 assert!(set.is_empty());
851 }
852
853 #[test]
854 fn test_strength_score_ed25519() {
855 assert_eq!(strength_score_for("ED25519", "256", true), 95);
856 assert_eq!(strength_score_for("ED25519", "256", false), 80);
857 }
858
859 #[test]
860 fn test_strength_score_sk_ed25519() {
861 assert_eq!(strength_score_for("sk-ED25519", "256", true), 100);
862 assert_eq!(strength_score_for("sk-ED25519", "256", false), 85);
863 }
864
865 #[test]
866 fn test_strength_score_rsa_buckets() {
867 assert_eq!(strength_score_for("RSA", "1024", true), 20);
868 assert_eq!(strength_score_for("RSA", "2048", true), 60);
869 assert_eq!(strength_score_for("RSA", "3072", true), 80);
870 assert_eq!(strength_score_for("RSA", "4096", true), 85);
871 assert_eq!(strength_score_for("RSA", "8192", true), 85);
872 }
873
874 #[test]
875 fn test_strength_score_dsa_is_low() {
876 assert_eq!(strength_score_for("DSA", "1024", true), 10);
877 assert_eq!(strength_score_for("DSA", "1024", false), 0);
878 }
879
880 #[test]
881 fn test_strength_score_ecdsa_buckets() {
882 assert_eq!(strength_score_for("ECDSA", "256", true), 75);
883 assert_eq!(strength_score_for("ECDSA", "384", true), 85);
884 assert_eq!(strength_score_for("ECDSA", "521", true), 90);
885 }
886
887 #[test]
888 fn test_strength_score_unknown_type() {
889 assert_eq!(strength_score_for("WEIRD", "256", true), 55);
890 assert_eq!(strength_score_for("", "0", false), 40);
891 }
892
893 #[test]
894 fn test_parse_bishop_block_typical_output() {
895 let stdout = "\
896256 SHA256:abc eric@host (ED25519)
897+--[ED25519 256]--+
898| |
899| |
900| . . . ... |
901| o o..ooo.o|
902| . S =.oo+==|
903| . o B +E*B|
904| . . O =.=.+|
905| .. = B o.oo|
906| .oo.+.=o.. |
907+----[SHA256]-----+
908";
909 let art = parse_bishop_block(stdout);
910 assert_eq!(art.lines().count(), 11);
911 assert!(art.starts_with("+--[ED25519 256]--+"));
912 assert!(art.ends_with("+----[SHA256]-----+"));
913 }
914
915 #[test]
916 fn test_parse_bishop_block_missing_returns_empty() {
917 let stdout = "256 SHA256:abc eric@host (ED25519)\n";
918 assert!(parse_bishop_block(stdout).is_empty());
919 }
920
921 #[test]
922 fn test_parse_bishop_block_truncated_returns_empty() {
923 let stdout = "+--[ED25519 256]--+\n| |\n+--+\n";
924 assert!(parse_bishop_block(stdout).is_empty());
925 }
926
927 fn search_corpus() -> Vec<SshKeyInfo> {
928 vec![
929 SshKeyInfo {
930 name: "id_ed25519".into(),
931 comment: "eric@mac".into(),
932 ..sample_key()
933 },
934 SshKeyInfo {
935 name: "yubikey_work".into(),
936 comment: "yubi@work".into(),
937 ..sample_key()
938 },
939 SshKeyInfo {
940 name: "customer-x".into(),
941 comment: "eric@customer".into(),
942 ..sample_key()
943 },
944 ]
945 }
946
947 #[test]
948 fn filtered_key_indices_none_returns_all() {
949 let keys = search_corpus();
950 let idx = filtered_key_indices(&keys, None);
951 assert_eq!(idx, vec![0, 1, 2]);
952 }
953
954 #[test]
955 fn filtered_key_indices_empty_returns_all() {
956 let keys = search_corpus();
957 let idx = filtered_key_indices(&keys, Some(""));
958 assert_eq!(idx, vec![0, 1, 2]);
959 }
960
961 #[test]
962 fn filtered_key_indices_matches_name() {
963 let keys = search_corpus();
964 let idx = filtered_key_indices(&keys, Some("yubi"));
965 assert_eq!(idx, vec![1]);
966 }
967
968 #[test]
969 fn filtered_key_indices_matches_comment() {
970 let keys = search_corpus();
971 let idx = filtered_key_indices(&keys, Some("eric"));
972 assert_eq!(idx, vec![0, 2]);
973 }
974
975 #[test]
976 fn filtered_key_indices_case_insensitive() {
977 let keys = search_corpus();
978 let idx = filtered_key_indices(&keys, Some("ERIC"));
979 assert_eq!(idx, vec![0, 2]);
980 }
981
982 #[test]
983 fn filtered_key_indices_no_match() {
984 let keys = search_corpus();
985 let idx = filtered_key_indices(&keys, Some("nonexistent"));
986 assert!(idx.is_empty());
987 }
988
989 #[test]
990 fn resolve_selection_unfiltered_is_identity() {
991 let keys = search_corpus();
992 assert_eq!(resolve_selection(&keys, None, 0), Some(0));
993 assert_eq!(resolve_selection(&keys, None, 2), Some(2));
994 assert_eq!(resolve_selection(&keys, None, 99), None);
995 }
996
997 #[test]
998 fn resolve_selection_filtered_maps_back_to_underlying() {
999 let keys = search_corpus();
1000 assert_eq!(resolve_selection(&keys, Some("eric"), 0), Some(0));
1002 assert_eq!(resolve_selection(&keys, Some("eric"), 1), Some(2));
1003 assert_eq!(resolve_selection(&keys, Some("eric"), 2), None);
1004 }
1005
1006 #[test]
1007 fn resolve_selection_no_match_returns_none() {
1008 let keys = search_corpus();
1009 assert_eq!(resolve_selection(&keys, Some("xyzzy"), 0), None);
1010 }
1011
1012 #[cfg(unix)]
1013 fn read_only_entry(dir: &Path, name: &str) -> std::fs::DirEntry {
1014 std::fs::read_dir(dir)
1015 .expect("read_dir")
1016 .filter_map(Result::ok)
1017 .find(|e| e.file_name() == name)
1018 .expect("entry not found")
1019 }
1020
1021 #[cfg(unix)]
1022 #[test]
1023 fn test_is_public_key_file_accepts_regular_pub_file() {
1024 let dir = tempfile::tempdir().unwrap();
1025 let path = dir.path().join("id_ed25519.pub");
1026 std::fs::write(&path, b"ssh-ed25519 AAAA").unwrap();
1027 let entry = read_only_entry(dir.path(), "id_ed25519.pub");
1028 assert!(is_public_key_file(&entry));
1029 }
1030
1031 #[cfg(unix)]
1032 #[test]
1033 fn test_is_public_key_file_accepts_symlink_to_regular_pub_file() {
1034 use std::os::unix::fs::symlink;
1035 let target_dir = tempfile::tempdir().unwrap();
1036 let link_dir = tempfile::tempdir().unwrap();
1037 let target = target_dir.path().join("id_ed25519.pub");
1038 std::fs::write(&target, b"ssh-ed25519 AAAA").unwrap();
1039 let link = link_dir.path().join("id_ed25519.pub");
1040 symlink(&target, &link).unwrap();
1041 let entry = read_only_entry(link_dir.path(), "id_ed25519.pub");
1042 assert!(is_public_key_file(&entry));
1043 }
1044
1045 #[cfg(unix)]
1046 #[test]
1047 fn test_is_public_key_file_rejects_broken_symlink() {
1048 use std::os::unix::fs::symlink;
1049 let dir = tempfile::tempdir().unwrap();
1050 let link = dir.path().join("id_ed25519.pub");
1051 symlink(dir.path().join("does_not_exist.pub"), &link).unwrap();
1052 let entry = read_only_entry(dir.path(), "id_ed25519.pub");
1053 assert!(!is_public_key_file(&entry));
1054 }
1055
1056 #[cfg(unix)]
1057 #[test]
1058 fn test_is_public_key_file_rejects_symlink_to_directory() {
1059 use std::os::unix::fs::symlink;
1060 let dir = tempfile::tempdir().unwrap();
1061 let real_dir = dir.path().join("realdir");
1062 std::fs::create_dir(&real_dir).unwrap();
1063 let link = dir.path().join("id_ed25519.pub");
1064 symlink(&real_dir, &link).unwrap();
1065 let entry = read_only_entry(dir.path(), "id_ed25519.pub");
1066 assert!(!is_public_key_file(&entry));
1067 }
1068
1069 #[test]
1072 fn file_created_ts_returns_private_key_mtime_when_present() {
1073 let dir = tempfile::tempdir().unwrap();
1074 let priv_path = dir.path().join("id_ed25519");
1075 let pub_path = dir.path().join("id_ed25519.pub");
1076 std::fs::write(&priv_path, b"PRIVATE").unwrap();
1077 std::fs::write(&pub_path, b"ssh-ed25519 AAAA").unwrap();
1078 let ts = file_mtime_ts(&priv_path, &pub_path).expect("private mtime");
1079 assert!(ts > 0);
1080 }
1081
1082 #[test]
1083 fn file_created_ts_falls_back_to_pubkey_when_private_missing() {
1084 let dir = tempfile::tempdir().unwrap();
1085 let priv_path = dir.path().join("does_not_exist");
1086 let pub_path = dir.path().join("id_ed25519.pub");
1087 std::fs::write(&pub_path, b"ssh-ed25519 AAAA").unwrap();
1088 let ts = file_mtime_ts(&priv_path, &pub_path).expect("pubkey mtime");
1089 assert!(ts > 0);
1090 }
1091
1092 #[test]
1093 fn file_created_ts_returns_none_when_both_missing() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let priv_path = dir.path().join("nope_priv");
1096 let pub_path = dir.path().join("nope_pub.pub");
1097 assert!(file_mtime_ts(&priv_path, &pub_path).is_none());
1098 }
1099}