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(ssh_dir: &Path, hosts: &[HostEntry]) -> Vec<SshKeyInfo> {
185 let entries = match std::fs::read_dir(ssh_dir) {
186 Ok(entries) => entries,
187 Err(_) => return Vec::new(),
188 };
189
190 let home = dirs::home_dir();
191 let agent_fingerprints = agent_loaded_fingerprints();
192
193 let mut keys: Vec<SshKeyInfo> = entries
194 .filter_map(|e| e.ok())
195 .filter(is_public_key_file)
196 .filter_map(|e| {
197 read_key_info(
198 ssh_dir,
199 &e.path(),
200 home.as_deref(),
201 hosts,
202 &agent_fingerprints,
203 )
204 })
205 .collect();
206
207 keys.sort_by(|a, b| a.name.cmp(&b.name));
208 debug!(
209 "[purple] discover_keys: found {} key(s) in {}, {} loaded in agent",
210 keys.len(),
211 ssh_dir.display(),
212 agent_fingerprints.len()
213 );
214 keys
215}
216
217fn agent_loaded_fingerprints() -> HashSet<String> {
223 let output = Command::new("ssh-add").arg("-l").output();
224 match output {
225 Ok(o) if o.status.success() => parse_agent_list(&String::from_utf8_lossy(&o.stdout)),
226 Ok(o) => {
227 let code = o.status.code().unwrap_or(-1);
228 let stderr = String::from_utf8_lossy(&o.stderr);
229 log::debug!(
230 "[external] ssh-add -l non-zero exit={code} stderr={}",
231 stderr.trim().lines().next().unwrap_or("<empty>"),
232 );
233 HashSet::new()
234 }
235 Err(e) => {
236 log::debug!("[external] ssh-add spawn failed: {e}");
237 HashSet::new()
238 }
239 }
240}
241
242fn parse_agent_list(stdout: &str) -> HashSet<String> {
247 stdout
248 .lines()
249 .filter_map(|line| {
250 let parts: Vec<&str> = line.splitn(3, ' ').collect();
251 if parts.len() >= 2 && parts[1].starts_with("SHA256:") {
252 Some(parts[1].to_string())
253 } else {
254 None
255 }
256 })
257 .collect()
258}
259
260fn strength_score_for(key_type: &str, bits: &str, encrypted: bool) -> u8 {
265 let is_sk = key_type.to_ascii_lowercase().starts_with("sk-");
269 let base: i16 = if is_sk {
270 95
271 } else {
272 match key_type.to_ascii_uppercase().as_str() {
273 "DSA" => 5,
274 "RSA" => match bits.parse::<u32>().unwrap_or(0) {
275 0..=1023 => 5,
276 1024..=2047 => 15,
277 2048..=3071 => 55,
278 3072..=4095 => 75,
279 _ => 80,
280 },
281 "ECDSA" => match bits.parse::<u32>().unwrap_or(0) {
282 256 => 70,
283 384 => 80,
284 521 => 85,
285 _ => 60,
286 },
287 "ED25519" => 90,
288 _ => 50,
289 }
290 };
291 let modifier: i16 = if encrypted { 5 } else { -10 };
292 (base + modifier).clamp(0, 100) as u8
293}
294
295fn private_key_encrypted(private_path: &Path) -> bool {
301 if !private_path.exists() {
302 return false;
303 }
304 let output = Command::new("ssh-keygen")
305 .arg("-y")
306 .args(["-P", ""])
307 .arg("-f")
308 .arg(private_path)
309 .output();
310 match output {
311 Ok(o) => !o.status.success(),
312 Err(_) => false,
313 }
314}
315
316fn parse_bishop_block(stdout: &str) -> String {
323 let art_lines: Vec<&str> = stdout
324 .lines()
325 .filter(|l| {
326 let t = l.trim_end();
327 (t.starts_with('+') && t.ends_with('+')) || (t.starts_with('|') && t.ends_with('|'))
328 })
329 .collect();
330 if art_lines.len() == 11 {
331 art_lines.join("\n")
332 } else {
333 String::new()
334 }
335}
336
337fn is_public_key_file(entry: &std::fs::DirEntry) -> bool {
339 let name = entry.file_name();
340 let name = name.to_string_lossy();
341
342 if !name.ends_with(".pub") {
344 return false;
345 }
346
347 let skip = ["authorized_keys.pub", "known_hosts.pub"];
349 if skip.contains(&name.as_ref()) {
350 return false;
351 }
352
353 std::fs::metadata(entry.path())
357 .map(|m| m.is_file())
358 .unwrap_or(false)
359}
360
361fn read_key_info(
364 ssh_dir: &Path,
365 pub_path: &Path,
366 home: Option<&Path>,
367 hosts: &[HostEntry],
368 agent_fingerprints: &HashSet<String>,
369) -> Option<SshKeyInfo> {
370 let output = Command::new("ssh-keygen")
371 .arg("-lv")
372 .arg("-f")
373 .arg(pub_path)
374 .args(["-E", "sha256"])
375 .output()
376 .ok()?;
377
378 if !output.status.success() {
379 return None;
380 }
381
382 let stdout = String::from_utf8_lossy(&output.stdout);
383 let first_line = stdout.lines().next()?.trim();
384
385 let (bits, fingerprint, comment, key_type) = parse_keygen_output(first_line)?;
387
388 let pub_name = pub_path.file_name()?.to_string_lossy();
390 let name = pub_name
391 .strip_suffix(".pub")
392 .unwrap_or(&pub_name)
393 .to_string();
394
395 let private_path = ssh_dir.join(&name);
397
398 let display_path = match home {
400 Some(home) if ssh_dir.starts_with(home) => {
401 let relative = ssh_dir.strip_prefix(home).unwrap();
402 format!("~/{}/{}", relative.display(), name)
403 }
404 _ => private_path.display().to_string(),
405 };
406
407 let linked_hosts = find_linked_hosts(&private_path, &display_path, hosts);
409
410 let bishop_art = parse_bishop_block(&stdout);
412
413 let is_certificate = detect_certificate(&pub_name, &key_type);
414
415 let encrypted = if is_certificate {
418 false
419 } else {
420 private_key_encrypted(&private_path)
421 };
422
423 let agent_loaded = agent_fingerprints.contains(&fingerprint);
425
426 let strength_score = strength_score_for(&key_type, &bits, encrypted);
427
428 let mtime_ts = file_mtime_ts(&private_path, pub_path);
429
430 Some(SshKeyInfo {
431 name,
432 display_path,
433 key_type,
434 bits,
435 fingerprint,
436 comment,
437 linked_hosts,
438 bishop_art,
439 strength_score,
440 encrypted,
441 agent_loaded,
442 is_certificate,
443 mtime_ts,
444 })
445}
446
447fn file_mtime_ts(private_path: &Path, pub_path: &Path) -> Option<u64> {
453 let from = |p: &Path| {
454 std::fs::metadata(p)
455 .ok()
456 .and_then(|m| m.modified().ok())
457 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
458 .map(|d| d.as_secs())
459 };
460 from(private_path).or_else(|| from(pub_path))
461}
462
463fn detect_certificate(pub_name: &str, key_type: &str) -> bool {
471 pub_name.ends_with("-cert.pub") || key_type.to_ascii_lowercase().contains("-cert")
472}
473
474fn parse_keygen_output(line: &str) -> Option<(String, String, String, String)> {
476 let parts: Vec<&str> = line.splitn(3, ' ').collect();
477 if parts.len() < 3 {
478 return None;
479 }
480
481 let bits = parts[0].to_string();
482 let fingerprint = parts[1].to_string();
483
484 let rest = parts[2];
486 let (comment, key_type) = if let Some(paren_start) = rest.rfind('(') {
487 let comment = rest[..paren_start].trim().to_string();
488 let key_type = rest[paren_start + 1..].trim_end_matches(')').to_string();
489 (comment, key_type)
490 } else {
491 (rest.to_string(), String::new())
492 };
493
494 Some((bits, fingerprint, comment, key_type))
495}
496
497fn find_linked_hosts(full_path: &Path, display_path: &str, hosts: &[HostEntry]) -> Vec<String> {
500 hosts
505 .iter()
506 .filter(|h| {
507 if h.identity_file.is_empty() {
508 return false;
509 }
510 h.identity_file == display_path || Path::new(&h.identity_file) == full_path
511 })
512 .map(|h| h.alias.clone())
513 .collect()
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn test_parse_keygen_output_ed25519() {
522 let line = "256 SHA256:abcdef1234567890 user@host (ED25519)";
523 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
524 assert_eq!(bits, "256");
525 assert_eq!(fp, "SHA256:abcdef1234567890");
526 assert_eq!(comment, "user@host");
527 assert_eq!(key_type, "ED25519");
528 }
529
530 #[test]
531 fn test_parse_keygen_output_rsa() {
532 let line = "4096 SHA256:xyz9876543210 deploy@prod.example.com (RSA)";
533 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
534 assert_eq!(bits, "4096");
535 assert_eq!(fp, "SHA256:xyz9876543210");
536 assert_eq!(comment, "deploy@prod.example.com");
537 assert_eq!(key_type, "RSA");
538 }
539
540 #[test]
541 fn test_parse_keygen_output_no_comment() {
542 let line = "256 SHA256:fingerprint (ED25519)";
543 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
544 assert_eq!(bits, "256");
545 assert_eq!(fp, "SHA256:fingerprint");
546 assert_eq!(comment, "");
547 assert_eq!(key_type, "ED25519");
548 }
549
550 #[test]
551 fn test_parse_keygen_output_comment_with_spaces() {
552 let line = "256 SHA256:fingerprint eko@MacBook Pro (ED25519)";
553 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
554 assert_eq!(bits, "256");
555 assert_eq!(fp, "SHA256:fingerprint");
556 assert_eq!(comment, "eko@MacBook Pro");
557 assert_eq!(key_type, "ED25519");
558 }
559
560 #[test]
561 fn test_parse_keygen_output_no_type_parens() {
562 let line = "256 SHA256:fingerprint user@host";
563 let (bits, fp, comment, key_type) = parse_keygen_output(line).unwrap();
564 assert_eq!(bits, "256");
565 assert_eq!(fp, "SHA256:fingerprint");
566 assert_eq!(comment, "user@host");
567 assert_eq!(key_type, "");
568 }
569
570 #[test]
571 fn test_parse_keygen_output_too_short() {
572 assert!(parse_keygen_output("256 SHA256:fp").is_none());
573 assert!(parse_keygen_output("").is_none());
574 }
575
576 #[test]
577 fn test_find_linked_hosts_display_path() {
578 let hosts = vec![
579 HostEntry {
580 alias: "prod".to_string(),
581 identity_file: "~/.ssh/id_ed25519".to_string(),
582 ..Default::default()
583 },
584 HostEntry {
585 alias: "staging".to_string(),
586 identity_file: "~/.ssh/other_key".to_string(),
587 ..Default::default()
588 },
589 ];
590 let linked = find_linked_hosts(
591 Path::new("/home/user/.ssh/id_ed25519"),
592 "~/.ssh/id_ed25519",
593 &hosts,
594 );
595 assert_eq!(linked, vec!["prod"]);
596 }
597
598 #[test]
599 fn test_find_linked_hosts_full_path() {
600 let hosts = vec![HostEntry {
601 alias: "server".to_string(),
602 identity_file: "/home/user/.ssh/deploy_key".to_string(),
603 ..Default::default()
604 }];
605 let linked = find_linked_hosts(
606 Path::new("/home/user/.ssh/deploy_key"),
607 "~/.ssh/deploy_key",
608 &hosts,
609 );
610 assert_eq!(linked, vec!["server"]);
611 }
612
613 #[test]
614 fn test_find_linked_hosts_no_identity_file_does_not_link() {
615 let hosts = vec![HostEntry {
619 alias: "server".to_string(),
620 identity_file: String::new(),
621 ..Default::default()
622 }];
623 let linked =
624 find_linked_hosts(Path::new("/home/user/.ssh/id_rsa"), "~/.ssh/id_rsa", &hosts);
625 assert!(linked.is_empty());
626 }
627
628 #[test]
629 fn test_find_linked_hosts_wrong_identity_file() {
630 let hosts = vec![HostEntry {
631 alias: "server".to_string(),
632 identity_file: "~/.ssh/other_key".to_string(),
633 ..Default::default()
634 }];
635 let linked =
636 find_linked_hosts(Path::new("/home/user/.ssh/id_rsa"), "~/.ssh/id_rsa", &hosts);
637 assert!(linked.is_empty());
638 }
639
640 fn sample_key() -> SshKeyInfo {
641 SshKeyInfo {
642 name: "id_ed25519".to_string(),
643 display_path: "~/.ssh/id_ed25519".to_string(),
644 key_type: "ED25519".to_string(),
645 bits: "256".to_string(),
646 fingerprint: "SHA256:8x2k7HhPqQfvN5jJrUvWxTsXmnQ4LpBkEoYzNcAdGhI".to_string(),
647 comment: "eric@MacBook".to_string(),
648 linked_hosts: Vec::new(),
649 bishop_art: String::new(),
650 strength_score: 95,
651 encrypted: true,
652 agent_loaded: true,
653 is_certificate: false,
654 mtime_ts: None,
655 }
656 }
657
658 #[test]
659 fn test_type_display() {
660 let key = sample_key();
661 assert_eq!(key.type_display(), "ED25519 256");
662
663 let key2 = SshKeyInfo {
664 bits: String::new(),
665 ..key
666 };
667 assert_eq!(key2.type_display(), "ED25519");
668 }
669
670 #[test]
671 fn detect_certificate_via_filename_suffix() {
672 assert!(detect_certificate("id_ed25519-cert.pub", "ED25519"));
673 }
674
675 #[test]
676 fn detect_certificate_via_key_type_full_oid() {
677 assert!(detect_certificate(
680 "id_ed25519-vault.pub",
681 "ED25519-CERT-V01@openssh.com"
682 ));
683 }
684
685 #[test]
686 fn detect_certificate_via_key_type_short() {
687 assert!(detect_certificate(
688 "id_ed25519-breakglass.pub",
689 "ED25519-CERT"
690 ));
691 }
692
693 #[test]
694 fn detect_certificate_rejects_plain_key() {
695 assert!(!detect_certificate("id_ed25519.pub", "ED25519"));
696 }
697
698 #[test]
699 fn detect_certificate_rejects_unrelated_dash_cert_in_name() {
700 assert!(!detect_certificate("my-cert-backup.pub", "RSA"));
703 }
704
705 #[test]
706 fn drunken_bishop_matches_openssh_canonical_17x9() {
707 let fp = decode_fingerprint("SHA256:1LayGj+CVIvJfOnQqADAT52DoJHhSa30feF/23wbRuE")
711 .expect("decode fingerprint");
712 let grid = drunken_bishop_grid(&fp, 17, 9);
713 let rendered: Vec<String> = grid
714 .iter()
715 .map(|row| row.iter().map(|&c| bishop_char(c)).collect())
716 .collect();
717 assert_eq!(
718 rendered,
719 vec![
720 "+=o o . ",
721 "*+.+ + . . ",
722 "+o= . o o o . ",
723 ".. o ..+ . . . .",
724 ". o *.oS . E ",
725 ". O = + . . ",
726 " . o =. . . + o ",
727 " . . o+. . o...",
728 " .... ...",
729 ]
730 );
731 }
732
733 #[test]
734 fn drunken_bishop_scales_to_larger_grid() {
735 let fp = decode_fingerprint("SHA256:1LayGj+CVIvJfOnQqADAT52DoJHhSa30feF/23wbRuE")
740 .expect("decode fingerprint");
741 let grid = drunken_bishop_grid(&fp, 25, 13);
742 assert_eq!(grid.len(), 13);
743 assert!(grid.iter().all(|row| row.len() == 25));
744 assert_eq!(grid[6][12], BISHOP_S_INDEX);
745 }
746
747 #[test]
748 fn decode_fingerprint_rejects_other_hash_prefixes() {
749 assert!(decode_fingerprint("MD5:abcd").is_none());
750 assert!(decode_fingerprint("plain-text").is_none());
751 }
752
753 #[test]
754 fn test_bishop_lines_empty() {
755 let key = SshKeyInfo {
756 bishop_art: String::new(),
757 ..sample_key()
758 };
759 assert!(key.bishop_lines().is_empty());
760 }
761
762 #[test]
763 fn test_bishop_lines_split() {
764 let key = SshKeyInfo {
765 bishop_art: "+--[ED25519 256]--+\n| .o*+ |\n+----[SHA256]-----+".to_string(),
766 ..sample_key()
767 };
768 assert_eq!(key.bishop_lines().len(), 3);
769 assert_eq!(key.bishop_lines()[1], "| .o*+ |");
770 }
771
772 #[test]
773 fn test_parse_agent_list_two_keys() {
774 let stdout = "256 SHA256:abc1 eric@host (ED25519)\n4096 SHA256:def2 work@laptop (RSA)\n";
775 let set = parse_agent_list(stdout);
776 assert_eq!(set.len(), 2);
777 assert!(set.contains("SHA256:abc1"));
778 assert!(set.contains("SHA256:def2"));
779 }
780
781 #[test]
782 fn test_parse_agent_list_empty_agent() {
783 let stdout = "The agent has no identities.\n";
784 let set = parse_agent_list(stdout);
785 assert!(set.is_empty());
786 }
787
788 #[test]
789 fn test_parse_agent_list_banner_skipped() {
790 let stdout = "Could not open a connection to your authentication agent.\n";
791 let set = parse_agent_list(stdout);
792 assert!(set.is_empty());
793 }
794
795 #[test]
796 fn test_strength_score_ed25519() {
797 assert_eq!(strength_score_for("ED25519", "256", true), 95);
798 assert_eq!(strength_score_for("ED25519", "256", false), 80);
799 }
800
801 #[test]
802 fn test_strength_score_sk_ed25519() {
803 assert_eq!(strength_score_for("sk-ED25519", "256", true), 100);
804 assert_eq!(strength_score_for("sk-ED25519", "256", false), 85);
805 }
806
807 #[test]
808 fn test_strength_score_rsa_buckets() {
809 assert_eq!(strength_score_for("RSA", "1024", true), 20);
810 assert_eq!(strength_score_for("RSA", "2048", true), 60);
811 assert_eq!(strength_score_for("RSA", "3072", true), 80);
812 assert_eq!(strength_score_for("RSA", "4096", true), 85);
813 assert_eq!(strength_score_for("RSA", "8192", true), 85);
814 }
815
816 #[test]
817 fn test_strength_score_dsa_is_low() {
818 assert_eq!(strength_score_for("DSA", "1024", true), 10);
819 assert_eq!(strength_score_for("DSA", "1024", false), 0);
820 }
821
822 #[test]
823 fn test_strength_score_ecdsa_buckets() {
824 assert_eq!(strength_score_for("ECDSA", "256", true), 75);
825 assert_eq!(strength_score_for("ECDSA", "384", true), 85);
826 assert_eq!(strength_score_for("ECDSA", "521", true), 90);
827 }
828
829 #[test]
830 fn test_strength_score_unknown_type() {
831 assert_eq!(strength_score_for("WEIRD", "256", true), 55);
832 assert_eq!(strength_score_for("", "0", false), 40);
833 }
834
835 #[test]
836 fn test_parse_bishop_block_typical_output() {
837 let stdout = "\
838256 SHA256:abc eric@host (ED25519)
839+--[ED25519 256]--+
840| |
841| |
842| . . . ... |
843| o o..ooo.o|
844| . S =.oo+==|
845| . o B +E*B|
846| . . O =.=.+|
847| .. = B o.oo|
848| .oo.+.=o.. |
849+----[SHA256]-----+
850";
851 let art = parse_bishop_block(stdout);
852 assert_eq!(art.lines().count(), 11);
853 assert!(art.starts_with("+--[ED25519 256]--+"));
854 assert!(art.ends_with("+----[SHA256]-----+"));
855 }
856
857 #[test]
858 fn test_parse_bishop_block_missing_returns_empty() {
859 let stdout = "256 SHA256:abc eric@host (ED25519)\n";
860 assert!(parse_bishop_block(stdout).is_empty());
861 }
862
863 #[test]
864 fn test_parse_bishop_block_truncated_returns_empty() {
865 let stdout = "+--[ED25519 256]--+\n| |\n+--+\n";
866 assert!(parse_bishop_block(stdout).is_empty());
867 }
868
869 fn search_corpus() -> Vec<SshKeyInfo> {
870 vec![
871 SshKeyInfo {
872 name: "id_ed25519".into(),
873 comment: "eric@mac".into(),
874 ..sample_key()
875 },
876 SshKeyInfo {
877 name: "yubikey_work".into(),
878 comment: "yubi@work".into(),
879 ..sample_key()
880 },
881 SshKeyInfo {
882 name: "customer-x".into(),
883 comment: "eric@customer".into(),
884 ..sample_key()
885 },
886 ]
887 }
888
889 #[test]
890 fn filtered_key_indices_none_returns_all() {
891 let keys = search_corpus();
892 let idx = filtered_key_indices(&keys, None);
893 assert_eq!(idx, vec![0, 1, 2]);
894 }
895
896 #[test]
897 fn filtered_key_indices_empty_returns_all() {
898 let keys = search_corpus();
899 let idx = filtered_key_indices(&keys, Some(""));
900 assert_eq!(idx, vec![0, 1, 2]);
901 }
902
903 #[test]
904 fn filtered_key_indices_matches_name() {
905 let keys = search_corpus();
906 let idx = filtered_key_indices(&keys, Some("yubi"));
907 assert_eq!(idx, vec![1]);
908 }
909
910 #[test]
911 fn filtered_key_indices_matches_comment() {
912 let keys = search_corpus();
913 let idx = filtered_key_indices(&keys, Some("eric"));
914 assert_eq!(idx, vec![0, 2]);
915 }
916
917 #[test]
918 fn filtered_key_indices_case_insensitive() {
919 let keys = search_corpus();
920 let idx = filtered_key_indices(&keys, Some("ERIC"));
921 assert_eq!(idx, vec![0, 2]);
922 }
923
924 #[test]
925 fn filtered_key_indices_no_match() {
926 let keys = search_corpus();
927 let idx = filtered_key_indices(&keys, Some("nonexistent"));
928 assert!(idx.is_empty());
929 }
930
931 #[test]
932 fn resolve_selection_unfiltered_is_identity() {
933 let keys = search_corpus();
934 assert_eq!(resolve_selection(&keys, None, 0), Some(0));
935 assert_eq!(resolve_selection(&keys, None, 2), Some(2));
936 assert_eq!(resolve_selection(&keys, None, 99), None);
937 }
938
939 #[test]
940 fn resolve_selection_filtered_maps_back_to_underlying() {
941 let keys = search_corpus();
942 assert_eq!(resolve_selection(&keys, Some("eric"), 0), Some(0));
944 assert_eq!(resolve_selection(&keys, Some("eric"), 1), Some(2));
945 assert_eq!(resolve_selection(&keys, Some("eric"), 2), None);
946 }
947
948 #[test]
949 fn resolve_selection_no_match_returns_none() {
950 let keys = search_corpus();
951 assert_eq!(resolve_selection(&keys, Some("xyzzy"), 0), None);
952 }
953
954 #[cfg(unix)]
955 fn read_only_entry(dir: &Path, name: &str) -> std::fs::DirEntry {
956 std::fs::read_dir(dir)
957 .expect("read_dir")
958 .filter_map(Result::ok)
959 .find(|e| e.file_name() == name)
960 .expect("entry not found")
961 }
962
963 #[cfg(unix)]
964 #[test]
965 fn test_is_public_key_file_accepts_regular_pub_file() {
966 let dir = tempfile::tempdir().unwrap();
967 let path = dir.path().join("id_ed25519.pub");
968 std::fs::write(&path, b"ssh-ed25519 AAAA").unwrap();
969 let entry = read_only_entry(dir.path(), "id_ed25519.pub");
970 assert!(is_public_key_file(&entry));
971 }
972
973 #[cfg(unix)]
974 #[test]
975 fn test_is_public_key_file_accepts_symlink_to_regular_pub_file() {
976 use std::os::unix::fs::symlink;
977 let target_dir = tempfile::tempdir().unwrap();
978 let link_dir = tempfile::tempdir().unwrap();
979 let target = target_dir.path().join("id_ed25519.pub");
980 std::fs::write(&target, b"ssh-ed25519 AAAA").unwrap();
981 let link = link_dir.path().join("id_ed25519.pub");
982 symlink(&target, &link).unwrap();
983 let entry = read_only_entry(link_dir.path(), "id_ed25519.pub");
984 assert!(is_public_key_file(&entry));
985 }
986
987 #[cfg(unix)]
988 #[test]
989 fn test_is_public_key_file_rejects_broken_symlink() {
990 use std::os::unix::fs::symlink;
991 let dir = tempfile::tempdir().unwrap();
992 let link = dir.path().join("id_ed25519.pub");
993 symlink(dir.path().join("does_not_exist.pub"), &link).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_rejects_symlink_to_directory() {
1001 use std::os::unix::fs::symlink;
1002 let dir = tempfile::tempdir().unwrap();
1003 let real_dir = dir.path().join("realdir");
1004 std::fs::create_dir(&real_dir).unwrap();
1005 let link = dir.path().join("id_ed25519.pub");
1006 symlink(&real_dir, &link).unwrap();
1007 let entry = read_only_entry(dir.path(), "id_ed25519.pub");
1008 assert!(!is_public_key_file(&entry));
1009 }
1010
1011 #[test]
1014 fn file_created_ts_returns_private_key_mtime_when_present() {
1015 let dir = tempfile::tempdir().unwrap();
1016 let priv_path = dir.path().join("id_ed25519");
1017 let pub_path = dir.path().join("id_ed25519.pub");
1018 std::fs::write(&priv_path, b"PRIVATE").unwrap();
1019 std::fs::write(&pub_path, b"ssh-ed25519 AAAA").unwrap();
1020 let ts = file_mtime_ts(&priv_path, &pub_path).expect("private mtime");
1021 assert!(ts > 0);
1022 }
1023
1024 #[test]
1025 fn file_created_ts_falls_back_to_pubkey_when_private_missing() {
1026 let dir = tempfile::tempdir().unwrap();
1027 let priv_path = dir.path().join("does_not_exist");
1028 let pub_path = dir.path().join("id_ed25519.pub");
1029 std::fs::write(&pub_path, b"ssh-ed25519 AAAA").unwrap();
1030 let ts = file_mtime_ts(&priv_path, &pub_path).expect("pubkey mtime");
1031 assert!(ts > 0);
1032 }
1033
1034 #[test]
1035 fn file_created_ts_returns_none_when_both_missing() {
1036 let dir = tempfile::tempdir().unwrap();
1037 let priv_path = dir.path().join("nope_priv");
1038 let pub_path = dir.path().join("nope_pub.pub");
1039 assert!(file_mtime_ts(&priv_path, &pub_path).is_none());
1040 }
1041}