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 static SSH_DIR_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
16 const { std::cell::RefCell::new(None) };
17}
18
19pub 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#[cfg(test)]
31pub fn set_ssh_dir_override(path: PathBuf) {
32 SSH_DIR_OVERRIDE.with(|p| *p.borrow_mut() = Some(path));
33}
34
35#[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#[derive(Debug, Clone)]
44pub struct SshKeyInfo {
45 pub name: String,
47 pub display_path: String,
49 pub key_type: String,
51 pub bits: String,
53 pub fingerprint: String,
55 pub comment: String,
57 pub linked_hosts: Vec<String>,
59 pub bishop_art: String,
63 pub strength_score: u8,
67 pub encrypted: bool,
71 pub agent_loaded: bool,
73 pub is_certificate: bool,
77 pub mtime_ts: Option<u64>,
83}
84
85impl SshKeyInfo {
86 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 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
106const 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
115pub 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
132pub 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
164pub fn bishop_char(counter: u8) -> char {
166 let idx = counter.min(BISHOP_E_INDEX) as usize;
167 BISHOP_CHARS[idx] as char
168}
169
170pub 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
181pub 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
203pub 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
242fn 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
267fn 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
285fn strength_score_for(key_type: &str, bits: &str, encrypted: bool) -> u8 {
290 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
320fn 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
341fn 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
362fn is_public_key_file(entry: &std::fs::DirEntry) -> bool {
364 let name = entry.file_name();
365 let name = name.to_string_lossy();
366
367 if !name.ends_with(".pub") {
369 return false;
370 }
371
372 let skip = ["authorized_keys.pub", "known_hosts.pub"];
374 if skip.contains(&name.as_ref()) {
375 return false;
376 }
377
378 std::fs::metadata(entry.path())
382 .map(|m| m.is_file())
383 .unwrap_or(false)
384}
385
386fn 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 let (bits, fingerprint, comment, key_type) = parse_keygen_output(first_line)?;
412
413 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 let private_path = ssh_dir.join(&name);
422
423 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 let linked_hosts = find_linked_hosts(&private_path, &display_path, hosts);
434
435 let bishop_art = parse_bishop_block(&stdout);
437
438 let is_certificate = detect_certificate(&pub_name, &key_type);
439
440 let encrypted = if is_certificate {
443 false
444 } else {
445 private_key_encrypted(&private_path)
446 };
447
448 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
472fn 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
488fn 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
499fn 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 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
522fn find_linked_hosts(full_path: &Path, display_path: &str, hosts: &[HostEntry]) -> Vec<String> {
525 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 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 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 assert!(!detect_certificate("my-cert-backup.pub", "RSA"));
728 }
729
730 #[test]
731 fn drunken_bishop_matches_openssh_canonical_17x9() {
732 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 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 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 #[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}