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!("[config] 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!("[config] Import completed: {imported} hosts added, {skipped} skipped");
71 Ok((imported, skipped, parse_failures, read_errors))
72}
73
74pub fn count_known_hosts_candidates(paths: Option<&crate::runtime::env::Paths>) -> usize {
78 let Some(paths) = paths else {
79 return 0;
80 };
81 let known_hosts_path = paths.ssh_dir().join("known_hosts");
82 let file = match std::fs::File::open(&known_hosts_path) {
83 Ok(f) => f,
84 Err(_) => return 0,
85 };
86 let reader = std::io::BufReader::new(file);
87 reader
88 .lines()
89 .map_while(Result::ok)
90 .filter(|line| {
91 let trimmed = line.trim();
92 !trimmed.is_empty() && !trimmed.starts_with('#')
93 })
94 .filter(|line| matches!(parse_known_hosts_line(line), KnownHostResult::Parsed(_)))
95 .count()
96}
97
98pub fn import_from_known_hosts(
101 paths: Option<&crate::runtime::env::Paths>,
102 config: &mut SshConfigFile,
103 group: Option<&str>,
104) -> Result<(usize, usize, usize, usize), String> {
105 info!("[config] Import started: source=~/.ssh/known_hosts");
106 let known_hosts_path = paths
107 .ok_or(crate::messages::IMPORT_HOME_DIR_UNKNOWN)?
108 .ssh_dir()
109 .join("known_hosts");
110
111 if !known_hosts_path.exists() {
112 return Err(crate::messages::IMPORT_KNOWN_HOSTS_MISSING.to_string());
113 }
114
115 let file = std::fs::File::open(&known_hosts_path)
116 .map_err(|e| crate::messages::import_known_hosts_open_failed(&e))?;
117 let reader = std::io::BufReader::new(file);
118
119 let mut read_errors = 0;
120 let mut parse_failures = 0;
121 let lines: Vec<String> = reader
122 .lines()
123 .filter_map(|r| match r {
124 Ok(line) => Some(line),
125 Err(_) => {
126 read_errors += 1;
127 None
128 }
129 })
130 .filter(|line| {
131 let trimmed = line.trim();
132 !trimmed.is_empty() && !trimmed.starts_with('#')
133 })
134 .collect();
135
136 let mut entries = Vec::new();
137 for line in &lines {
138 match parse_known_hosts_line(line) {
139 KnownHostResult::Parsed(entry) => entries.push(entry),
140 KnownHostResult::Skipped => {} KnownHostResult::Failed => parse_failures += 1,
142 }
143 }
144
145 let (imported, skipped) = add_entries(config, &entries, group)?;
146 info!("[config] Import completed: {imported} hosts added, {skipped} skipped");
147 Ok((imported, skipped, parse_failures, read_errors))
148}
149
150fn is_bare_ip(host: &str) -> bool {
152 if !host.is_empty() && host.chars().all(|c| c.is_ascii_digit() || c == '.') {
154 return true;
155 }
156 let ipv6_part = host.split('%').next().unwrap_or(host);
158 ipv6_part.contains(':') && ipv6_part.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
159}
160
161#[allow(clippy::large_enum_variant)]
163enum KnownHostResult {
164 Parsed(HostEntry),
166 Skipped,
168 Failed,
170}
171
172fn parse_known_hosts_line(line: &str) -> KnownHostResult {
174 let parts: Vec<&str> = line.split_whitespace().collect();
175 if parts.len() < 3 {
176 return KnownHostResult::Failed;
177 }
178
179 if parts[0].starts_with('@') {
181 return KnownHostResult::Skipped;
182 }
183 let host_part = parts[0];
184
185 if host_part.starts_with('|') {
187 return KnownHostResult::Skipped;
188 }
189
190 let host = host_part
193 .split(',')
194 .find(|entry| {
195 let bare = if entry.starts_with('[') {
196 entry
197 .get(1..entry.find(']').unwrap_or(entry.len()))
198 .unwrap_or(entry)
199 } else {
200 entry
201 };
202 !is_bare_ip(bare)
203 })
204 .unwrap_or_else(|| host_part.split(',').next().unwrap_or(host_part));
205
206 let (hostname, port) = if host.starts_with('[') {
208 let Some(end) = host.find(']') else {
209 return KnownHostResult::Failed;
210 };
211 let h = &host[1..end];
212 let rest = &host[end + 1..];
213 let p = if rest.is_empty() {
214 22
215 } else if let Some(port_str) = rest.strip_prefix(':') {
216 if port_str.is_empty() {
217 return KnownHostResult::Failed; }
219 match port_str.parse::<u16>() {
220 Ok(port) if port > 0 => port,
221 _ => return KnownHostResult::Failed,
222 }
223 } else {
224 return KnownHostResult::Failed; };
226 (h.to_string(), p)
227 } else {
228 (host.to_string(), 22)
229 };
230
231 if hostname.is_empty() {
233 return KnownHostResult::Failed;
234 }
235
236 if is_bare_ip(&hostname) {
238 return KnownHostResult::Skipped;
239 }
240
241 let alias = hostname.split('.').next().unwrap_or(&hostname).to_string();
242
243 if crate::ssh_config::model::is_host_pattern(&alias) {
245 return KnownHostResult::Skipped;
246 }
247
248 KnownHostResult::Parsed(HostEntry {
249 alias,
250 hostname,
251 port,
252 ..Default::default()
253 })
254}
255
256fn add_entries(
258 config: &mut SshConfigFile,
259 entries: &[HostEntry],
260 group: Option<&str>,
261) -> Result<(usize, usize), String> {
262 let mut imported = 0;
263 let mut skipped = 0;
264 let mut header_written = false;
265
266 for entry in entries {
267 if config.has_host(&entry.alias) {
268 skipped += 1;
269 continue;
270 }
271
272 if let Some(group_name) = group.filter(|_| !header_written) {
274 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
275 config
276 .elements
277 .push(crate::ssh_config::model::ConfigElement::GlobalLine(
278 String::new(),
279 ));
280 }
281 config
282 .elements
283 .push(crate::ssh_config::model::ConfigElement::GlobalLine(
284 format!("# {}", group_name),
285 ));
286 header_written = true;
287 }
288
289 if group.is_some() && imported == 0 {
290 let block = SshConfigFile::entry_to_block(entry);
292 config
293 .elements
294 .push(crate::ssh_config::model::ConfigElement::HostBlock(block));
295 } else {
296 config.add_host(entry);
297 }
298 imported += 1;
299 }
300
301 Ok((imported, skipped))
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 fn assert_host_blocks_separated(output: &str) {
311 let lines: Vec<&str> = output.lines().collect();
312 for w in lines.windows(2) {
313 let (prev, cur) = (w[0], w[1]);
314 if cur.starts_with("Host ") {
315 let prev_is_blank = prev.trim().is_empty();
316 let prev_is_top_level_comment = prev.starts_with('#');
317 assert!(
318 prev_is_blank || prev_is_top_level_comment,
319 "Host block glued to previous content (no blank separator):\n{output}"
320 );
321 }
322 }
323 }
324
325 #[test]
326 fn test_import_group_hosts_blank_separated() {
327 let mut config = SshConfigFile {
331 elements: Vec::new(),
332 path: std::path::PathBuf::new(),
333 crlf: false,
334 bom: false,
335 };
336 let entries = vec![
337 HostEntry {
338 alias: "web".into(),
339 hostname: "1.1.1.1".into(),
340 ..Default::default()
341 },
342 HostEntry {
343 alias: "db".into(),
344 hostname: "2.2.2.2".into(),
345 ..Default::default()
346 },
347 HostEntry {
348 alias: "api".into(),
349 hostname: "3.3.3.3".into(),
350 ..Default::default()
351 },
352 ];
353 let (imported, skipped) = add_entries(&mut config, &entries, Some("Imported")).unwrap();
354 assert_eq!(imported, 3);
355 assert_eq!(skipped, 0);
356
357 let output = config.serialize();
358 assert_host_blocks_separated(&output);
359 assert!(!output.contains("\n\n\n"), "triple blank lines:\n{output}");
360 }
361
362 #[test]
363 fn test_parse_known_hosts_simple() {
364 let KnownHostResult::Parsed(entry) = parse_known_hosts_line("example.com ssh-rsa AAAA...")
365 else {
366 panic!("expected Parsed");
367 };
368 assert_eq!(entry.hostname, "example.com");
369 assert_eq!(entry.alias, "example");
370 assert_eq!(entry.port, 22);
371 }
372
373 #[test]
374 fn test_parse_known_hosts_with_port() {
375 let KnownHostResult::Parsed(entry) =
376 parse_known_hosts_line("[myhost.com]:2222 ssh-ed25519 AAAA...")
377 else {
378 panic!("expected Parsed");
379 };
380 assert_eq!(entry.hostname, "myhost.com");
381 assert_eq!(entry.alias, "myhost");
382 assert_eq!(entry.port, 2222);
383 }
384
385 #[test]
386 fn test_parse_known_hosts_hashed() {
387 assert!(matches!(
388 parse_known_hosts_line("|1|abc=|def= ssh-rsa AAAA..."),
389 KnownHostResult::Skipped
390 ));
391 }
392
393 #[test]
394 fn test_parse_known_hosts_ip_only() {
395 assert!(matches!(
396 parse_known_hosts_line("192.168.1.1 ssh-rsa AAAA..."),
397 KnownHostResult::Skipped
398 ));
399 }
400
401 #[test]
402 fn test_parse_known_hosts_ipv6_skipped() {
403 assert!(matches!(
405 parse_known_hosts_line("2001:db8::1 ssh-rsa AAAA..."),
406 KnownHostResult::Skipped
407 ));
408 assert!(matches!(
409 parse_known_hosts_line("fe80::1 ssh-ed25519 AAAA..."),
410 KnownHostResult::Skipped
411 ));
412 }
413
414 #[test]
415 fn test_parse_known_hosts_hex_hostname_not_skipped() {
416 let KnownHostResult::Parsed(entry) = parse_known_hosts_line("deadbeef ssh-rsa AAAA...")
418 else {
419 panic!("expected Parsed");
420 };
421 assert_eq!(entry.alias, "deadbeef");
422
423 let KnownHostResult::Parsed(entry) =
424 parse_known_hosts_line("cafe.example.com ssh-rsa AAAA...")
425 else {
426 panic!("expected Parsed");
427 };
428 assert_eq!(entry.alias, "cafe");
429 }
430
431 #[test]
432 fn test_parse_known_hosts_invalid_port() {
433 assert!(matches!(
435 parse_known_hosts_line("[myhost]:abc ssh-rsa AAAA..."),
436 KnownHostResult::Failed
437 ));
438 assert!(matches!(
440 parse_known_hosts_line("[myhost]:70000 ssh-rsa AAAA..."),
441 KnownHostResult::Failed
442 ));
443 assert!(matches!(
445 parse_known_hosts_line("[myhost]:0 ssh-rsa AAAA..."),
446 KnownHostResult::Failed
447 ));
448 }
449
450 #[test]
451 fn test_parse_known_hosts_comma_separated() {
452 let KnownHostResult::Parsed(entry) =
453 parse_known_hosts_line("myserver.com,192.168.1.1 ssh-ed25519 AAAA...")
454 else {
455 panic!("expected Parsed");
456 };
457 assert_eq!(entry.hostname, "myserver.com");
458 assert_eq!(entry.alias, "myserver");
459 }
460
461 #[test]
462 fn test_parse_known_hosts_malformed_is_failure() {
463 assert!(matches!(
465 parse_known_hosts_line("onlyhost ssh-rsa"),
466 KnownHostResult::Failed
467 ));
468 assert!(matches!(
470 parse_known_hosts_line("[broken ssh-rsa AAAA..."),
471 KnownHostResult::Failed
472 ));
473 }
474
475 #[test]
476 fn test_parse_known_hosts_marker_is_skipped() {
477 assert!(matches!(
478 parse_known_hosts_line("@cert-authority *.example.com ssh-rsa AAAA..."),
479 KnownHostResult::Skipped
480 ));
481 assert!(matches!(
482 parse_known_hosts_line("@revoked host.com ssh-rsa AAAA..."),
483 KnownHostResult::Skipped
484 ));
485 }
486
487 #[test]
488 fn test_parse_known_hosts_numeric_first_label_not_skipped() {
489 let KnownHostResult::Parsed(entry) =
491 parse_known_hosts_line("123.example.com ssh-rsa AAAA...")
492 else {
493 panic!("expected Parsed");
494 };
495 assert_eq!(entry.hostname, "123.example.com");
496 assert_eq!(entry.alias, "123");
497 }
498
499 #[test]
500 fn test_parse_known_hosts_bracket_trailing_colon_fails() {
501 assert!(matches!(
503 parse_known_hosts_line("[myhost]: ssh-rsa AAAA..."),
504 KnownHostResult::Failed
505 ));
506 }
507
508 #[test]
509 fn test_parse_known_hosts_bracket_junk_after_close_fails() {
510 assert!(matches!(
512 parse_known_hosts_line("[myhost]junk ssh-rsa AAAA..."),
513 KnownHostResult::Failed
514 ));
515 }
516
517 #[test]
518 fn test_parse_known_hosts_bracket_no_port() {
519 let KnownHostResult::Parsed(entry) = parse_known_hosts_line("[myhost.com] ssh-rsa AAAA...")
521 else {
522 panic!("expected Parsed");
523 };
524 assert_eq!(entry.hostname, "myhost.com");
525 assert_eq!(entry.port, 22);
526 }
527
528 #[test]
529 fn test_parse_known_hosts_wildcard_is_skipped() {
530 assert!(matches!(
531 parse_known_hosts_line("*.example.com ssh-rsa AAAA..."),
532 KnownHostResult::Skipped
533 ));
534 }
535
536 #[test]
537 fn test_parse_known_hosts_bracket_pattern_skipped() {
538 assert!(matches!(
540 parse_known_hosts_line("web[12].example.com ssh-rsa AAAA..."),
541 KnownHostResult::Skipped
542 ));
543 }
544
545 #[test]
546 fn test_parse_known_hosts_negation_pattern_skipped() {
547 assert!(matches!(
548 parse_known_hosts_line("!prod.example.com ssh-rsa AAAA..."),
549 KnownHostResult::Skipped
550 ));
551 }
552
553 #[test]
554 fn test_parse_known_hosts_ip_first_comma_picks_hostname() {
555 let KnownHostResult::Parsed(entry) =
557 parse_known_hosts_line("192.0.2.10,web.example.com ssh-rsa AAAA...")
558 else {
559 panic!("expected Parsed");
560 };
561 assert_eq!(entry.hostname, "web.example.com");
562 assert_eq!(entry.alias, "web");
563 }
564
565 #[test]
566 fn test_parse_known_hosts_ipv6_first_comma_picks_hostname() {
567 let KnownHostResult::Parsed(entry) =
568 parse_known_hosts_line("2001:db8::1,server.example.com ssh-rsa AAAA...")
569 else {
570 panic!("expected Parsed");
571 };
572 assert_eq!(entry.hostname, "server.example.com");
573 assert_eq!(entry.alias, "server");
574 }
575
576 #[test]
577 fn test_parse_known_hosts_all_ips_comma_skipped() {
578 assert!(matches!(
580 parse_known_hosts_line("192.0.2.10,10.0.0.1 ssh-rsa AAAA..."),
581 KnownHostResult::Skipped
582 ));
583 }
584
585 #[test]
586 fn test_parse_known_hosts_bracketed_ip_first_comma_picks_hostname() {
587 let KnownHostResult::Parsed(entry) =
589 parse_known_hosts_line("[192.0.2.10]:2222,web.example.com ssh-rsa AAAA...")
590 else {
591 panic!("expected Parsed");
592 };
593 assert_eq!(entry.hostname, "web.example.com");
594 assert_eq!(entry.alias, "web");
595 }
596
597 #[test]
602 fn test_parse_known_hosts_empty_string() {
603 assert!(matches!(
605 parse_known_hosts_line(""),
606 KnownHostResult::Failed
607 ));
608 }
609
610 #[test]
611 fn test_parse_known_hosts_single_field() {
612 assert!(matches!(
614 parse_known_hosts_line("example.com"),
615 KnownHostResult::Failed
616 ));
617 }
618
619 #[test]
620 fn test_parse_known_hosts_hostname_with_hyphen() {
621 let KnownHostResult::Parsed(entry) =
622 parse_known_hosts_line("my-server.example.com ssh-rsa AAAA...")
623 else {
624 panic!("expected Parsed");
625 };
626 assert_eq!(entry.hostname, "my-server.example.com");
627 assert_eq!(entry.alias, "my-server");
628 }
629
630 #[test]
631 fn test_parse_known_hosts_multiple_hostnames_comma() {
632 let KnownHostResult::Parsed(entry) =
634 parse_known_hosts_line("primary.example.com,secondary.example.com ssh-rsa AAAA...")
635 else {
636 panic!("expected Parsed");
637 };
638 assert_eq!(entry.hostname, "primary.example.com");
639 assert_eq!(entry.alias, "primary");
640 }
641
642 #[test]
643 fn test_parse_known_hosts_ipv6_zone_id_skipped() {
644 assert!(matches!(
646 parse_known_hosts_line("fe80::1%eth0 ssh-rsa AAAA..."),
647 KnownHostResult::Skipped
648 ));
649 }
650
651 #[test]
652 fn test_parse_known_hosts_question_mark_pattern_skipped() {
653 assert!(matches!(
655 parse_known_hosts_line("web?.example.com ssh-rsa AAAA..."),
656 KnownHostResult::Skipped
657 ));
658 }
659
660 #[test]
665 fn test_import_status_pluralization() {
666 let fmt = |imported: usize, skipped: usize| -> String {
668 format!(
669 "Imported {} host{}, skipped {} duplicate{}",
670 imported,
671 if imported == 1 { "" } else { "s" },
672 skipped,
673 if skipped == 1 { "" } else { "s" },
674 )
675 };
676 assert_eq!(fmt(1, 0), "Imported 1 host, skipped 0 duplicates");
677 assert_eq!(fmt(1, 1), "Imported 1 host, skipped 1 duplicate");
678 assert_eq!(fmt(5, 0), "Imported 5 hosts, skipped 0 duplicates");
679 assert_eq!(fmt(5, 3), "Imported 5 hosts, skipped 3 duplicates");
680 assert_eq!(fmt(0, 5), "Imported 0 hosts, skipped 5 duplicates");
681 }
682
683 #[test]
684 fn test_import_all_duplicates_message() {
685 let msg_single = if 1 == 1 {
686 "Host already exists".to_string()
687 } else {
688 format!("All {} hosts already exist", 1)
689 };
690 assert_eq!(msg_single, "Host already exists");
691
692 let msg_multi = if 5 == 1 {
693 "Host already exists".to_string()
694 } else {
695 format!("All {} hosts already exist", 5)
696 };
697 assert_eq!(msg_multi, "All 5 hosts already exist");
698 }
699
700 #[test]
705 fn test_import_from_known_hosts_adds_to_config() {
706 let dir = std::env::temp_dir().join(format!(
708 "purple_test_import_{:?}",
709 std::thread::current().id()
710 ));
711 let _ = std::fs::remove_dir_all(&dir);
712 std::fs::create_dir_all(&dir).unwrap();
713
714 let hosts_file = dir.join("hosts.txt");
715 std::fs::write(&hosts_file, "web.example.com\ndb.example.com\n").unwrap();
716
717 let mut config = SshConfigFile {
718 elements: Vec::new(),
719 path: dir.join("config"),
720 crlf: false,
721 bom: false,
722 };
723
724 let result = import_from_file(&mut config, &hosts_file, Some("test-import"));
725 assert!(result.is_ok());
726 let (imported, skipped, _, _) = result.unwrap();
727 assert_eq!(imported, 2);
728 assert_eq!(skipped, 0);
729
730 assert!(config.has_host("web"));
732 assert!(config.has_host("db"));
733
734 let _ = std::fs::remove_dir_all(&dir);
735 }
736
737 #[test]
738 fn test_import_skips_duplicates() {
739 let dir = std::env::temp_dir().join(format!(
740 "purple_test_import_dup_{:?}",
741 std::thread::current().id()
742 ));
743 let _ = std::fs::remove_dir_all(&dir);
744 std::fs::create_dir_all(&dir).unwrap();
745
746 let hosts_file = dir.join("hosts.txt");
747 std::fs::write(&hosts_file, "web.example.com\n").unwrap();
748
749 let mut config = SshConfigFile {
750 elements: Vec::new(),
751 path: dir.join("config"),
752 crlf: false,
753 bom: false,
754 };
755
756 let (imported, _, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
758 assert_eq!(imported, 1);
759
760 let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
762 assert_eq!(imported, 0);
763 assert_eq!(skipped, 1);
764
765 let _ = std::fs::remove_dir_all(&dir);
766 }
767
768 #[test]
769 fn test_import_from_file_nonexistent() {
770 let mut config = SshConfigFile {
771 elements: Vec::new(),
772 path: std::path::PathBuf::from("/dev/null"),
773 crlf: false,
774 bom: false,
775 };
776 let result = import_from_file(&mut config, Path::new("/nonexistent/file"), None);
777 assert!(result.is_err());
778 }
779
780 #[test]
781 fn test_import_empty_file() {
782 let dir = std::env::temp_dir().join(format!(
783 "purple_test_import_empty_{:?}",
784 std::thread::current().id()
785 ));
786 let _ = std::fs::remove_dir_all(&dir);
787 std::fs::create_dir_all(&dir).unwrap();
788
789 let hosts_file = dir.join("hosts.txt");
790 std::fs::write(&hosts_file, "").unwrap();
791
792 let mut config = SshConfigFile {
793 elements: Vec::new(),
794 path: dir.join("config"),
795 crlf: false,
796 bom: false,
797 };
798
799 let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
800 assert_eq!(imported, 0);
801 assert_eq!(skipped, 0);
802
803 let _ = std::fs::remove_dir_all(&dir);
804 }
805
806 #[test]
807 fn test_import_comments_and_blanks_only() {
808 let dir = std::env::temp_dir().join(format!(
809 "purple_test_import_comments_{:?}",
810 std::thread::current().id()
811 ));
812 let _ = std::fs::remove_dir_all(&dir);
813 std::fs::create_dir_all(&dir).unwrap();
814
815 let hosts_file = dir.join("hosts.txt");
816 std::fs::write(&hosts_file, "# comment\n\n# another\n").unwrap();
817
818 let mut config = SshConfigFile {
819 elements: Vec::new(),
820 path: dir.join("config"),
821 crlf: false,
822 bom: false,
823 };
824
825 let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
826 assert_eq!(imported, 0);
827 assert_eq!(skipped, 0);
828
829 let _ = std::fs::remove_dir_all(&dir);
830 }
831
832 #[test]
833 fn test_is_bare_ip() {
834 assert!(is_bare_ip("192.168.1.1"));
835 assert!(is_bare_ip("10.0.0.1"));
836 assert!(is_bare_ip("2001:db8::1"));
837 assert!(is_bare_ip("fe80::1"));
838 assert!(is_bare_ip("fe80::1%en0"));
839 assert!(is_bare_ip("fe80::1%eth0"));
840 assert!(!is_bare_ip("example.com"));
841 assert!(!is_bare_ip("123.example.com"));
842 assert!(!is_bare_ip("deadbeef"));
843 assert!(!is_bare_ip(""));
844 }
845}