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(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
98/// Import hosts from ~/.ssh/known_hosts.
99/// Returns (imported, skipped, parse_failures, read_errors).
100pub 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!("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 => {} // Intentional skip (hashed, marker, IP-only, wildcard)
141            KnownHostResult::Failed => parse_failures += 1,
142        }
143    }
144
145    let (imported, skipped) = add_entries(config, &entries, group)?;
146    info!("Import completed: {imported} hosts added, {skipped} skipped");
147    Ok((imported, skipped, parse_failures, read_errors))
148}
149
150/// Check if a hostname is a bare IP address (not an FQDN).
151fn is_bare_ip(host: &str) -> bool {
152    // IPv4: digits and dots only (e.g., "192.168.1.1")
153    if !host.is_empty() && host.chars().all(|c| c.is_ascii_digit() || c == '.') {
154        return true;
155    }
156    // IPv6: hex digits + colons + optional zone ID (e.g., "2001:db8::1", "fe80::1%en0")
157    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/// Result of parsing a known_hosts line.
162#[allow(clippy::large_enum_variant)]
163enum KnownHostResult {
164    /// Successfully parsed into a HostEntry.
165    Parsed(HostEntry),
166    /// Intentionally skipped (hashed, marker, IP-only, wildcard).
167    Skipped,
168    /// Failed to parse (malformed line).
169    Failed,
170}
171
172/// Parse a single known_hosts line into a HostEntry.
173fn 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    // Skip marker lines (@cert-authority, @revoked)
180    if parts[0].starts_with('@') {
181        return KnownHostResult::Skipped;
182    }
183    let host_part = parts[0];
184
185    // Skip hashed entries (start with |)
186    if host_part.starts_with('|') {
187        return KnownHostResult::Skipped;
188    }
189
190    // Pick first non-IP host from comma-separated list.
191    // known_hosts may have ip,hostname or hostname,ip pairs.
192    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    // Handle [host]:port format
207    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; // [host]: with no port
218            }
219            match port_str.parse::<u16>() {
220                Ok(port) if port > 0 => port,
221                _ => return KnownHostResult::Failed,
222            }
223        } else {
224            return KnownHostResult::Failed; // [host]junk with no colon
225        };
226        (h.to_string(), p)
227    } else {
228        (host.to_string(), 22)
229    };
230
231    // Skip empty hostname
232    if hostname.is_empty() {
233        return KnownHostResult::Failed;
234    }
235
236    // Skip bare IP addresses (not FQDNs) before alias extraction.
237    if is_bare_ip(&hostname) {
238        return KnownHostResult::Skipped;
239    }
240
241    let alias = hostname.split('.').next().unwrap_or(&hostname).to_string();
242
243    // Skip wildcard/pattern entries
244    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
256/// Add entries to config, skipping exact alias duplicates.
257fn 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        // Write group header before the first actually-imported host
273        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            // Push first host directly after group comment (no blank separator between them)
291            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    #[test]
309    fn test_parse_known_hosts_simple() {
310        let KnownHostResult::Parsed(entry) = parse_known_hosts_line("example.com ssh-rsa AAAA...")
311        else {
312            panic!("expected Parsed");
313        };
314        assert_eq!(entry.hostname, "example.com");
315        assert_eq!(entry.alias, "example");
316        assert_eq!(entry.port, 22);
317    }
318
319    #[test]
320    fn test_parse_known_hosts_with_port() {
321        let KnownHostResult::Parsed(entry) =
322            parse_known_hosts_line("[myhost.com]:2222 ssh-ed25519 AAAA...")
323        else {
324            panic!("expected Parsed");
325        };
326        assert_eq!(entry.hostname, "myhost.com");
327        assert_eq!(entry.alias, "myhost");
328        assert_eq!(entry.port, 2222);
329    }
330
331    #[test]
332    fn test_parse_known_hosts_hashed() {
333        assert!(matches!(
334            parse_known_hosts_line("|1|abc=|def= ssh-rsa AAAA..."),
335            KnownHostResult::Skipped
336        ));
337    }
338
339    #[test]
340    fn test_parse_known_hosts_ip_only() {
341        assert!(matches!(
342            parse_known_hosts_line("192.168.1.1 ssh-rsa AAAA..."),
343            KnownHostResult::Skipped
344        ));
345    }
346
347    #[test]
348    fn test_parse_known_hosts_ipv6_skipped() {
349        // Bare IPv6 addresses should be skipped (hex digits + colons)
350        assert!(matches!(
351            parse_known_hosts_line("2001:db8::1 ssh-rsa AAAA..."),
352            KnownHostResult::Skipped
353        ));
354        assert!(matches!(
355            parse_known_hosts_line("fe80::1 ssh-ed25519 AAAA..."),
356            KnownHostResult::Skipped
357        ));
358    }
359
360    #[test]
361    fn test_parse_known_hosts_hex_hostname_not_skipped() {
362        // Pure hex hostnames without colons are valid hostnames, not IPs
363        let KnownHostResult::Parsed(entry) = parse_known_hosts_line("deadbeef ssh-rsa AAAA...")
364        else {
365            panic!("expected Parsed");
366        };
367        assert_eq!(entry.alias, "deadbeef");
368
369        let KnownHostResult::Parsed(entry) =
370            parse_known_hosts_line("cafe.example.com ssh-rsa AAAA...")
371        else {
372            panic!("expected Parsed");
373        };
374        assert_eq!(entry.alias, "cafe");
375    }
376
377    #[test]
378    fn test_parse_known_hosts_invalid_port() {
379        // Non-numeric port
380        assert!(matches!(
381            parse_known_hosts_line("[myhost]:abc ssh-rsa AAAA..."),
382            KnownHostResult::Failed
383        ));
384        // Port out of u16 range
385        assert!(matches!(
386            parse_known_hosts_line("[myhost]:70000 ssh-rsa AAAA..."),
387            KnownHostResult::Failed
388        ));
389        // Port 0
390        assert!(matches!(
391            parse_known_hosts_line("[myhost]:0 ssh-rsa AAAA..."),
392            KnownHostResult::Failed
393        ));
394    }
395
396    #[test]
397    fn test_parse_known_hosts_comma_separated() {
398        let KnownHostResult::Parsed(entry) =
399            parse_known_hosts_line("myserver.com,192.168.1.1 ssh-ed25519 AAAA...")
400        else {
401            panic!("expected Parsed");
402        };
403        assert_eq!(entry.hostname, "myserver.com");
404        assert_eq!(entry.alias, "myserver");
405    }
406
407    #[test]
408    fn test_parse_known_hosts_malformed_is_failure() {
409        // Too few fields = parse failure
410        assert!(matches!(
411            parse_known_hosts_line("onlyhost ssh-rsa"),
412            KnownHostResult::Failed
413        ));
414        // Unclosed bracket = parse failure
415        assert!(matches!(
416            parse_known_hosts_line("[broken ssh-rsa AAAA..."),
417            KnownHostResult::Failed
418        ));
419    }
420
421    #[test]
422    fn test_parse_known_hosts_marker_is_skipped() {
423        assert!(matches!(
424            parse_known_hosts_line("@cert-authority *.example.com ssh-rsa AAAA..."),
425            KnownHostResult::Skipped
426        ));
427        assert!(matches!(
428            parse_known_hosts_line("@revoked host.com ssh-rsa AAAA..."),
429            KnownHostResult::Skipped
430        ));
431    }
432
433    #[test]
434    fn test_parse_known_hosts_numeric_first_label_not_skipped() {
435        // "123.example.com" has a numeric first label but is a valid FQDN, not an IP
436        let KnownHostResult::Parsed(entry) =
437            parse_known_hosts_line("123.example.com ssh-rsa AAAA...")
438        else {
439            panic!("expected Parsed");
440        };
441        assert_eq!(entry.hostname, "123.example.com");
442        assert_eq!(entry.alias, "123");
443    }
444
445    #[test]
446    fn test_parse_known_hosts_bracket_trailing_colon_fails() {
447        // [host]: with no port number should fail
448        assert!(matches!(
449            parse_known_hosts_line("[myhost]: ssh-rsa AAAA..."),
450            KnownHostResult::Failed
451        ));
452    }
453
454    #[test]
455    fn test_parse_known_hosts_bracket_junk_after_close_fails() {
456        // [host]junk with no colon separator should fail
457        assert!(matches!(
458            parse_known_hosts_line("[myhost]junk ssh-rsa AAAA..."),
459            KnownHostResult::Failed
460        ));
461    }
462
463    #[test]
464    fn test_parse_known_hosts_bracket_no_port() {
465        // [host] with no port should default to 22
466        let KnownHostResult::Parsed(entry) = parse_known_hosts_line("[myhost.com] ssh-rsa AAAA...")
467        else {
468            panic!("expected Parsed");
469        };
470        assert_eq!(entry.hostname, "myhost.com");
471        assert_eq!(entry.port, 22);
472    }
473
474    #[test]
475    fn test_parse_known_hosts_wildcard_is_skipped() {
476        assert!(matches!(
477            parse_known_hosts_line("*.example.com ssh-rsa AAAA..."),
478            KnownHostResult::Skipped
479        ));
480    }
481
482    #[test]
483    fn test_parse_known_hosts_bracket_pattern_skipped() {
484        // OpenSSH character class pattern [12] should be skipped
485        assert!(matches!(
486            parse_known_hosts_line("web[12].example.com ssh-rsa AAAA..."),
487            KnownHostResult::Skipped
488        ));
489    }
490
491    #[test]
492    fn test_parse_known_hosts_negation_pattern_skipped() {
493        assert!(matches!(
494            parse_known_hosts_line("!prod.example.com ssh-rsa AAAA..."),
495            KnownHostResult::Skipped
496        ));
497    }
498
499    #[test]
500    fn test_parse_known_hosts_ip_first_comma_picks_hostname() {
501        // When IP comes before hostname in comma list, hostname should still be used
502        let KnownHostResult::Parsed(entry) =
503            parse_known_hosts_line("192.0.2.10,web.example.com ssh-rsa AAAA...")
504        else {
505            panic!("expected Parsed");
506        };
507        assert_eq!(entry.hostname, "web.example.com");
508        assert_eq!(entry.alias, "web");
509    }
510
511    #[test]
512    fn test_parse_known_hosts_ipv6_first_comma_picks_hostname() {
513        let KnownHostResult::Parsed(entry) =
514            parse_known_hosts_line("2001:db8::1,server.example.com ssh-rsa AAAA...")
515        else {
516            panic!("expected Parsed");
517        };
518        assert_eq!(entry.hostname, "server.example.com");
519        assert_eq!(entry.alias, "server");
520    }
521
522    #[test]
523    fn test_parse_known_hosts_all_ips_comma_skipped() {
524        // If all comma entries are IPs, skip the whole line
525        assert!(matches!(
526            parse_known_hosts_line("192.0.2.10,10.0.0.1 ssh-rsa AAAA..."),
527            KnownHostResult::Skipped
528        ));
529    }
530
531    #[test]
532    fn test_parse_known_hosts_bracketed_ip_first_comma_picks_hostname() {
533        // [ip]:port,hostname format should pick the hostname
534        let KnownHostResult::Parsed(entry) =
535            parse_known_hosts_line("[192.0.2.10]:2222,web.example.com ssh-rsa AAAA...")
536        else {
537            panic!("expected Parsed");
538        };
539        assert_eq!(entry.hostname, "web.example.com");
540        assert_eq!(entry.alias, "web");
541    }
542
543    // =========================================================================
544    // Additional parse_known_hosts_line edge cases
545    // =========================================================================
546
547    #[test]
548    fn test_parse_known_hosts_empty_string() {
549        // Empty line should be filtered before parsing, but if it reaches the parser:
550        assert!(matches!(
551            parse_known_hosts_line(""),
552            KnownHostResult::Failed
553        ));
554    }
555
556    #[test]
557    fn test_parse_known_hosts_single_field() {
558        // Only one field, not enough for a valid known_hosts line
559        assert!(matches!(
560            parse_known_hosts_line("example.com"),
561            KnownHostResult::Failed
562        ));
563    }
564
565    #[test]
566    fn test_parse_known_hosts_hostname_with_hyphen() {
567        let KnownHostResult::Parsed(entry) =
568            parse_known_hosts_line("my-server.example.com ssh-rsa AAAA...")
569        else {
570            panic!("expected Parsed");
571        };
572        assert_eq!(entry.hostname, "my-server.example.com");
573        assert_eq!(entry.alias, "my-server");
574    }
575
576    #[test]
577    fn test_parse_known_hosts_multiple_hostnames_comma() {
578        // Two non-IP hostnames: first one should be picked
579        let KnownHostResult::Parsed(entry) =
580            parse_known_hosts_line("primary.example.com,secondary.example.com ssh-rsa AAAA...")
581        else {
582            panic!("expected Parsed");
583        };
584        assert_eq!(entry.hostname, "primary.example.com");
585        assert_eq!(entry.alias, "primary");
586    }
587
588    #[test]
589    fn test_parse_known_hosts_ipv6_zone_id_skipped() {
590        // IPv6 with zone ID should be detected as bare IP and skipped
591        assert!(matches!(
592            parse_known_hosts_line("fe80::1%eth0 ssh-rsa AAAA..."),
593            KnownHostResult::Skipped
594        ));
595    }
596
597    #[test]
598    fn test_parse_known_hosts_question_mark_pattern_skipped() {
599        // ? is a pattern character in SSH
600        assert!(matches!(
601            parse_known_hosts_line("web?.example.com ssh-rsa AAAA..."),
602            KnownHostResult::Skipped
603        ));
604    }
605
606    // =========================================================================
607    // Import results and status message formatting
608    // =========================================================================
609
610    #[test]
611    fn test_import_status_pluralization() {
612        // Verify the exact format strings used in handler.rs
613        let fmt = |imported: usize, skipped: usize| -> String {
614            format!(
615                "Imported {} host{}, skipped {} duplicate{}",
616                imported,
617                if imported == 1 { "" } else { "s" },
618                skipped,
619                if skipped == 1 { "" } else { "s" },
620            )
621        };
622        assert_eq!(fmt(1, 0), "Imported 1 host, skipped 0 duplicates");
623        assert_eq!(fmt(1, 1), "Imported 1 host, skipped 1 duplicate");
624        assert_eq!(fmt(5, 0), "Imported 5 hosts, skipped 0 duplicates");
625        assert_eq!(fmt(5, 3), "Imported 5 hosts, skipped 3 duplicates");
626        assert_eq!(fmt(0, 5), "Imported 0 hosts, skipped 5 duplicates");
627    }
628
629    #[test]
630    fn test_import_all_duplicates_message() {
631        let msg_single = if 1 == 1 {
632            "Host already exists".to_string()
633        } else {
634            format!("All {} hosts already exist", 1)
635        };
636        assert_eq!(msg_single, "Host already exists");
637
638        let msg_multi = if 5 == 1 {
639            "Host already exists".to_string()
640        } else {
641            format!("All {} hosts already exist", 5)
642        };
643        assert_eq!(msg_multi, "All 5 hosts already exist");
644    }
645
646    // =========================================================================
647    // import_from_known_hosts with in-memory config
648    // =========================================================================
649
650    #[test]
651    fn test_import_from_known_hosts_adds_to_config() {
652        // Create a temporary known_hosts-style file and import via import_from_file
653        let dir = std::env::temp_dir().join(format!(
654            "purple_test_import_{:?}",
655            std::thread::current().id()
656        ));
657        let _ = std::fs::remove_dir_all(&dir);
658        std::fs::create_dir_all(&dir).unwrap();
659
660        let hosts_file = dir.join("hosts.txt");
661        std::fs::write(&hosts_file, "web.example.com\ndb.example.com\n").unwrap();
662
663        let mut config = SshConfigFile {
664            elements: Vec::new(),
665            path: dir.join("config"),
666            crlf: false,
667            bom: false,
668        };
669
670        let result = import_from_file(&mut config, &hosts_file, Some("test-import"));
671        assert!(result.is_ok());
672        let (imported, skipped, _, _) = result.unwrap();
673        assert_eq!(imported, 2);
674        assert_eq!(skipped, 0);
675
676        // Verify hosts are in config
677        assert!(config.has_host("web"));
678        assert!(config.has_host("db"));
679
680        let _ = std::fs::remove_dir_all(&dir);
681    }
682
683    #[test]
684    fn test_import_skips_duplicates() {
685        let dir = std::env::temp_dir().join(format!(
686            "purple_test_import_dup_{:?}",
687            std::thread::current().id()
688        ));
689        let _ = std::fs::remove_dir_all(&dir);
690        std::fs::create_dir_all(&dir).unwrap();
691
692        let hosts_file = dir.join("hosts.txt");
693        std::fs::write(&hosts_file, "web.example.com\n").unwrap();
694
695        let mut config = SshConfigFile {
696            elements: Vec::new(),
697            path: dir.join("config"),
698            crlf: false,
699            bom: false,
700        };
701
702        // First import
703        let (imported, _, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
704        assert_eq!(imported, 1);
705
706        // Second import - should be all duplicates
707        let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
708        assert_eq!(imported, 0);
709        assert_eq!(skipped, 1);
710
711        let _ = std::fs::remove_dir_all(&dir);
712    }
713
714    #[test]
715    fn test_import_from_file_nonexistent() {
716        let mut config = SshConfigFile {
717            elements: Vec::new(),
718            path: std::path::PathBuf::from("/dev/null"),
719            crlf: false,
720            bom: false,
721        };
722        let result = import_from_file(&mut config, Path::new("/nonexistent/file"), None);
723        assert!(result.is_err());
724    }
725
726    #[test]
727    fn test_import_empty_file() {
728        let dir = std::env::temp_dir().join(format!(
729            "purple_test_import_empty_{:?}",
730            std::thread::current().id()
731        ));
732        let _ = std::fs::remove_dir_all(&dir);
733        std::fs::create_dir_all(&dir).unwrap();
734
735        let hosts_file = dir.join("hosts.txt");
736        std::fs::write(&hosts_file, "").unwrap();
737
738        let mut config = SshConfigFile {
739            elements: Vec::new(),
740            path: dir.join("config"),
741            crlf: false,
742            bom: false,
743        };
744
745        let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
746        assert_eq!(imported, 0);
747        assert_eq!(skipped, 0);
748
749        let _ = std::fs::remove_dir_all(&dir);
750    }
751
752    #[test]
753    fn test_import_comments_and_blanks_only() {
754        let dir = std::env::temp_dir().join(format!(
755            "purple_test_import_comments_{:?}",
756            std::thread::current().id()
757        ));
758        let _ = std::fs::remove_dir_all(&dir);
759        std::fs::create_dir_all(&dir).unwrap();
760
761        let hosts_file = dir.join("hosts.txt");
762        std::fs::write(&hosts_file, "# comment\n\n# another\n").unwrap();
763
764        let mut config = SshConfigFile {
765            elements: Vec::new(),
766            path: dir.join("config"),
767            crlf: false,
768            bom: false,
769        };
770
771        let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
772        assert_eq!(imported, 0);
773        assert_eq!(skipped, 0);
774
775        let _ = std::fs::remove_dir_all(&dir);
776    }
777
778    #[test]
779    fn test_is_bare_ip() {
780        assert!(is_bare_ip("192.168.1.1"));
781        assert!(is_bare_ip("10.0.0.1"));
782        assert!(is_bare_ip("2001:db8::1"));
783        assert!(is_bare_ip("fe80::1"));
784        assert!(is_bare_ip("fe80::1%en0"));
785        assert!(is_bare_ip("fe80::1%eth0"));
786        assert!(!is_bare_ip("example.com"));
787        assert!(!is_bare_ip("123.example.com"));
788        assert!(!is_bare_ip("deadbeef"));
789        assert!(!is_bare_ip(""));
790    }
791}