1use std::io::BufRead;
2use std::path::Path;
3
4use log::{debug, info};
5
6use crate::quick_add;
7use crate::ssh_config::model::{HostEntry, SshConfigFile};
8
9pub fn import_from_file(
12 config: &mut SshConfigFile,
13 path: &Path,
14 group: Option<&str>,
15) -> Result<(usize, usize, usize, usize), String> {
16 info!("Import started: source={}", path.display());
17 let file = std::fs::File::open(path)
18 .map_err(|e| crate::messages::import_open_failed(&path.display(), &e))?;
19 let reader = std::io::BufReader::new(file);
20
21 let mut read_errors = 0;
22 let mut parse_failures = 0;
23 let lines: Vec<String> = reader
24 .lines()
25 .filter_map(|r| match r {
26 Ok(line) => Some(line),
27 Err(_) => {
28 read_errors += 1;
29 None
30 }
31 })
32 .filter(|line| {
33 let trimmed = line.trim();
34 !trimmed.is_empty() && !trimmed.starts_with('#')
35 })
36 .collect();
37
38 let mut entries = Vec::new();
39 for line in &lines {
40 let trimmed = line.trim();
41 match quick_add::parse_target(trimmed) {
42 Ok(parsed) => {
43 let alias = parsed
44 .hostname
45 .split('.')
46 .next()
47 .unwrap_or(&parsed.hostname)
48 .to_string();
49 if crate::ssh_config::model::is_host_pattern(&alias) {
51 parse_failures += 1;
52 continue;
53 }
54 entries.push(HostEntry {
55 alias,
56 hostname: parsed.hostname,
57 user: parsed.user,
58 port: parsed.port,
59 ..Default::default()
60 });
61 }
62 Err(_) => {
63 debug!("[config] Import: skipped unparseable line: {trimmed}");
64 parse_failures += 1;
65 }
66 }
67 }
68
69 let (imported, skipped) = add_entries(config, &entries, group)?;
70 info!("Import completed: {imported} hosts added, {skipped} skipped");
71 Ok((imported, skipped, parse_failures, read_errors))
72}
73
74pub fn count_known_hosts_candidates() -> usize {
78 let home = match dirs::home_dir() {
79 Some(h) => h,
80 None => return 0,
81 };
82 let known_hosts_path = home.join(".ssh").join("known_hosts");
83 let file = match std::fs::File::open(&known_hosts_path) {
84 Ok(f) => f,
85 Err(_) => return 0,
86 };
87 let reader = std::io::BufReader::new(file);
88 reader
89 .lines()
90 .map_while(Result::ok)
91 .filter(|line| {
92 let trimmed = line.trim();
93 !trimmed.is_empty() && !trimmed.starts_with('#')
94 })
95 .filter(|line| matches!(parse_known_hosts_line(line), KnownHostResult::Parsed(_)))
96 .count()
97}
98
99pub fn import_from_known_hosts(
102 config: &mut SshConfigFile,
103 group: Option<&str>,
104) -> Result<(usize, usize, usize, usize), String> {
105 info!("Import started: source=~/.ssh/known_hosts");
106 let home = dirs::home_dir().ok_or(crate::messages::IMPORT_HOME_DIR_UNKNOWN)?;
107 let known_hosts_path = home.join(".ssh").join("known_hosts");
108
109 if !known_hosts_path.exists() {
110 return Err(crate::messages::IMPORT_KNOWN_HOSTS_MISSING.to_string());
111 }
112
113 let file = std::fs::File::open(&known_hosts_path)
114 .map_err(|e| crate::messages::import_known_hosts_open_failed(&e))?;
115 let reader = std::io::BufReader::new(file);
116
117 let mut read_errors = 0;
118 let mut parse_failures = 0;
119 let lines: Vec<String> = reader
120 .lines()
121 .filter_map(|r| match r {
122 Ok(line) => Some(line),
123 Err(_) => {
124 read_errors += 1;
125 None
126 }
127 })
128 .filter(|line| {
129 let trimmed = line.trim();
130 !trimmed.is_empty() && !trimmed.starts_with('#')
131 })
132 .collect();
133
134 let mut entries = Vec::new();
135 for line in &lines {
136 match parse_known_hosts_line(line) {
137 KnownHostResult::Parsed(entry) => entries.push(entry),
138 KnownHostResult::Skipped => {} KnownHostResult::Failed => parse_failures += 1,
140 }
141 }
142
143 let (imported, skipped) = add_entries(config, &entries, group)?;
144 info!("Import completed: {imported} hosts added, {skipped} skipped");
145 Ok((imported, skipped, parse_failures, read_errors))
146}
147
148fn is_bare_ip(host: &str) -> bool {
150 if !host.is_empty() && host.chars().all(|c| c.is_ascii_digit() || c == '.') {
152 return true;
153 }
154 let ipv6_part = host.split('%').next().unwrap_or(host);
156 ipv6_part.contains(':') && ipv6_part.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
157}
158
159#[allow(clippy::large_enum_variant)]
161enum KnownHostResult {
162 Parsed(HostEntry),
164 Skipped,
166 Failed,
168}
169
170fn parse_known_hosts_line(line: &str) -> KnownHostResult {
172 let parts: Vec<&str> = line.split_whitespace().collect();
173 if parts.len() < 3 {
174 return KnownHostResult::Failed;
175 }
176
177 if parts[0].starts_with('@') {
179 return KnownHostResult::Skipped;
180 }
181 let host_part = parts[0];
182
183 if host_part.starts_with('|') {
185 return KnownHostResult::Skipped;
186 }
187
188 let host = host_part
191 .split(',')
192 .find(|entry| {
193 let bare = if entry.starts_with('[') {
194 entry
195 .get(1..entry.find(']').unwrap_or(entry.len()))
196 .unwrap_or(entry)
197 } else {
198 entry
199 };
200 !is_bare_ip(bare)
201 })
202 .unwrap_or_else(|| host_part.split(',').next().unwrap_or(host_part));
203
204 let (hostname, port) = if host.starts_with('[') {
206 let Some(end) = host.find(']') else {
207 return KnownHostResult::Failed;
208 };
209 let h = &host[1..end];
210 let rest = &host[end + 1..];
211 let p = if rest.is_empty() {
212 22
213 } else if let Some(port_str) = rest.strip_prefix(':') {
214 if port_str.is_empty() {
215 return KnownHostResult::Failed; }
217 match port_str.parse::<u16>() {
218 Ok(port) if port > 0 => port,
219 _ => return KnownHostResult::Failed,
220 }
221 } else {
222 return KnownHostResult::Failed; };
224 (h.to_string(), p)
225 } else {
226 (host.to_string(), 22)
227 };
228
229 if hostname.is_empty() {
231 return KnownHostResult::Failed;
232 }
233
234 if is_bare_ip(&hostname) {
236 return KnownHostResult::Skipped;
237 }
238
239 let alias = hostname.split('.').next().unwrap_or(&hostname).to_string();
240
241 if crate::ssh_config::model::is_host_pattern(&alias) {
243 return KnownHostResult::Skipped;
244 }
245
246 KnownHostResult::Parsed(HostEntry {
247 alias,
248 hostname,
249 port,
250 ..Default::default()
251 })
252}
253
254fn add_entries(
256 config: &mut SshConfigFile,
257 entries: &[HostEntry],
258 group: Option<&str>,
259) -> Result<(usize, usize), String> {
260 let mut imported = 0;
261 let mut skipped = 0;
262 let mut header_written = false;
263
264 for entry in entries {
265 if config.has_host(&entry.alias) {
266 skipped += 1;
267 continue;
268 }
269
270 if let Some(group_name) = group.filter(|_| !header_written) {
272 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
273 config
274 .elements
275 .push(crate::ssh_config::model::ConfigElement::GlobalLine(
276 String::new(),
277 ));
278 }
279 config
280 .elements
281 .push(crate::ssh_config::model::ConfigElement::GlobalLine(
282 format!("# {}", group_name),
283 ));
284 header_written = true;
285 }
286
287 if group.is_some() && imported == 0 {
288 let block = SshConfigFile::entry_to_block(entry);
290 config
291 .elements
292 .push(crate::ssh_config::model::ConfigElement::HostBlock(block));
293 } else {
294 config.add_host(entry);
295 }
296 imported += 1;
297 }
298
299 Ok((imported, skipped))
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn test_parse_known_hosts_simple() {
308 let KnownHostResult::Parsed(entry) = parse_known_hosts_line("example.com ssh-rsa AAAA...")
309 else {
310 panic!("expected Parsed");
311 };
312 assert_eq!(entry.hostname, "example.com");
313 assert_eq!(entry.alias, "example");
314 assert_eq!(entry.port, 22);
315 }
316
317 #[test]
318 fn test_parse_known_hosts_with_port() {
319 let KnownHostResult::Parsed(entry) =
320 parse_known_hosts_line("[myhost.com]:2222 ssh-ed25519 AAAA...")
321 else {
322 panic!("expected Parsed");
323 };
324 assert_eq!(entry.hostname, "myhost.com");
325 assert_eq!(entry.alias, "myhost");
326 assert_eq!(entry.port, 2222);
327 }
328
329 #[test]
330 fn test_parse_known_hosts_hashed() {
331 assert!(matches!(
332 parse_known_hosts_line("|1|abc=|def= ssh-rsa AAAA..."),
333 KnownHostResult::Skipped
334 ));
335 }
336
337 #[test]
338 fn test_parse_known_hosts_ip_only() {
339 assert!(matches!(
340 parse_known_hosts_line("192.168.1.1 ssh-rsa AAAA..."),
341 KnownHostResult::Skipped
342 ));
343 }
344
345 #[test]
346 fn test_parse_known_hosts_ipv6_skipped() {
347 assert!(matches!(
349 parse_known_hosts_line("2001:db8::1 ssh-rsa AAAA..."),
350 KnownHostResult::Skipped
351 ));
352 assert!(matches!(
353 parse_known_hosts_line("fe80::1 ssh-ed25519 AAAA..."),
354 KnownHostResult::Skipped
355 ));
356 }
357
358 #[test]
359 fn test_parse_known_hosts_hex_hostname_not_skipped() {
360 let KnownHostResult::Parsed(entry) = parse_known_hosts_line("deadbeef ssh-rsa AAAA...")
362 else {
363 panic!("expected Parsed");
364 };
365 assert_eq!(entry.alias, "deadbeef");
366
367 let KnownHostResult::Parsed(entry) =
368 parse_known_hosts_line("cafe.example.com ssh-rsa AAAA...")
369 else {
370 panic!("expected Parsed");
371 };
372 assert_eq!(entry.alias, "cafe");
373 }
374
375 #[test]
376 fn test_parse_known_hosts_invalid_port() {
377 assert!(matches!(
379 parse_known_hosts_line("[myhost]:abc ssh-rsa AAAA..."),
380 KnownHostResult::Failed
381 ));
382 assert!(matches!(
384 parse_known_hosts_line("[myhost]:70000 ssh-rsa AAAA..."),
385 KnownHostResult::Failed
386 ));
387 assert!(matches!(
389 parse_known_hosts_line("[myhost]:0 ssh-rsa AAAA..."),
390 KnownHostResult::Failed
391 ));
392 }
393
394 #[test]
395 fn test_parse_known_hosts_comma_separated() {
396 let KnownHostResult::Parsed(entry) =
397 parse_known_hosts_line("myserver.com,192.168.1.1 ssh-ed25519 AAAA...")
398 else {
399 panic!("expected Parsed");
400 };
401 assert_eq!(entry.hostname, "myserver.com");
402 assert_eq!(entry.alias, "myserver");
403 }
404
405 #[test]
406 fn test_parse_known_hosts_malformed_is_failure() {
407 assert!(matches!(
409 parse_known_hosts_line("onlyhost ssh-rsa"),
410 KnownHostResult::Failed
411 ));
412 assert!(matches!(
414 parse_known_hosts_line("[broken ssh-rsa AAAA..."),
415 KnownHostResult::Failed
416 ));
417 }
418
419 #[test]
420 fn test_parse_known_hosts_marker_is_skipped() {
421 assert!(matches!(
422 parse_known_hosts_line("@cert-authority *.example.com ssh-rsa AAAA..."),
423 KnownHostResult::Skipped
424 ));
425 assert!(matches!(
426 parse_known_hosts_line("@revoked host.com ssh-rsa AAAA..."),
427 KnownHostResult::Skipped
428 ));
429 }
430
431 #[test]
432 fn test_parse_known_hosts_numeric_first_label_not_skipped() {
433 let KnownHostResult::Parsed(entry) =
435 parse_known_hosts_line("123.example.com ssh-rsa AAAA...")
436 else {
437 panic!("expected Parsed");
438 };
439 assert_eq!(entry.hostname, "123.example.com");
440 assert_eq!(entry.alias, "123");
441 }
442
443 #[test]
444 fn test_parse_known_hosts_bracket_trailing_colon_fails() {
445 assert!(matches!(
447 parse_known_hosts_line("[myhost]: ssh-rsa AAAA..."),
448 KnownHostResult::Failed
449 ));
450 }
451
452 #[test]
453 fn test_parse_known_hosts_bracket_junk_after_close_fails() {
454 assert!(matches!(
456 parse_known_hosts_line("[myhost]junk ssh-rsa AAAA..."),
457 KnownHostResult::Failed
458 ));
459 }
460
461 #[test]
462 fn test_parse_known_hosts_bracket_no_port() {
463 let KnownHostResult::Parsed(entry) = parse_known_hosts_line("[myhost.com] ssh-rsa AAAA...")
465 else {
466 panic!("expected Parsed");
467 };
468 assert_eq!(entry.hostname, "myhost.com");
469 assert_eq!(entry.port, 22);
470 }
471
472 #[test]
473 fn test_parse_known_hosts_wildcard_is_skipped() {
474 assert!(matches!(
475 parse_known_hosts_line("*.example.com ssh-rsa AAAA..."),
476 KnownHostResult::Skipped
477 ));
478 }
479
480 #[test]
481 fn test_parse_known_hosts_bracket_pattern_skipped() {
482 assert!(matches!(
484 parse_known_hosts_line("web[12].example.com ssh-rsa AAAA..."),
485 KnownHostResult::Skipped
486 ));
487 }
488
489 #[test]
490 fn test_parse_known_hosts_negation_pattern_skipped() {
491 assert!(matches!(
492 parse_known_hosts_line("!prod.example.com ssh-rsa AAAA..."),
493 KnownHostResult::Skipped
494 ));
495 }
496
497 #[test]
498 fn test_parse_known_hosts_ip_first_comma_picks_hostname() {
499 let KnownHostResult::Parsed(entry) =
501 parse_known_hosts_line("192.0.2.10,web.example.com ssh-rsa AAAA...")
502 else {
503 panic!("expected Parsed");
504 };
505 assert_eq!(entry.hostname, "web.example.com");
506 assert_eq!(entry.alias, "web");
507 }
508
509 #[test]
510 fn test_parse_known_hosts_ipv6_first_comma_picks_hostname() {
511 let KnownHostResult::Parsed(entry) =
512 parse_known_hosts_line("2001:db8::1,server.example.com ssh-rsa AAAA...")
513 else {
514 panic!("expected Parsed");
515 };
516 assert_eq!(entry.hostname, "server.example.com");
517 assert_eq!(entry.alias, "server");
518 }
519
520 #[test]
521 fn test_parse_known_hosts_all_ips_comma_skipped() {
522 assert!(matches!(
524 parse_known_hosts_line("192.0.2.10,10.0.0.1 ssh-rsa AAAA..."),
525 KnownHostResult::Skipped
526 ));
527 }
528
529 #[test]
530 fn test_parse_known_hosts_bracketed_ip_first_comma_picks_hostname() {
531 let KnownHostResult::Parsed(entry) =
533 parse_known_hosts_line("[192.0.2.10]:2222,web.example.com ssh-rsa AAAA...")
534 else {
535 panic!("expected Parsed");
536 };
537 assert_eq!(entry.hostname, "web.example.com");
538 assert_eq!(entry.alias, "web");
539 }
540
541 #[test]
546 fn test_parse_known_hosts_empty_string() {
547 assert!(matches!(
549 parse_known_hosts_line(""),
550 KnownHostResult::Failed
551 ));
552 }
553
554 #[test]
555 fn test_parse_known_hosts_single_field() {
556 assert!(matches!(
558 parse_known_hosts_line("example.com"),
559 KnownHostResult::Failed
560 ));
561 }
562
563 #[test]
564 fn test_parse_known_hosts_hostname_with_hyphen() {
565 let KnownHostResult::Parsed(entry) =
566 parse_known_hosts_line("my-server.example.com ssh-rsa AAAA...")
567 else {
568 panic!("expected Parsed");
569 };
570 assert_eq!(entry.hostname, "my-server.example.com");
571 assert_eq!(entry.alias, "my-server");
572 }
573
574 #[test]
575 fn test_parse_known_hosts_multiple_hostnames_comma() {
576 let KnownHostResult::Parsed(entry) =
578 parse_known_hosts_line("primary.example.com,secondary.example.com ssh-rsa AAAA...")
579 else {
580 panic!("expected Parsed");
581 };
582 assert_eq!(entry.hostname, "primary.example.com");
583 assert_eq!(entry.alias, "primary");
584 }
585
586 #[test]
587 fn test_parse_known_hosts_ipv6_zone_id_skipped() {
588 assert!(matches!(
590 parse_known_hosts_line("fe80::1%eth0 ssh-rsa AAAA..."),
591 KnownHostResult::Skipped
592 ));
593 }
594
595 #[test]
596 fn test_parse_known_hosts_question_mark_pattern_skipped() {
597 assert!(matches!(
599 parse_known_hosts_line("web?.example.com ssh-rsa AAAA..."),
600 KnownHostResult::Skipped
601 ));
602 }
603
604 #[test]
609 fn test_import_status_pluralization() {
610 let fmt = |imported: usize, skipped: usize| -> String {
612 format!(
613 "Imported {} host{}, skipped {} duplicate{}",
614 imported,
615 if imported == 1 { "" } else { "s" },
616 skipped,
617 if skipped == 1 { "" } else { "s" },
618 )
619 };
620 assert_eq!(fmt(1, 0), "Imported 1 host, skipped 0 duplicates");
621 assert_eq!(fmt(1, 1), "Imported 1 host, skipped 1 duplicate");
622 assert_eq!(fmt(5, 0), "Imported 5 hosts, skipped 0 duplicates");
623 assert_eq!(fmt(5, 3), "Imported 5 hosts, skipped 3 duplicates");
624 assert_eq!(fmt(0, 5), "Imported 0 hosts, skipped 5 duplicates");
625 }
626
627 #[test]
628 fn test_import_all_duplicates_message() {
629 let msg_single = if 1 == 1 {
630 "Host already exists".to_string()
631 } else {
632 format!("All {} hosts already exist", 1)
633 };
634 assert_eq!(msg_single, "Host already exists");
635
636 let msg_multi = if 5 == 1 {
637 "Host already exists".to_string()
638 } else {
639 format!("All {} hosts already exist", 5)
640 };
641 assert_eq!(msg_multi, "All 5 hosts already exist");
642 }
643
644 #[test]
649 fn test_import_from_known_hosts_adds_to_config() {
650 let dir = std::env::temp_dir().join(format!(
652 "purple_test_import_{:?}",
653 std::thread::current().id()
654 ));
655 let _ = std::fs::remove_dir_all(&dir);
656 std::fs::create_dir_all(&dir).unwrap();
657
658 let hosts_file = dir.join("hosts.txt");
659 std::fs::write(&hosts_file, "web.example.com\ndb.example.com\n").unwrap();
660
661 let mut config = SshConfigFile {
662 elements: Vec::new(),
663 path: dir.join("config"),
664 crlf: false,
665 bom: false,
666 };
667
668 let result = import_from_file(&mut config, &hosts_file, Some("test-import"));
669 assert!(result.is_ok());
670 let (imported, skipped, _, _) = result.unwrap();
671 assert_eq!(imported, 2);
672 assert_eq!(skipped, 0);
673
674 assert!(config.has_host("web"));
676 assert!(config.has_host("db"));
677
678 let _ = std::fs::remove_dir_all(&dir);
679 }
680
681 #[test]
682 fn test_import_skips_duplicates() {
683 let dir = std::env::temp_dir().join(format!(
684 "purple_test_import_dup_{:?}",
685 std::thread::current().id()
686 ));
687 let _ = std::fs::remove_dir_all(&dir);
688 std::fs::create_dir_all(&dir).unwrap();
689
690 let hosts_file = dir.join("hosts.txt");
691 std::fs::write(&hosts_file, "web.example.com\n").unwrap();
692
693 let mut config = SshConfigFile {
694 elements: Vec::new(),
695 path: dir.join("config"),
696 crlf: false,
697 bom: false,
698 };
699
700 let (imported, _, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
702 assert_eq!(imported, 1);
703
704 let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
706 assert_eq!(imported, 0);
707 assert_eq!(skipped, 1);
708
709 let _ = std::fs::remove_dir_all(&dir);
710 }
711
712 #[test]
713 fn test_import_from_file_nonexistent() {
714 let mut config = SshConfigFile {
715 elements: Vec::new(),
716 path: std::path::PathBuf::from("/dev/null"),
717 crlf: false,
718 bom: false,
719 };
720 let result = import_from_file(&mut config, Path::new("/nonexistent/file"), None);
721 assert!(result.is_err());
722 }
723
724 #[test]
725 fn test_import_empty_file() {
726 let dir = std::env::temp_dir().join(format!(
727 "purple_test_import_empty_{:?}",
728 std::thread::current().id()
729 ));
730 let _ = std::fs::remove_dir_all(&dir);
731 std::fs::create_dir_all(&dir).unwrap();
732
733 let hosts_file = dir.join("hosts.txt");
734 std::fs::write(&hosts_file, "").unwrap();
735
736 let mut config = SshConfigFile {
737 elements: Vec::new(),
738 path: dir.join("config"),
739 crlf: false,
740 bom: false,
741 };
742
743 let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
744 assert_eq!(imported, 0);
745 assert_eq!(skipped, 0);
746
747 let _ = std::fs::remove_dir_all(&dir);
748 }
749
750 #[test]
751 fn test_import_comments_and_blanks_only() {
752 let dir = std::env::temp_dir().join(format!(
753 "purple_test_import_comments_{:?}",
754 std::thread::current().id()
755 ));
756 let _ = std::fs::remove_dir_all(&dir);
757 std::fs::create_dir_all(&dir).unwrap();
758
759 let hosts_file = dir.join("hosts.txt");
760 std::fs::write(&hosts_file, "# comment\n\n# another\n").unwrap();
761
762 let mut config = SshConfigFile {
763 elements: Vec::new(),
764 path: dir.join("config"),
765 crlf: false,
766 bom: false,
767 };
768
769 let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
770 assert_eq!(imported, 0);
771 assert_eq!(skipped, 0);
772
773 let _ = std::fs::remove_dir_all(&dir);
774 }
775
776 #[test]
777 fn test_is_bare_ip() {
778 assert!(is_bare_ip("192.168.1.1"));
779 assert!(is_bare_ip("10.0.0.1"));
780 assert!(is_bare_ip("2001:db8::1"));
781 assert!(is_bare_ip("fe80::1"));
782 assert!(is_bare_ip("fe80::1%en0"));
783 assert!(is_bare_ip("fe80::1%eth0"));
784 assert!(!is_bare_ip("example.com"));
785 assert!(!is_bare_ip("123.example.com"));
786 assert!(!is_bare_ip("deadbeef"));
787 assert!(!is_bare_ip(""));
788 }
789}