Skip to main content

purple_ssh/
import.rs

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
9/// Import hosts from a file with one `[user@]host[:port]` per line.
10/// Returns (imported, skipped, parse_failures, read_errors).
11pub 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                // Skip entries whose derived alias is an SSH pattern (*, ?, [, !)
50                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
74/// Count how many importable entries exist in ~/.ssh/known_hosts.
75/// Returns the count of parseable hostname entries, or 0 if the file
76/// doesn't exist or can't be read.
77pub 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
99/// Import hosts from ~/.ssh/known_hosts.
100/// Returns (imported, skipped, parse_failures, read_errors).
101pub 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 => {} // Intentional skip (hashed, marker, IP-only, wildcard)
139            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
148/// Check if a hostname is a bare IP address (not an FQDN).
149fn is_bare_ip(host: &str) -> bool {
150    // IPv4: digits and dots only (e.g., "192.168.1.1")
151    if !host.is_empty() && host.chars().all(|c| c.is_ascii_digit() || c == '.') {
152        return true;
153    }
154    // IPv6: hex digits + colons + optional zone ID (e.g., "2001:db8::1", "fe80::1%en0")
155    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/// Result of parsing a known_hosts line.
160#[allow(clippy::large_enum_variant)]
161enum KnownHostResult {
162    /// Successfully parsed into a HostEntry.
163    Parsed(HostEntry),
164    /// Intentionally skipped (hashed, marker, IP-only, wildcard).
165    Skipped,
166    /// Failed to parse (malformed line).
167    Failed,
168}
169
170/// Parse a single known_hosts line into a HostEntry.
171fn 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    // Skip marker lines (@cert-authority, @revoked)
178    if parts[0].starts_with('@') {
179        return KnownHostResult::Skipped;
180    }
181    let host_part = parts[0];
182
183    // Skip hashed entries (start with |)
184    if host_part.starts_with('|') {
185        return KnownHostResult::Skipped;
186    }
187
188    // Pick first non-IP host from comma-separated list.
189    // known_hosts may have ip,hostname or hostname,ip pairs.
190    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    // Handle [host]:port format
205    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; // [host]: with no port
216            }
217            match port_str.parse::<u16>() {
218                Ok(port) if port > 0 => port,
219                _ => return KnownHostResult::Failed,
220            }
221        } else {
222            return KnownHostResult::Failed; // [host]junk with no colon
223        };
224        (h.to_string(), p)
225    } else {
226        (host.to_string(), 22)
227    };
228
229    // Skip empty hostname
230    if hostname.is_empty() {
231        return KnownHostResult::Failed;
232    }
233
234    // Skip bare IP addresses (not FQDNs) before alias extraction.
235    if is_bare_ip(&hostname) {
236        return KnownHostResult::Skipped;
237    }
238
239    let alias = hostname.split('.').next().unwrap_or(&hostname).to_string();
240
241    // Skip wildcard/pattern entries
242    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
254/// Add entries to config, skipping exact alias duplicates.
255fn 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        // Write group header before the first actually-imported host
271        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            // Push first host directly after group comment (no blank separator between them)
289            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        // Bare IPv6 addresses should be skipped (hex digits + colons)
348        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        // Pure hex hostnames without colons are valid hostnames, not IPs
361        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        // Non-numeric port
378        assert!(matches!(
379            parse_known_hosts_line("[myhost]:abc ssh-rsa AAAA..."),
380            KnownHostResult::Failed
381        ));
382        // Port out of u16 range
383        assert!(matches!(
384            parse_known_hosts_line("[myhost]:70000 ssh-rsa AAAA..."),
385            KnownHostResult::Failed
386        ));
387        // Port 0
388        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        // Too few fields = parse failure
408        assert!(matches!(
409            parse_known_hosts_line("onlyhost ssh-rsa"),
410            KnownHostResult::Failed
411        ));
412        // Unclosed bracket = parse failure
413        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        // "123.example.com" has a numeric first label but is a valid FQDN, not an IP
434        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        // [host]: with no port number should fail
446        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        // [host]junk with no colon separator should fail
455        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        // [host] with no port should default to 22
464        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        // OpenSSH character class pattern [12] should be skipped
483        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        // When IP comes before hostname in comma list, hostname should still be used
500        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        // If all comma entries are IPs, skip the whole line
523        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        // [ip]:port,hostname format should pick the hostname
532        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    // =========================================================================
542    // Additional parse_known_hosts_line edge cases
543    // =========================================================================
544
545    #[test]
546    fn test_parse_known_hosts_empty_string() {
547        // Empty line should be filtered before parsing, but if it reaches the parser:
548        assert!(matches!(
549            parse_known_hosts_line(""),
550            KnownHostResult::Failed
551        ));
552    }
553
554    #[test]
555    fn test_parse_known_hosts_single_field() {
556        // Only one field, not enough for a valid known_hosts line
557        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        // Two non-IP hostnames: first one should be picked
577        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        // IPv6 with zone ID should be detected as bare IP and skipped
589        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        // ? is a pattern character in SSH
598        assert!(matches!(
599            parse_known_hosts_line("web?.example.com ssh-rsa AAAA..."),
600            KnownHostResult::Skipped
601        ));
602    }
603
604    // =========================================================================
605    // Import results and status message formatting
606    // =========================================================================
607
608    #[test]
609    fn test_import_status_pluralization() {
610        // Verify the exact format strings used in handler.rs
611        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    // =========================================================================
645    // import_from_known_hosts with in-memory config
646    // =========================================================================
647
648    #[test]
649    fn test_import_from_known_hosts_adds_to_config() {
650        // Create a temporary known_hosts-style file and import via import_from_file
651        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        // Verify hosts are in config
675        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        // First import
701        let (imported, _, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
702        assert_eq!(imported, 1);
703
704        // Second import - should be all duplicates
705        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}