Skip to main content

retch_sysinfo/
network.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Network interface detection, IP resolution, Wi-Fi, and related helpers.
5
6use owo_colors::OwoColorize;
7use sysinfo::Networks;
8
9/// Detects the local IP address and active network interface name.
10pub fn detect_active_interface_and_local_ip() -> (Option<String>, Option<String>) {
11    let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
12        .ok()
13        .and_then(|socket| {
14            socket.connect("8.8.8.8:53").ok()?;
15            socket.local_addr().ok().map(|addr| addr.ip().to_string())
16        });
17
18    let active_interface = {
19        #[cfg(target_os = "linux")]
20        {
21            let native_iface = std::fs::read_to_string("/proc/net/route")
22                .ok()
23                .and_then(|content| parse_proc_net_route(&content));
24
25            native_iface.or_else(|| {
26                std::process::Command::new("ip")
27                    .args(["route", "show", "default"])
28                    .output()
29                    .ok()
30                    .and_then(|o| String::from_utf8(o.stdout).ok())
31                    .and_then(|s| {
32                        s.split_whitespace()
33                            .position(|w| w == "dev")
34                            .and_then(|i| s.split_whitespace().nth(i + 1))
35                            .map(|s| s.to_string())
36                    })
37            })
38        }
39        #[cfg(target_os = "macos")]
40        {
41            std::process::Command::new("route")
42                .args(["-n", "get", "default"])
43                .output()
44                .ok()
45                .and_then(|o| String::from_utf8(o.stdout).ok())
46                .and_then(|s| {
47                    s.lines()
48                        .find(|l| l.contains("interface:"))
49                        .and_then(|l| l.split_whitespace().last())
50                        .map(|s| s.to_string())
51                })
52        }
53        #[cfg(target_os = "windows")]
54        {
55            // Identify the active (default-route) interface as the adapter whose
56            // assigned IPs include the outbound `local_ip` we just resolved via the
57            // UDP-connect trick. This avoids spawning PowerShell `Get-NetRoute`,
58            // which costs ~1s of startup on Windows and dominated `--short` runtime.
59            // sysinfo already exposes per-interface IPs on Windows (see
60            // `detect_networks`), so no process spawn or extra API call is needed.
61            local_ip
62                .as_deref()
63                .and_then(|ip| ip.parse::<std::net::IpAddr>().ok())
64                .and_then(|target| {
65                    let networks = Networks::new_with_refreshed_list();
66                    match_active_interface(
67                        networks.iter().map(|(name, data)| {
68                            (
69                                name.to_string(),
70                                data.ip_networks().iter().map(|n| n.addr).collect(),
71                            )
72                        }),
73                        target,
74                    )
75                })
76        }
77        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
78        {
79            None
80        }
81    };
82
83    (local_ip, active_interface)
84}
85
86/// Returns the name of the interface whose assigned IPs include `local_ip`.
87///
88/// Used on Windows to identify the active (default-route) interface without a
89/// slow `Get-NetRoute` PowerShell spawn: the outbound local IP the OS picks to
90/// reach the internet uniquely belongs to the adapter carrying the default
91/// route, so matching it against each adapter's IP set yields the same answer.
92#[cfg(any(target_os = "windows", test))]
93fn match_active_interface(
94    ifaces: impl Iterator<Item = (String, Vec<std::net::IpAddr>)>,
95    local_ip: std::net::IpAddr,
96) -> Option<String> {
97    ifaces
98        .into_iter()
99        .find(|(_, ips)| ips.contains(&local_ip))
100        .map(|(name, _)| name)
101}
102
103/// Fetches the public IP address via an external service (best-effort, 2s timeout).
104pub fn detect_public_ip() -> Option<String> {
105    std::process::Command::new("curl")
106        .args(["-s", "--max-time", "2", "https://api.ipify.org"])
107        .output()
108        .ok()
109        .and_then(|o| String::from_utf8(o.stdout).ok())
110        .map(|s| s.trim().to_string())
111        .filter(|s| !s.is_empty())
112}
113
114/// Builds the formatted list of network interfaces with IP addresses and RX/TX stats.
115/// One network interface, as both the rendered display line and the facts about it.
116///
117/// **The two fields exist because returning only the string was the bug.** `display.rs`
118/// needed to know which entry was the active interface and which were up, and with nothing
119/// but a formatted, ANSI-colourised line to go on it resorted to `line.contains(active)`
120/// and `line.contains("[Up]")`. Both were wrong: the first matches any interface whose
121/// *rendered line* contains the active name as a substring (`Wi-Fi` matches
122/// `Wi-Fi-Native WiFi Filter Driver-0000`, and on Linux `eth0` matches `eth0.100`), and
123/// the second could never match at all, because the status is colourised, so the bytes are
124/// `[`+`\x1b[32m`+`Up`+`\x1b[39m`+`]` and the literal `[Up]` never appears.
125///
126/// Carrying the facts alongside the presentation makes both questions exact.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct NetworkInterface {
129    /// Kernel/adapter interface name, exactly as the OS reports it (`eth0`, `Wi-Fi`).
130    /// This is the same vocabulary `active_interface` uses, so the two compare directly.
131    pub name: String,
132    /// Whether the interface is up, or has moved bytes.
133    pub is_up: bool,
134    /// The formatted display line, including the name, addresses, status and RX/TX.
135    pub line: String,
136}
137
138pub fn detect_networks(
139    active_interface: Option<&str>,
140    local_ip: Option<&str>,
141) -> Vec<NetworkInterface> {
142    // On Windows, NDIS lightweight filters bound to an adapter are reported as interfaces
143    // in their own right, each carrying a copy of that adapter's counters — so a single
144    // Wi-Fi card shows up as `Wi-Fi` plus several `Wi-Fi-<filter>-0000` entries with
145    // identical RX/TX. Drop them, on the same rule `net-io` uses, so an adapter is listed
146    // once. Computed before the loop because it is one table read, not one per interface.
147    #[cfg(target_os = "windows")]
148    let excluded = crate::win_iftable::excluded_interface_names();
149
150    Networks::new_with_refreshed_list()
151        .iter()
152        .filter(|(name, _)| {
153            #[cfg(target_os = "windows")]
154            {
155                !excluded.iter().any(|e| e == *name)
156            }
157            #[cfg(not(target_os = "windows"))]
158            {
159                let _ = name;
160                true
161            }
162        })
163        .map(|(name, data)| {
164            let rx = format_bytes(data.total_received());
165            let tx = format_bytes(data.total_transmitted());
166            let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
167                || data.total_received() > 0
168                || data.total_transmitted() > 0;
169            let status = if is_up {
170                "Up".green().to_string()
171            } else {
172                "Down".red().to_string()
173            };
174
175            let mut ipv4_addresses = Vec::new();
176            let mut ipv6_addresses = Vec::new();
177
178            if is_up {
179                for ip_net in data.ip_networks() {
180                    let ip = ip_net.addr;
181                    let name_lower = name.to_lowercase();
182                    let is_loopback_iface =
183                        name_lower.starts_with("lo") || name_lower.contains("loopback");
184                    if ip.is_loopback() && !is_loopback_iface {
185                        continue;
186                    }
187                    match ip {
188                        std::net::IpAddr::V4(v4) => {
189                            ipv4_addresses.push(v4.to_string());
190                        }
191                        std::net::IpAddr::V6(v6) => {
192                            if !v6.is_unicast_link_local() {
193                                ipv6_addresses.push(v6.to_string());
194                            }
195                        }
196                    }
197                }
198
199                // Fallback to active interface UDP-resolved local IP if no IPs detected by sysinfo
200                if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
201                    if let (Some(active), Some(ip)) = (active_interface, local_ip) {
202                        if name == active {
203                            ipv4_addresses.push(ip.to_string());
204                        }
205                    }
206                }
207            }
208
209            let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
210                let mut combined = Vec::new();
211                if !ipv4_addresses.is_empty() {
212                    combined.push(ipv4_addresses.join(", "));
213                }
214                if !ipv6_addresses.is_empty() {
215                    combined.push(ipv6_addresses.join(", "));
216                }
217                format!(" ({})", combined.join(", "))
218            } else {
219                String::new()
220            };
221
222            NetworkInterface {
223                name: name.to_string(),
224                is_up,
225                line: format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx),
226            }
227        })
228        .collect()
229}
230
231/// Formats a byte count into human-readable form (KB, MB, GB, etc.)
232pub fn format_bytes(bytes: u64) -> String {
233    const KB: u64 = 1024;
234    const MB: u64 = KB * 1024;
235    const GB: u64 = MB * 1024;
236
237    if bytes >= GB {
238        format!("{:.1} GB", bytes as f64 / GB as f64)
239    } else if bytes >= MB {
240        format!("{:.1} MB", bytes as f64 / MB as f64)
241    } else if bytes >= KB {
242        format!("{:.1} KB", bytes as f64 / KB as f64)
243    } else {
244        format!("{} B", bytes)
245    }
246}
247
248/// Looks up a PCI vendor name from `/usr/share/hwdata/pci.ids` (or fallback paths).
249///
250/// `vendor_id` should be a lowercase hex string without the `0x` prefix.
251pub fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
252    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
253    let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
254    for path in &paths {
255        if let Ok(content) = std::fs::read_to_string(path) {
256            for line in content.lines() {
257                if line.starts_with('#') || line.is_empty() {
258                    continue;
259                }
260                if !line.starts_with('\t') {
261                    let parts: Vec<&str> = line.split_whitespace().collect();
262                    if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
263                        let name = line.strip_prefix(parts[0]).unwrap().trim();
264                        return Some(name.to_string());
265                    }
266                }
267            }
268        }
269    }
270    None
271}
272
273/// Detects the connected Wi-Fi network and link parameters.
274pub fn detect_wifi() -> Option<String> {
275    #[cfg(target_os = "linux")]
276    {
277        let mut wifi_interface = None;
278        if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
279            for entry in entries.filter_map(|e| e.ok()) {
280                let path = entry.path();
281                if path.join("wireless").exists() || path.join("phy80211").exists() {
282                    wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
283                    break;
284                }
285            }
286        }
287
288        if let Some(ref iface) = wifi_interface {
289            if let Ok(output) = std::process::Command::new("iw")
290                .args(["dev", iface, "link"])
291                .output()
292            {
293                if let Ok(stdout) = String::from_utf8(output.stdout) {
294                    let (ssid, links) = parse_iw_link_output(&stdout);
295                    if let Some(s) = ssid {
296                        let card_model = get_wifi_card_model(iface);
297                        let prefix = if let Some(m) = card_model {
298                            format!("{} [{}] - ", m, iface)
299                        } else {
300                            format!("[{}] - ", iface)
301                        };
302
303                        if !links.is_empty() {
304                            let mut link_strs = Vec::new();
305                            for link in links {
306                                let freq_str = link.freq.map(|f| {
307                                    let ghz_mhz = if f >= 1000.0 {
308                                        format!("{:.1} GHz", f / 1000.0)
309                                    } else {
310                                        format!("{} MHz", f)
311                                    };
312                                    if let Some(ch) = freq_to_channel(f) {
313                                        format!("{} ch{}", ghz_mhz, ch)
314                                    } else {
315                                        ghz_mhz
316                                    }
317                                });
318
319                                let mut rx_tx = Vec::new();
320                                if let Some(rx) = link.rx_rate {
321                                    if rx != "0"
322                                        && !rx.starts_with("0 ")
323                                        && rx != "0 Mbps"
324                                        && rx != "0 MBit/s"
325                                    {
326                                        rx_tx.push(format!("↓{}", clean_rate(&rx)));
327                                    }
328                                }
329                                if let Some(tx) = link.tx_rate {
330                                    if tx != "0"
331                                        && !tx.starts_with("0 ")
332                                        && tx != "0 Mbps"
333                                        && tx != "0 MBit/s"
334                                    {
335                                        rx_tx.push(format!("↑{}", clean_rate(&tx)));
336                                    }
337                                }
338
339                                match (freq_str, rx_tx.is_empty()) {
340                                    (Some(f), false) => {
341                                        link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
342                                    }
343                                    (Some(f), true) => link_strs.push(f),
344                                    (None, false) => link_strs.push(rx_tx.join(" ")),
345                                    _ => {}
346                                }
347                            }
348                            if !link_strs.is_empty() {
349                                return Some(format!(
350                                    "{}{}{} ({})",
351                                    prefix,
352                                    s,
353                                    "",
354                                    link_strs.join(", ")
355                                ));
356                            } else {
357                                return Some(format!("{}{}", prefix, s));
358                            }
359                        }
360                        return Some(format!("{}{}", prefix, s));
361                    }
362                }
363            }
364        }
365
366        // Fallback to nmcli (using --rescan no to avoid slow hardware channel scans)
367        if let Ok(output) = std::process::Command::new("nmcli")
368            .args([
369                "-t",
370                "-f",
371                "active,ssid,rate",
372                "device",
373                "wifi",
374                "list",
375                "--rescan",
376                "no",
377            ])
378            .output()
379        {
380            if let Ok(stdout) = String::from_utf8(output.stdout) {
381                for line in stdout.lines() {
382                    let line = line.trim();
383                    if let Some(rest) = line.strip_prefix("yes:") {
384                        if let Some(colon_idx) = rest.rfind(':') {
385                            let ssid = &rest[..colon_idx];
386                            let rate = rest[colon_idx + 1..].trim();
387                            if !ssid.is_empty() {
388                                if !rate.is_empty()
389                                    && rate != "0"
390                                    && !rate.starts_with("0 ")
391                                    && rate != "0 Mbit/s"
392                                    && rate != "0 Mbps"
393                                {
394                                    return Some(format!("{} ({})", ssid, clean_rate(rate)));
395                                } else {
396                                    return Some(ssid.to_string());
397                                }
398                            }
399                        } else if !rest.is_empty() {
400                            return Some(rest.to_string());
401                        }
402                    }
403                }
404            }
405        }
406
407        // Fallback to iwgetid
408        if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
409            if let Ok(stdout) = String::from_utf8(output.stdout) {
410                let ssid = stdout.trim();
411                if !ssid.is_empty() {
412                    return Some(ssid.to_string());
413                }
414            }
415        }
416        None
417    }
418
419    #[cfg(target_os = "macos")]
420    {
421        crate::macos_ffi::get_wifi_info().map(|(ssid, rate)| match rate {
422            Some(r) if r > 0 => format!("{} (↑{} Mbps)", ssid, r),
423            _ => ssid,
424        })
425    }
426
427    #[cfg(target_os = "windows")]
428    {
429        if let Ok(output) = std::process::Command::new("netsh")
430            .args(["wlan", "show", "interfaces"])
431            .output()
432        {
433            if let Ok(stdout) = String::from_utf8(output.stdout) {
434                return parse_netsh_output(&stdout);
435            }
436        }
437        None
438    }
439
440    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
441    {
442        None
443    }
444}
445
446#[cfg(any(target_os = "linux", test))]
447pub fn parse_proc_net_route(content: &str) -> Option<String> {
448    for line in content.lines().skip(1) {
449        let parts: Vec<&str> = line.split_whitespace().collect();
450        if parts.len() >= 8 {
451            let dest = parts[1];
452            let mask = parts[7];
453            if dest == "00000000" && mask == "00000000" {
454                return Some(parts[0].to_string());
455            }
456        }
457    }
458    None
459}
460
461#[allow(
462    clippy::manual_is_multiple_of,
463    clippy::manual_range_contains,
464    dead_code
465)]
466fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
467    let freq = freq_mhz.round() as u32;
468    if freq >= 2412 && freq <= 2472 {
469        Some((freq - 2407) / 5)
470    } else if freq == 2484 {
471        Some(14)
472    } else if freq >= 5160 && freq <= 5885 {
473        if (freq - 5000) % 5 == 0 {
474            Some((freq - 5000) / 5)
475        } else {
476            None
477        }
478    } else if freq >= 5955 && freq <= 7115 {
479        if (freq - 5950) % 5 == 0 {
480            Some((freq - 5950) / 5)
481        } else {
482            None
483        }
484    } else {
485        None
486    }
487}
488
489#[allow(dead_code)]
490fn get_wifi_card_model(iface: &str) -> Option<String> {
491    let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
492    let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
493    let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
494    let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
495
496    let vendor_name = lookup_pci_vendor(&vendor_clean);
497    let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
498
499    match (vendor_name, model_name) {
500        (Some(v), Some(m)) => {
501            let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
502            if m.to_lowercase().contains(&v_clean.to_lowercase())
503                || m.to_lowercase().contains(
504                    &v_clean
505                        .split_whitespace()
506                        .next()
507                        .unwrap_or("")
508                        .to_lowercase(),
509                )
510            {
511                Some(m)
512            } else {
513                Some(format!("{} {}", v_clean, m))
514            }
515        }
516        (None, Some(m)) => Some(m),
517        _ => None,
518    }
519}
520
521#[allow(dead_code)]
522fn clean_rate(rate: &str) -> String {
523    rate.replace("MBit/s", "Mbps")
524        .replace("GBit/s", "Gbps")
525        .replace("Bit/s", "bps")
526}
527
528#[derive(Debug, Clone)]
529pub struct WifiLink {
530    pub freq: Option<f64>,
531    pub rx_rate: Option<String>,
532    pub tx_rate: Option<String>,
533}
534
535#[allow(dead_code)]
536pub fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
537    let mut ssid = None;
538    let mut links = Vec::new();
539    let mut current_link = None;
540
541    for line in stdout.lines() {
542        let trimmed = line.trim();
543        if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
544            if let Some(link) = current_link.take() {
545                links.push(link);
546            }
547            current_link = Some(WifiLink {
548                freq: None,
549                rx_rate: None,
550                tx_rate: None,
551            });
552        } else if trimmed.starts_with("SSID:") {
553            ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
554        } else if trimmed.starts_with("freq:") {
555            if let Some(ref mut link) = current_link {
556                let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
557                link.freq = freq_str.parse::<f64>().ok();
558            }
559        } else if trimmed.starts_with("rx bitrate:") {
560            if let Some(ref mut link) = current_link {
561                let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
562                let rate = rx_str
563                    .split_whitespace()
564                    .take(2)
565                    .collect::<Vec<&str>>()
566                    .join(" ");
567                link.rx_rate = Some(rate);
568            }
569        } else if trimmed.starts_with("tx bitrate:") {
570            if let Some(ref mut link) = current_link {
571                let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
572                let rate = tx_str
573                    .split_whitespace()
574                    .take(2)
575                    .collect::<Vec<&str>>()
576                    .join(" ");
577                link.tx_rate = Some(rate);
578            }
579        }
580    }
581    if let Some(link) = current_link {
582        links.push(link);
583    }
584    (ssid, links)
585}
586
587#[allow(dead_code)]
588pub fn parse_netsh_output(stdout: &str) -> Option<String> {
589    let mut ssid = None;
590    let mut rx = None;
591    let mut tx = None;
592    let mut band = None;
593    for line in stdout.lines() {
594        let trimmed = line.trim();
595        if trimmed.starts_with("SSID") {
596            if let Some(idx) = trimmed.find(':') {
597                let val = trimmed[idx + 1..].trim().to_string();
598                if !val.is_empty() {
599                    ssid = Some(val);
600                }
601            }
602        } else if trimmed.starts_with("Receive rate (Mbps)") {
603            if let Some(idx) = trimmed.find(':') {
604                let val = trimmed[idx + 1..].trim().to_string();
605                if !val.is_empty() {
606                    rx = Some(val);
607                }
608            }
609        } else if trimmed.starts_with("Transmit rate (Mbps)") {
610            if let Some(idx) = trimmed.find(':') {
611                let val = trimmed[idx + 1..].trim().to_string();
612                if !val.is_empty() {
613                    tx = Some(val);
614                }
615            }
616        } else if trimmed.starts_with("Band") {
617            if let Some(idx) = trimmed.find(':') {
618                let val = trimmed[idx + 1..].trim().to_string();
619                if !val.is_empty() {
620                    band = Some(val);
621                }
622            }
623        }
624    }
625    if let Some(s) = ssid {
626        let mut rate_strs = Vec::new();
627        if let Some(rx_val) = rx {
628            if rx_val != "0" {
629                rate_strs.push(format!("↓{} Mbps", rx_val));
630            }
631        }
632        if let Some(tx_val) = tx {
633            if tx_val != "0" {
634                rate_strs.push(format!("↑{} Mbps", tx_val));
635            }
636        }
637        let info = match (band, rate_strs.is_empty()) {
638            (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
639            (Some(b), true) => b,
640            (None, false) => rate_strs.join(" "),
641            _ => String::new(),
642        };
643        if !info.is_empty() {
644            Some(format!("{} ({})", s, info))
645        } else {
646            Some(s)
647        }
648    } else {
649        None
650    }
651}
652
653/// Returns the list of configured DNS nameserver addresses.
654///
655/// Linux/macOS: parses `nameserver` lines from `/etc/resolv.conf`.
656/// Windows: reads `GetAdaptersAddresses`' per-adapter DNS server list natively.
657/// Returns an empty `Vec` if nothing is found.
658///
659/// **This was the single slowest field in `--long` on Windows.** It spawned
660/// `powershell -Command "Get-DnsClientServerAddress …"`, measured at **3409 ms** against a
661/// ~322 ms process-startup floor — enough on its own to set `--long`'s wall clock
662/// (3352 ms) and put retch 2.3x behind fastfetch in that mode. `-NoProfile` was measured
663/// and is **not** the answer: bare `powershell -Command exit` costs 893 ms with a profile
664/// and 878 ms without, so it is ~890 ms of interpreter startup plus ~2100 ms of cmdlet
665/// work. Only removing the spawn removes the cost — the same conclusion #146-#150 reached
666/// for the other Windows probes.
667pub fn detect_dns() -> Vec<String> {
668    #[cfg(any(target_os = "linux", target_os = "macos"))]
669    {
670        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
671            return parse_resolv_conf(&content);
672        }
673    }
674    #[cfg(target_os = "windows")]
675    {
676        windows_dns_servers()
677    }
678    #[cfg(not(target_os = "windows"))]
679    Vec::new()
680}
681
682#[cfg(any(target_os = "linux", target_os = "macos", test))]
683pub fn parse_resolv_conf(content: &str) -> Vec<String> {
684    content
685        .lines()
686        .filter_map(|line| {
687            let line = line.trim();
688            if line.starts_with('#') || line.starts_with(';') {
689                return None;
690            }
691            let mut parts = line.split_whitespace();
692            if parts.next()? == "nameserver" {
693                parts.next().map(|s| s.to_string())
694            } else {
695                None
696            }
697        })
698        .collect()
699}
700
701/// Returns the configured DNS domain name.
702///
703/// On **Linux**, the domain of the link carrying the IP default route wins (see
704/// [`resolve_default_route_domain`]). `/etc/resolv.conf` is only a fallback there, because
705/// under systemd-resolved it is the stub file whose `search` list is the *merged* set of
706/// every link's domains — so its first entry is frequently a VPN's domain rather than the
707/// default route's. On **macOS**, `/etc/resolv.conf` is written by configd from the primary
708/// network service, so it is read directly. On **Windows**, queries the primary DNS domain
709/// via `GetComputerNameExW` (`ComputerNameDnsDomain`).
710///
711/// Returns `None` when no domain is configured (e.g. a workgroup machine, or a default-route
712/// link with no DNS domain of its own) or the source is unavailable.
713pub fn detect_domain() -> Option<String> {
714    #[cfg(target_os = "linux")]
715    {
716        // Ask systemd-resolved what the *default-route* link's domain is. When it manages
717        // that link its answer is authoritative — including "no domain" — so we must not
718        // fall through to resolv.conf's merged list, which is what leaks a VPN's domain.
719        if let (Some(iface), Some(status)) = (default_route_interface(), resolvectl_status()) {
720            if let DefaultRouteDomain::Managed(domain) =
721                resolve_default_route_domain(&parse_resolvectl_domains(status), &iface)
722            {
723                return domain;
724            }
725        }
726        read_resolv_conf_domain()
727    }
728    #[cfg(target_os = "macos")]
729    {
730        read_resolv_conf_domain()
731    }
732    #[cfg(target_os = "windows")]
733    {
734        detect_domain_windows()
735    }
736    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
737    {
738        None
739    }
740}
741
742/// Reads the `domain`/`search` fallback from `/etc/resolv.conf`.
743#[cfg(any(target_os = "linux", target_os = "macos"))]
744fn read_resolv_conf_domain() -> Option<String> {
745    std::fs::read_to_string("/etc/resolv.conf")
746        .ok()
747        .and_then(|content| parse_domain_from_resolv_conf(&content))
748}
749
750/// Returns the interface carrying the IP default route, from `/proc/net/route`.
751///
752/// Deliberately the *routing table*, not resolvectl's `Default Route:` field — that field is
753/// systemd-resolved's DNS-routing flag (may this link's servers answer arbitrary queries)
754/// and is commonly `yes` for a VPN link and the physical link simultaneously, so it cannot
755/// identify the default route.
756#[cfg(target_os = "linux")]
757fn default_route_interface() -> Option<String> {
758    std::fs::read_to_string("/proc/net/route")
759        .ok()
760        .and_then(|content| parse_proc_net_route(&content))
761}
762
763/// Cached output of `resolvectl status --no-pager`, or `None` if it is unavailable.
764#[cfg(target_os = "linux")]
765static RESOLVECTL_STATUS: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
766
767/// Runs `resolvectl status --no-pager` at most once per process and caches the output.
768///
769/// Both the `domain` and `domain-search` fields need it, and they are collected
770/// sequentially; retch is a short-lived one-shot process, so a process-lifetime cache
771/// cannot go stale and saves a second ~5 ms spawn in `--full`.
772#[cfg(target_os = "linux")]
773fn resolvectl_status() -> Option<&'static str> {
774    RESOLVECTL_STATUS
775        .get_or_init(|| {
776            let output = std::process::Command::new("resolvectl")
777                .args(["status", "--no-pager"])
778                .output()
779                .ok()?;
780            if !output.status.success() {
781                return None;
782            }
783            Some(String::from_utf8_lossy(&output.stdout).into_owned())
784        })
785        .as_deref()
786}
787
788/// Structure holding DNS configuration per Windows adapter.
789#[cfg(any(target_os = "windows", test))]
790#[derive(Debug, Clone, PartialEq, Eq)]
791struct WinAdapterDnsInfo {
792    friendly_name: String,
793    dns_suffix: String,
794    is_up: bool,
795    is_loopback: bool,
796    /// Nameservers configured on this adapter, from `GetAdaptersAddresses`.
797    dns_servers: Vec<std::net::IpAddr>,
798}
799
800/// Windows `AF_INET`. Note `AF_INET6` is **23** on Windows, not 10 as on Linux.
801#[cfg(any(target_os = "windows", test))]
802const AF_INET: u16 = 2;
803/// Windows `AF_INET6`.
804#[cfg(any(target_os = "windows", test))]
805const AF_INET6: u16 = 23;
806
807/// Decodes a Win32 `sockaddr` into an IP address.
808///
809/// The family field is host-order `u16`; the address bytes that follow are in network
810/// order, which is the order `Ipv4Addr`/`Ipv6Addr` take them in, so no swapping is needed.
811/// `sockaddr_in` puts the 4 address bytes at offset 4 (after family and port);
812/// `sockaddr_in6` puts its 16 at offset 8 (after family, port and flowinfo).
813///
814/// Every access is bounds-checked against the length the OS reported rather than assumed
815/// from the family, so a short or truncated buffer yields `None` instead of reading past
816/// the end of it.
817#[cfg(any(target_os = "windows", test))]
818fn parse_sockaddr(bytes: &[u8]) -> Option<std::net::IpAddr> {
819    let family = u16::from_ne_bytes([*bytes.first()?, *bytes.get(1)?]);
820    match family {
821        AF_INET => {
822            let octets: [u8; 4] = bytes.get(4..8)?.try_into().ok()?;
823            Some(std::net::IpAddr::V4(octets.into()))
824        }
825        AF_INET6 => {
826            let octets: [u8; 16] = bytes.get(8..24)?.try_into().ok()?;
827            Some(std::net::IpAddr::V6(octets.into()))
828        }
829        _ => None,
830    }
831}
832
833/// Collects the machine's IPv4 nameservers from the adapter list.
834///
835/// **IPv4-only, deliberately**: the PowerShell query this replaces passed
836/// `-AddressFamily IPv4`, so restricting it here keeps the output byte-identical and makes
837/// this a pure performance change. Windows also hands out well-known placeholder v6
838/// servers (`fec0:0:0:ffff::1` and friends) on machines with no real v6 DNS, which would
839/// need filtering of their own. Reporting v6 nameservers — Linux already does, since
840/// `resolv.conf` lists them — is a separate, behavioural change.
841///
842/// Sorted as strings and de-duplicated, reproducing `Sort-Object -Unique`: that is a
843/// lexicographic sort, so `10.10.1.1` precedes `100.101.255.254`. Preserved for parity
844/// rather than because a numeric sort would be worse.
845#[cfg(target_os = "windows")]
846fn windows_dns_servers() -> Vec<String> {
847    let mut servers: Vec<String> = get_windows_adapters_dns_info()
848        .into_iter()
849        .flat_map(|adapter| adapter.dns_servers)
850        .filter(|ip| ip.is_ipv4())
851        .map(|ip| ip.to_string())
852        .collect();
853    servers.sort();
854    servers.dedup();
855    servers
856}
857
858/// Resolves the default route's DNS domain on Windows.
859///
860/// Matches the default route interface against adapter friendly names and
861/// returns its connection-specific `dns_suffix`. If no interface suffix is set,
862/// falls back to the machine-wide `global_domain`.
863#[cfg(any(target_os = "windows", test))]
864fn resolve_windows_default_domain(
865    active_iface: Option<&str>,
866    adapters: &[WinAdapterDnsInfo],
867    global_domain: Option<&str>,
868) -> Option<String> {
869    if let Some(iface) = active_iface {
870        if let Some(adapter) = adapters
871            .iter()
872            .find(|a| a.friendly_name.eq_ignore_ascii_case(iface))
873        {
874            if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
875                return Some(suffix);
876            }
877        }
878    }
879
880    if let Some(global) = global_domain.and_then(clean_domain) {
881        return Some(global);
882    }
883
884    None
885}
886
887/// Formats global and per-adapter search domain lists on Windows.
888#[cfg(any(target_os = "windows", test))]
889fn parse_windows_domain_search(
890    global_search_list: Option<&str>,
891    adapters: &[WinAdapterDnsInfo],
892) -> Vec<String> {
893    let mut results = Vec::new();
894
895    if let Some(raw) = global_search_list {
896        let global_domains: Vec<String> = raw
897            .split(&[',', ' '][..])
898            .filter_map(clean_domain)
899            .collect();
900        if !global_domains.is_empty() {
901            results.extend(format_global_search_domains(&global_domains));
902        }
903    }
904
905    for adapter in adapters {
906        if adapter.is_up && !adapter.is_loopback {
907            if let Some(suffix) = clean_domain(&adapter.dns_suffix) {
908                results.push(format!("{}: {}", adapter.friendly_name, suffix));
909            }
910        }
911    }
912
913    results
914}
915
916/// Windows: returns the active adapter's DNS domain via `GetAdaptersAddresses`.
917#[cfg(target_os = "windows")]
918fn detect_domain_windows() -> Option<String> {
919    let (_, active_iface) = detect_active_interface_and_local_ip();
920    let adapters = get_windows_adapters_dns_info();
921    let global_domain = crate::win_reg::get_reg_string(
922        crate::win_reg::HKEY_LOCAL_MACHINE,
923        "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
924        "Domain",
925    );
926    resolve_windows_default_domain(active_iface.as_deref(), &adapters, global_domain.as_deref())
927}
928
929/// Queries `GetAdaptersAddresses` for per-adapter DNS suffix and status info.
930#[cfg(target_os = "windows")]
931fn get_windows_adapters_dns_info() -> Vec<WinAdapterDnsInfo> {
932    use std::ffi::OsString;
933    use std::os::windows::ffi::OsStringExt;
934    use std::ptr;
935
936    #[repr(C)]
937    #[allow(non_snake_case)]
938    struct IpAdapterAddresses {
939        Length: u32,
940        IfIndex: u32,
941        Next: *mut IpAdapterAddresses,
942        AdapterName: *const i8,
943        FirstUnicastAddress: *const std::ffi::c_void,
944        FirstAnycastAddress: *const std::ffi::c_void,
945        FirstMulticastAddress: *const std::ffi::c_void,
946        FirstDnsServerAddress: *const IpAdapterDnsServerAddress,
947        DnsSuffix: *const u16,
948        Description: *const u16,
949        FriendlyName: *const u16,
950        PhysicalAddress: [u8; 8],
951        PhysicalAddressLength: u32,
952        Flags: u32,
953        Mtu: u32,
954        IfType: u32,
955        OperStatus: u32,
956    }
957
958    /// `SOCKET_ADDRESS` — a pointer to a `sockaddr` plus its length.
959    #[repr(C)]
960    #[allow(non_snake_case)]
961    struct SocketAddress {
962        lpSockaddr: *const u8,
963        iSockaddrLength: i32,
964    }
965
966    /// `IP_ADAPTER_DNS_SERVER_ADDRESS_XP`, a singly-linked list per adapter.
967    ///
968    /// The header declares `Length`/`Reserved` inside a union with a `ULONGLONG Alignment`,
969    /// which is why the two `u32`s sit at offset 0 and the `Next` pointer at 8.
970    #[repr(C)]
971    #[allow(non_snake_case)]
972    struct IpAdapterDnsServerAddress {
973        Length: u32,
974        Reserved: u32,
975        Next: *const IpAdapterDnsServerAddress,
976        Address: SocketAddress,
977    }
978
979    const AF_UNSPEC: u32 = 0;
980    /// `GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST`.
981    ///
982    /// **`GAA_FLAG_SKIP_DNS_SERVER` (0x08) used to be set here, and removing it is what
983    /// makes the native `dns` field possible.** With it, Windows leaves
984    /// `FirstDnsServerAddress` null — the field was declared in the struct below but could
985    /// never contain anything, which is why `detect_dns` had to spawn PowerShell instead.
986    /// Unicast is deliberately still requested (0x01 unset): `detect_domain` needs it.
987    const GAA_FLAGS: u32 = 0x06;
988    const IF_TYPE_SOFTWARE_LOOPBACK: u32 = 24;
989    const IF_OPER_STATUS_UP: u32 = 1;
990
991    #[link(name = "iphlpapi")]
992    extern "system" {
993        fn GetAdaptersAddresses(
994            family: u32,
995            flags: u32,
996            reserved: *mut std::ffi::c_void,
997            adapter_addresses: *mut IpAdapterAddresses,
998            size_pointer: *mut u32,
999        ) -> u32;
1000    }
1001
1002    let mut size: u32 = 0;
1003    // SAFETY: Size probe call with null pointer.
1004    unsafe {
1005        GetAdaptersAddresses(
1006            AF_UNSPEC,
1007            GAA_FLAGS,
1008            ptr::null_mut(),
1009            ptr::null_mut(),
1010            &mut size,
1011        );
1012    }
1013    if size == 0 {
1014        return Vec::new();
1015    }
1016
1017    let mut buf = vec![0u8; size as usize];
1018    // SAFETY: Buffer passed with capacity specified by `size`.
1019    let ret = unsafe {
1020        GetAdaptersAddresses(
1021            AF_UNSPEC,
1022            GAA_FLAGS,
1023            ptr::null_mut(),
1024            buf.as_mut_ptr() as *mut IpAdapterAddresses,
1025            &mut size,
1026        )
1027    };
1028    if ret != 0 {
1029        return Vec::new();
1030    }
1031
1032    let mut result = Vec::new();
1033    let mut curr = buf.as_ptr() as *const IpAdapterAddresses;
1034
1035    unsafe {
1036        while !curr.is_null() {
1037            let adapter = &*curr;
1038
1039            let friendly_name = if !adapter.FriendlyName.is_null() {
1040                let mut len = 0;
1041                while *adapter.FriendlyName.add(len) != 0 {
1042                    len += 1;
1043                }
1044                let slice = std::slice::from_raw_parts(adapter.FriendlyName, len);
1045                OsString::from_wide(slice).to_string_lossy().into_owned()
1046            } else {
1047                String::new()
1048            };
1049
1050            let adapter_name = if !adapter.AdapterName.is_null() {
1051                std::ffi::CStr::from_ptr(adapter.AdapterName)
1052                    .to_string_lossy()
1053                    .into_owned()
1054            } else {
1055                String::new()
1056            };
1057
1058            let mut dns_suffix = if !adapter.DnsSuffix.is_null() {
1059                let mut len = 0;
1060                while *adapter.DnsSuffix.add(len) != 0 {
1061                    len += 1;
1062                }
1063                let slice = std::slice::from_raw_parts(adapter.DnsSuffix, len);
1064                OsString::from_wide(slice).to_string_lossy().into_owned()
1065            } else {
1066                String::new()
1067            };
1068
1069            if dns_suffix.trim().is_empty() && !adapter_name.is_empty() {
1070                let subkey = format!(
1071                    "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces\\{}",
1072                    adapter_name
1073                );
1074                if let Some(s) = crate::win_reg::get_reg_string(
1075                    crate::win_reg::HKEY_LOCAL_MACHINE,
1076                    &subkey,
1077                    "SearchList",
1078                ) {
1079                    dns_suffix = s;
1080                } else if let Some(s) = crate::win_reg::get_reg_string(
1081                    crate::win_reg::HKEY_LOCAL_MACHINE,
1082                    &subkey,
1083                    "DhcpSearchList",
1084                ) {
1085                    dns_suffix = s;
1086                } else if let Some(s) = crate::win_reg::get_reg_string(
1087                    crate::win_reg::HKEY_LOCAL_MACHINE,
1088                    &subkey,
1089                    "Domain",
1090                ) {
1091                    dns_suffix = s;
1092                } else if let Some(s) = crate::win_reg::get_reg_string(
1093                    crate::win_reg::HKEY_LOCAL_MACHINE,
1094                    &subkey,
1095                    "DhcpDomain",
1096                ) {
1097                    dns_suffix = s;
1098                }
1099            }
1100
1101            // Walk this adapter's DNS server list. Each entry points at a `sockaddr` whose
1102            // length the OS reports; `parse_sockaddr` is handed exactly that many bytes and
1103            // bounds-checks within them, so a short or unfamiliar family is skipped rather
1104            // than read past.
1105            let mut dns_servers = Vec::new();
1106            let mut dns_entry = adapter.FirstDnsServerAddress;
1107            while !dns_entry.is_null() {
1108                let entry = &*dns_entry;
1109                if !entry.Address.lpSockaddr.is_null() && entry.Address.iSockaddrLength > 0 {
1110                    let len = entry.Address.iSockaddrLength as usize;
1111                    let bytes = std::slice::from_raw_parts(entry.Address.lpSockaddr, len);
1112                    if let Some(ip) = parse_sockaddr(bytes) {
1113                        dns_servers.push(ip);
1114                    }
1115                }
1116                dns_entry = entry.Next;
1117            }
1118
1119            result.push(WinAdapterDnsInfo {
1120                friendly_name,
1121                dns_suffix,
1122                is_up: adapter.OperStatus == IF_OPER_STATUS_UP,
1123                is_loopback: adapter.IfType == IF_TYPE_SOFTWARE_LOOPBACK,
1124                dns_servers,
1125            });
1126
1127            curr = adapter.Next;
1128        }
1129    }
1130
1131    result
1132}
1133
1134/// Trims a raw domain string and maps the empty string to `None`.
1135///
1136/// A non-domain-joined Windows host reports an empty DNS domain; treat that as
1137/// "no domain configured" rather than surfacing an empty value.
1138#[cfg(any(target_os = "windows", test))]
1139fn clean_domain(raw: &str) -> Option<String> {
1140    let trimmed = raw.trim();
1141    if trimmed.is_empty() {
1142        None
1143    } else {
1144        Some(trimmed.to_string())
1145    }
1146}
1147
1148/// Returns per-interface DNS search domain lists.
1149///
1150/// On Linux, tries `resolvectl status --no-pager` first and parses per-link
1151/// DNS Domain / DNS Search Domains entries. Falls back to the global `search`
1152/// list from `/etc/resolv.conf` when resolvectl is unavailable.
1153/// On macOS, reads the global `search` list from `/etc/resolv.conf`.
1154/// On Windows, enumerates adapters via `GetAdaptersAddresses` and registry `SearchList`.
1155pub fn detect_domain_search() -> Vec<String> {
1156    #[cfg(target_os = "linux")]
1157    {
1158        // Shares the one cached `resolvectl` spawn with `detect_domain`.
1159        if let Some(status) = resolvectl_status() {
1160            let result = parse_resolvectl_search(status);
1161            if !result.is_empty() {
1162                return result;
1163            }
1164        }
1165        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1166            return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1167        }
1168    }
1169    #[cfg(target_os = "macos")]
1170    {
1171        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
1172            return format_global_search_domains(&parse_search_from_resolv_conf(&content));
1173        }
1174    }
1175    #[cfg(target_os = "windows")]
1176    {
1177        let adapters = get_windows_adapters_dns_info();
1178        let global_search_list = crate::win_reg::get_reg_string(
1179            crate::win_reg::HKEY_LOCAL_MACHINE,
1180            "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
1181            "SearchList",
1182        );
1183        let results = parse_windows_domain_search(global_search_list.as_deref(), &adapters);
1184        if !results.is_empty() {
1185            return results;
1186        }
1187    }
1188    Vec::new()
1189}
1190
1191/// Scope label for search domains that carry no per-interface attribution.
1192///
1193/// `/etc/resolv.conf`'s `search` list is a single global list — it does not say which link
1194/// each domain came from — so it is labelled honestly rather than attributed to an
1195/// interface, which would be a fabrication on a multi-homed host.
1196#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1197const GLOBAL_SEARCH_SCOPE: &str = "global";
1198
1199/// Renders a scope-less (global) search-domain list in the same shape as the per-link
1200/// resolvectl path: one entry of `"<scope>: a, b"`.
1201#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
1202pub fn format_global_search_domains(domains: &[String]) -> Vec<String> {
1203    if domains.is_empty() {
1204        Vec::new()
1205    } else {
1206        vec![format!("{}: {}", GLOBAL_SEARCH_SCOPE, domains.join(", "))]
1207    }
1208}
1209
1210/// Parses the `domain` directive (or first `search` entry as fallback) from
1211/// `/etc/resolv.conf` content.
1212#[cfg(any(target_os = "linux", target_os = "macos", test))]
1213pub fn parse_domain_from_resolv_conf(content: &str) -> Option<String> {
1214    let mut first_search: Option<String> = None;
1215    for line in content.lines() {
1216        let line = line.trim();
1217        if line.starts_with('#') || line.starts_with(';') {
1218            continue;
1219        }
1220        let mut parts = line.split_whitespace();
1221        match parts.next() {
1222            Some("domain") => {
1223                if let Some(d) = parts.next() {
1224                    return Some(d.to_string());
1225                }
1226            }
1227            Some("search") if first_search.is_none() => {
1228                if let Some(d) = parts.next() {
1229                    first_search = Some(d.to_string());
1230                }
1231            }
1232            _ => {}
1233        }
1234    }
1235    first_search
1236}
1237
1238/// Parses all entries from the `search` directive in `/etc/resolv.conf` content.
1239#[cfg(any(target_os = "linux", target_os = "macos", test))]
1240pub fn parse_search_from_resolv_conf(content: &str) -> Vec<String> {
1241    for line in content.lines() {
1242        let line = line.trim();
1243        if line.starts_with('#') || line.starts_with(';') {
1244            continue;
1245        }
1246        let mut parts = line.split_whitespace();
1247        if parts.next() == Some("search") {
1248            let domains: Vec<String> = parts.map(|s| s.to_string()).collect();
1249            if !domains.is_empty() {
1250                return domains;
1251            }
1252        }
1253    }
1254    Vec::new()
1255}
1256
1257/// One link's DNS search domains, as reported by `resolvectl status`.
1258#[derive(Debug, Clone, Default, PartialEq, Eq)]
1259#[cfg(any(target_os = "linux", test))]
1260pub struct LinkDomains {
1261    /// Interface name, e.g. `wlp194s0`.
1262    pub interface: String,
1263    /// Search domains in report order, with routing-only (`~`-prefixed) entries removed.
1264    /// Empty means systemd-resolved manages this link but it has no search domain.
1265    pub search: Vec<String>,
1266}
1267
1268/// DNS search domains from `resolvectl status`, split by scope.
1269#[derive(Debug, Clone, Default, PartialEq, Eq)]
1270#[cfg(any(target_os = "linux", test))]
1271pub struct ResolvectlDomains {
1272    /// Domains from the `Global` section (e.g. `resolved.conf`'s `Domains=`), which belong
1273    /// to no particular interface.
1274    pub global: Vec<String>,
1275    /// One entry per `Link N (iface)` section, in report order.
1276    pub links: Vec<LinkDomains>,
1277}
1278
1279/// Outcome of looking up the default-route link in [`ResolvectlDomains`].
1280#[derive(Debug, Clone, PartialEq, Eq)]
1281#[cfg(any(target_os = "linux", test))]
1282pub enum DefaultRouteDomain {
1283    /// systemd-resolved manages the link, so its answer is authoritative. `None` means the
1284    /// link genuinely has no domain — the caller must **not** fall back to the merged
1285    /// `/etc/resolv.conf` search list, which would resurrect another link's (e.g. a VPN's)
1286    /// domain.
1287    Managed(Option<String>),
1288    /// The link has no section in the resolvectl output, so systemd-resolved has no opinion
1289    /// about it; the caller should fall back to `/etc/resolv.conf`.
1290    Unmanaged,
1291}
1292
1293/// Picks the domain to display for `interface` from parsed resolvectl output.
1294///
1295/// Prefers the link's own first search domain, then a `Global` domain (interface-independent,
1296/// so it cannot be another link's). Never returns a *different* link's domain — that is the
1297/// whole point: the default route's domain must win over a VPN's.
1298#[cfg(any(target_os = "linux", test))]
1299pub fn resolve_default_route_domain(
1300    domains: &ResolvectlDomains,
1301    interface: &str,
1302) -> DefaultRouteDomain {
1303    match domains.links.iter().find(|l| l.interface == interface) {
1304        Some(link) => DefaultRouteDomain::Managed(
1305            link.search
1306                .first()
1307                .or_else(|| domains.global.first())
1308                .cloned(),
1309        ),
1310        None => DefaultRouteDomain::Unmanaged,
1311    }
1312}
1313
1314/// Parses `resolvectl status --no-pager` output into per-scope DNS search domains.
1315///
1316/// Handles the two shapes that tripped up the previous single-line parser:
1317/// - **Wrapped values.** resolvectl right-aligns labels and continues long values on
1318///   following indented, label-less lines; those continuations were silently dropped.
1319/// - **Routing-only domains.** systemd prefixes a domain with `~` when it should only
1320///   *route* queries to that link, never be appended as a search suffix. Every `~` entry is
1321///   excluded (the old code special-cased only the exact catch-all `~.`).
1322///
1323/// Sections are recognised by content (`Global`, `Link N (iface)`) rather than by indentation,
1324/// since resolvectl's exact column padding varies with the longest label present. A `Link`
1325/// header always creates an entry, even with no domain line, so callers can distinguish
1326/// "managed, no domain" from "not managed at all".
1327#[cfg(any(target_os = "linux", test))]
1328pub fn parse_resolvectl_domains(content: &str) -> ResolvectlDomains {
1329    let mut out = ResolvectlDomains::default();
1330    // `None` = the Global section, `Some(iface)` = that link's section.
1331    let mut section: Option<String> = None;
1332    // True while consuming the (possibly wrapped) value of a DNS domain field.
1333    let mut in_domain_value = false;
1334
1335    for line in content.lines() {
1336        let trimmed = line.trim();
1337        if trimmed.is_empty() {
1338            in_domain_value = false;
1339            continue;
1340        }
1341
1342        // "Link N (iface)" starts a link section. Checked before the continuation branch
1343        // below, since a header carries no colon either.
1344        if let Some(iface) = trimmed
1345            .strip_prefix("Link ")
1346            .and_then(|rest| rest.split_once('('))
1347            .and_then(|(_, rest)| rest.split_once(')'))
1348            .map(|(iface, _)| iface)
1349        {
1350            in_domain_value = false;
1351            section = Some(iface.to_string());
1352            // Record the link even if it never reports a domain.
1353            if !out.links.iter().any(|l| l.interface == iface) {
1354                out.links.push(LinkDomains {
1355                    interface: iface.to_string(),
1356                    search: Vec::new(),
1357                });
1358            }
1359            continue;
1360        }
1361
1362        if trimmed == "Global" {
1363            in_domain_value = false;
1364            section = None;
1365            continue;
1366        }
1367
1368        if let Some(value) = trimmed
1369            .strip_prefix("DNS Domain:")
1370            .or_else(|| trimmed.strip_prefix("DNS Search Domains:"))
1371        {
1372            in_domain_value = true;
1373            push_resolvectl_domains(&mut out, section.as_deref(), value);
1374            continue;
1375        }
1376
1377        // A wrapped continuation of the domain value carries no `label:` of its own, and
1378        // domain names cannot contain ':' — so any colon means a new field has started.
1379        if in_domain_value && !trimmed.contains(':') {
1380            push_resolvectl_domains(&mut out, section.as_deref(), trimmed);
1381            continue;
1382        }
1383        in_domain_value = false;
1384    }
1385    out
1386}
1387
1388/// Appends whitespace-separated domains from one resolvectl value fragment to `section`,
1389/// dropping systemd routing-only (`~`-prefixed) entries.
1390#[cfg(any(target_os = "linux", test))]
1391fn push_resolvectl_domains(out: &mut ResolvectlDomains, section: Option<&str>, value: &str) {
1392    let domains = value
1393        .split_whitespace()
1394        .filter(|d| !d.starts_with('~'))
1395        .map(|d| d.to_string());
1396    match section {
1397        Some(iface) => match out.links.iter_mut().find(|l| l.interface == iface) {
1398            Some(link) => link.search.extend(domains),
1399            None => out.links.push(LinkDomains {
1400                interface: iface.to_string(),
1401                search: domains.collect(),
1402            }),
1403        },
1404        None => out.global.extend(domains),
1405    }
1406}
1407
1408/// Parses `resolvectl status --no-pager` output into per-interface search domain strings.
1409///
1410/// Formats [`parse_resolvectl_domains`] as `"wlan0: home.local"` entries, one per link,
1411/// skipping links with no search domain of their own.
1412#[cfg(any(target_os = "linux", test))]
1413pub fn parse_resolvectl_search(content: &str) -> Vec<String> {
1414    parse_resolvectl_domains(content)
1415        .links
1416        .into_iter()
1417        .filter(|link| !link.search.is_empty())
1418        .map(|link| format!("{}: {}", link.interface, link.search.join(", ")))
1419        .collect()
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    use super::*;
1425
1426    // ── parse_sockaddr ────────────────────────────────────────────────────────
1427    //
1428    // Byte fixtures rather than live adapters, so these assert the wire layout on every
1429    // platform's CI rather than whatever this machine's DNS happens to be — the
1430    // #155/v0.6.2 pattern. The bytes are laid out exactly as Windows hands them over.
1431
1432    /// `sockaddr_in` for 10.10.1.1: family (host order), port, then 4 address bytes.
1433    fn sockaddr_in(octets: [u8; 4]) -> Vec<u8> {
1434        let mut v = Vec::new();
1435        v.extend_from_slice(&AF_INET.to_ne_bytes());
1436        v.extend_from_slice(&53u16.to_be_bytes()); // sin_port
1437        v.extend_from_slice(&octets); // sin_addr at offset 4
1438        v.extend_from_slice(&[0u8; 8]); // sin_zero
1439        v
1440    }
1441
1442    /// `sockaddr_in6`: family, port, flowinfo, then the 16 address bytes at offset 8.
1443    fn sockaddr_in6(octets: [u8; 16]) -> Vec<u8> {
1444        let mut v = Vec::new();
1445        v.extend_from_slice(&AF_INET6.to_ne_bytes());
1446        v.extend_from_slice(&53u16.to_be_bytes());
1447        v.extend_from_slice(&0u32.to_ne_bytes()); // sin6_flowinfo
1448        v.extend_from_slice(&octets); // sin6_addr at offset 8
1449        v.extend_from_slice(&0u32.to_ne_bytes()); // sin6_scope_id
1450        v
1451    }
1452
1453    #[test]
1454    fn test_parse_sockaddr_reads_ipv4_at_offset_four() {
1455        // The real nameserver this machine reported, so the fixture is not invented.
1456        assert_eq!(
1457            parse_sockaddr(&sockaddr_in([10, 10, 1, 1])),
1458            Some("10.10.1.1".parse().unwrap())
1459        );
1460        assert_eq!(
1461            parse_sockaddr(&sockaddr_in([100, 101, 255, 254])),
1462            Some("100.101.255.254".parse().unwrap())
1463        );
1464    }
1465
1466    #[test]
1467    fn test_parse_sockaddr_reads_ipv6_at_offset_eight() {
1468        // fec0:0:0:ffff::1 — one of the placeholder servers Windows hands out when no real
1469        // v6 nameserver is configured, which is exactly why v6 is filtered out upstream.
1470        let mut o = [0u8; 16];
1471        o[0] = 0xfe;
1472        o[1] = 0xc0;
1473        o[6] = 0xff;
1474        o[7] = 0xff;
1475        o[15] = 1;
1476        assert_eq!(
1477            parse_sockaddr(&sockaddr_in6(o)),
1478            Some("fec0:0:0:ffff::1".parse().unwrap())
1479        );
1480    }
1481
1482    #[test]
1483    fn test_parse_sockaddr_rejects_short_and_unknown_buffers() {
1484        // A truncated buffer must yield None rather than read past the end of it: the
1485        // length comes from the OS and is trusted for the slice, not for the family.
1486        assert_eq!(parse_sockaddr(&[]), None);
1487        assert_eq!(parse_sockaddr(&AF_INET.to_ne_bytes()), None);
1488        assert_eq!(parse_sockaddr(&sockaddr_in([1, 2, 3, 4])[..7]), None);
1489        assert_eq!(parse_sockaddr(&sockaddr_in6([0; 16])[..20]), None);
1490        // AF_INET6 is 23 on Windows; 10 is the Linux value and must not be mistaken for it.
1491        let mut wrong_family = sockaddr_in6([0; 16]);
1492        wrong_family[0] = 10;
1493        wrong_family[1] = 0;
1494        assert_eq!(parse_sockaddr(&wrong_family), None);
1495    }
1496
1497    #[test]
1498    fn test_clean_domain() {
1499        // Normal domain passes through.
1500        assert_eq!(
1501            clean_domain("corp.example.com"),
1502            Some("corp.example.com".to_string())
1503        );
1504        // Surrounding whitespace is trimmed.
1505        assert_eq!(
1506            clean_domain("  example.org \n"),
1507            Some("example.org".to_string())
1508        );
1509        // A workgroup host reports an empty domain -> None (not Some("")).
1510        assert_eq!(clean_domain(""), None);
1511        assert_eq!(clean_domain("   "), None);
1512    }
1513
1514    #[test]
1515    fn test_match_active_interface() {
1516        use std::net::IpAddr;
1517        let target: IpAddr = "192.168.1.50".parse().unwrap();
1518        let ifaces = vec![
1519            ("lo".to_string(), vec!["127.0.0.1".parse().unwrap()]),
1520            (
1521                "Ethernet".to_string(),
1522                vec!["192.168.1.50".parse().unwrap(), "fe80::1".parse().unwrap()],
1523            ),
1524            ("Wi-Fi".to_string(), vec!["10.0.0.2".parse().unwrap()]),
1525        ];
1526        // Matches the adapter that actually holds the outbound local IP.
1527        assert_eq!(
1528            match_active_interface(ifaces.into_iter(), target),
1529            Some("Ethernet".to_string())
1530        );
1531
1532        // No adapter holds the target IP -> None (e.g. offline / unresolved).
1533        let orphan: IpAddr = "8.8.8.8".parse().unwrap();
1534        let ifaces2 = vec![(
1535            "lo".to_string(),
1536            vec!["127.0.0.1".parse::<IpAddr>().unwrap()],
1537        )];
1538        assert_eq!(match_active_interface(ifaces2.into_iter(), orphan), None);
1539    }
1540
1541    #[test]
1542    fn test_format_bytes() {
1543        assert_eq!(format_bytes(500), "500 B");
1544        assert_eq!(format_bytes(1024), "1.0 KB");
1545        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1546        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1547        assert_eq!(format_bytes(1536), "1.5 KB");
1548    }
1549
1550    #[test]
1551    fn test_parse_proc_net_route() {
1552        let sample =
1553            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1554                      wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
1555                      wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
1556        assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
1557
1558        let sample_no_default =
1559            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
1560                                 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
1561        assert_eq!(parse_proc_net_route(sample_no_default), None);
1562    }
1563
1564    #[test]
1565    fn test_parse_netsh_output() {
1566        let sample = "    Name                   : Wi-Fi\n    State                  : connected\n    SSID                   : Office_Wi-Fi\n    Receive rate (Mbps)    : 433\n    Transmit rate (Mbps)   : 866\n    Band                   : 5 GHz\n";
1567        assert_eq!(
1568            parse_netsh_output(sample),
1569            Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
1570        );
1571    }
1572
1573    #[test]
1574    fn test_parse_iw_link_output() {
1575        let sample = "Connected to 84:78:48:dc:97:23 (on wlp2s0)\n        SSID: OfficeNet\n        freq: 6135.0\n        rx bitrate: 6.0 MBit/s\n        tx bitrate: 864.6 MBit/s 160MHz HE-MCS 4\n";
1576        let (ssid, links) = parse_iw_link_output(sample);
1577        assert_eq!(ssid, Some("OfficeNet".to_string()));
1578        assert_eq!(links.len(), 1);
1579        assert_eq!(links[0].freq, Some(6135.0));
1580        assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
1581        assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
1582
1583        // MLO multi-link mock output
1584        let sample_mlo = "Connected to aa:bb:cc:dd:ee:ff (on wlan0)\n        SSID: HomeWiFi\n        freq: 5180.0\n        rx bitrate: 866.0 MBit/s\n        tx bitrate: 866.0 MBit/s\nConnected to aa:bb:cc:dd:ee:01 (on wlan0)\n        freq: 6135.0\n        rx bitrate: 1200.0 MBit/s\n        tx bitrate: 1200.0 MBit/s\n";
1585        let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
1586        assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
1587        assert_eq!(links_mlo.len(), 2);
1588        assert_eq!(links_mlo[0].freq, Some(5180.0));
1589        assert_eq!(links_mlo[1].freq, Some(6135.0));
1590    }
1591
1592    #[test]
1593    fn test_parse_resolv_conf() {
1594        let sample = "# Generated by NetworkManager\ndomain home\nsearch home\nnameserver 192.168.1.1\nnameserver 8.8.8.8\n; comment\nnameserver 2001:db8::1\n";
1595        assert_eq!(
1596            parse_resolv_conf(sample),
1597            vec!["192.168.1.1", "8.8.8.8", "2001:db8::1"]
1598        );
1599
1600        let empty = "# no nameservers\nsearch local\n";
1601        assert_eq!(parse_resolv_conf(empty), Vec::<String>::new());
1602    }
1603
1604    #[test]
1605    fn test_parse_domain_from_resolv_conf_domain_directive() {
1606        let s = "# test\ndomain example.com\nsearch fallback.com\nnameserver 1.1.1.1\n";
1607        assert_eq!(
1608            parse_domain_from_resolv_conf(s),
1609            Some("example.com".to_string())
1610        );
1611    }
1612
1613    #[test]
1614    fn test_parse_domain_from_resolv_conf_search_fallback() {
1615        let s = "# no domain directive\nsearch local.lan other.lan\nnameserver 1.1.1.1\n";
1616        assert_eq!(
1617            parse_domain_from_resolv_conf(s),
1618            Some("local.lan".to_string())
1619        );
1620    }
1621
1622    #[test]
1623    fn test_parse_domain_from_resolv_conf_none() {
1624        let s = "# no domain or search\nnameserver 1.1.1.1\n";
1625        assert_eq!(parse_domain_from_resolv_conf(s), None);
1626    }
1627
1628    #[test]
1629    fn test_parse_search_from_resolv_conf() {
1630        let s = "search home.local corp.example.com\nnameserver 1.1.1.1\n";
1631        assert_eq!(
1632            parse_search_from_resolv_conf(s),
1633            vec!["home.local", "corp.example.com"]
1634        );
1635    }
1636
1637    #[test]
1638    fn test_parse_resolvectl_search_basic() {
1639        let sample = "Global\n\
1640            Link 2 (lo)\n\
1641              Current Scopes: none\n\
1642            Link 3 (wlan0)\n\
1643              Current Scopes: DNS\n\
1644              DNS Domain: home.local\n\
1645            Link 4 (eth0)\n\
1646              Current Scopes: DNS\n\
1647              DNS Search Domains: corp.example.com internal.net\n";
1648        let result = parse_resolvectl_search(sample);
1649        assert_eq!(
1650            result,
1651            vec!["wlan0: home.local", "eth0: corp.example.com, internal.net"]
1652        );
1653    }
1654
1655    #[test]
1656    fn test_parse_resolvectl_search_skips_routing_domain() {
1657        let sample = "Link 2 (wlan0)\n  DNS Domain: ~.\nLink 3 (eth0)\n  DNS Domain: corp.net\n";
1658        let result = parse_resolvectl_search(sample);
1659        assert_eq!(result, vec!["eth0: corp.net"]);
1660    }
1661
1662    #[test]
1663    fn test_parse_resolvectl_search_empty() {
1664        let sample = "Global\n  DNS Servers: 1.1.1.1\n";
1665        assert!(parse_resolvectl_search(sample).is_empty());
1666    }
1667
1668    // ── domain selection: default route vs. VPN ───────────────────────────────
1669
1670    /// Verbatim `resolvectl status --no-pager` output from the reported machine: a NetBird
1671    /// VPN (`wt0`, split tunnel) alongside the Wi-Fi default route (`wlp194s0`). Note that
1672    /// **both** links report `Default Route: yes` — that is systemd-resolved's DNS-routing
1673    /// flag, not the IP default route — and that `wt0`'s DNS Domain value wraps onto a
1674    /// continuation line.
1675    const RESOLVECTL_VPN_SAMPLE: &str = concat!(
1676        "Global\n",
1677        "         Protocols: LLMNR=resolve -mDNS -DNSOverTLS DNSSEC=no/unsupported\n",
1678        "  resolv.conf mode: stub\n",
1679        "\n",
1680        "Link 2 (wlp194s0)\n",
1681        "    Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6\n",
1682        "         Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1683        "                    DNSSEC=no/unsupported\n",
1684        "Current DNS Server: 192.168.86.1\n",
1685        "       DNS Servers: 192.168.86.1\n",
1686        "        DNS Domain: lan\n",
1687        "     Default Route: yes\n",
1688        "\n",
1689        "Link 3 (wt0)\n",
1690        "    Current Scopes: DNS\n",
1691        "         Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1692        "                    DNSSEC=no/unsupported\n",
1693        "Current DNS Server: 100.101.32.155\n",
1694        "       DNS Servers: 100.101.32.155\n",
1695        "        DNS Domain: netbird.cloud ~gammatile.com ~101.100.in-addr.arpa\n",
1696        "                    ~f.f.0.0.3.2.b.5.b.c.8.5.7.f.d.f.ip6.arpa ~.\n",
1697        "     Default Route: yes\n",
1698    );
1699
1700    #[test]
1701    fn test_domain_prefers_default_route_over_vpn() {
1702        // The reported bug: resolv.conf's merged "search netbird.cloud lan" put the VPN
1703        // first, so the Domain field showed netbird.cloud. Keyed on the default-route
1704        // interface, the answer is the Wi-Fi link's own domain.
1705        let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1706        assert_eq!(
1707            resolve_default_route_domain(&parsed, "wlp194s0"),
1708            DefaultRouteDomain::Managed(Some("lan".to_string()))
1709        );
1710    }
1711
1712    #[test]
1713    fn test_domain_reports_vpn_when_vpn_is_the_default_route() {
1714        // Full-tunnel case: if the VPN *is* the default route, its domain is correct.
1715        let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1716        assert_eq!(
1717            resolve_default_route_domain(&parsed, "wt0"),
1718            DefaultRouteDomain::Managed(Some("netbird.cloud".to_string()))
1719        );
1720    }
1721
1722    #[test]
1723    fn test_domain_routing_only_entries_are_not_domains() {
1724        // Every `~`-prefixed entry is routing-only, never a search suffix — so a link whose
1725        // only entries are `~`-prefixed has no domain (and must not yield "~gammatile.com").
1726        let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1727        let wt0 = parsed.links.iter().find(|l| l.interface == "wt0").unwrap();
1728        assert_eq!(wt0.search, vec!["netbird.cloud"]);
1729        assert!(wt0.search.iter().all(|d| !d.starts_with('~')));
1730
1731        let routing_only = "Link 5 (tun0)\n        DNS Domain: ~corp.example.com ~.\n";
1732        let parsed = parse_resolvectl_domains(routing_only);
1733        assert_eq!(
1734            resolve_default_route_domain(&parsed, "tun0"),
1735            DefaultRouteDomain::Managed(None)
1736        );
1737    }
1738
1739    #[test]
1740    fn test_parse_resolvectl_domains_reads_wrapped_continuation_lines() {
1741        // Regression: the old parser read only the first line of a wrapped value, silently
1742        // dropping the rest. Here the continuation carries a real search domain.
1743        let sample = concat!(
1744            "Link 2 (eth0)\n",
1745            "        DNS Domain: one.example.com two.example.com\n",
1746            "                    three.example.com ~routing.example.com\n",
1747            "     Default Route: yes\n",
1748        );
1749        let parsed = parse_resolvectl_domains(sample);
1750        assert_eq!(
1751            parsed.links[0].search,
1752            vec!["one.example.com", "two.example.com", "three.example.com"]
1753        );
1754        // The following `label: value` line must end the value, not join it.
1755        assert!(!parsed.links[0].search.iter().any(|d| d.contains("yes")));
1756    }
1757
1758    #[test]
1759    fn test_parse_resolvectl_domains_ignores_other_wrapped_fields() {
1760        // `Protocols:` also wraps; its continuation must not be mistaken for a domain.
1761        let sample = concat!(
1762            "Link 2 (eth0)\n",
1763            "         Protocols: +DefaultRoute LLMNR=resolve -mDNS -DNSOverTLS\n",
1764            "                    DNSSEC=no/unsupported\n",
1765            "        DNS Domain: real.example.com\n",
1766        );
1767        let parsed = parse_resolvectl_domains(sample);
1768        assert_eq!(parsed.links[0].search, vec!["real.example.com"]);
1769    }
1770
1771    #[test]
1772    fn test_domain_unmanaged_link_falls_back() {
1773        // A default-route interface systemd-resolved knows nothing about: the caller should
1774        // fall back to /etc/resolv.conf rather than borrow another link's domain.
1775        let parsed = parse_resolvectl_domains(RESOLVECTL_VPN_SAMPLE);
1776        assert_eq!(
1777            resolve_default_route_domain(&parsed, "ppp0"),
1778            DefaultRouteDomain::Unmanaged
1779        );
1780    }
1781
1782    #[test]
1783    fn test_domain_managed_without_domain_does_not_borrow_from_other_links() {
1784        // wlp194s0 is managed but has no domain of its own; the VPN's domain must NOT be
1785        // substituted (that is the bug). With no Global domain either, the answer is "none".
1786        let sample = concat!(
1787            "Link 2 (wlp194s0)\n",
1788            "     Default Route: yes\n",
1789            "Link 3 (wt0)\n",
1790            "        DNS Domain: netbird.cloud\n",
1791        );
1792        let parsed = parse_resolvectl_domains(sample);
1793        assert_eq!(
1794            resolve_default_route_domain(&parsed, "wlp194s0"),
1795            DefaultRouteDomain::Managed(None)
1796        );
1797    }
1798
1799    #[test]
1800    fn test_domain_falls_back_to_global_but_not_to_another_link() {
1801        // A Global domain (resolved.conf `Domains=`) belongs to no interface, so it is a
1802        // legitimate answer when the default-route link has none of its own.
1803        let sample = concat!(
1804            "Global\n",
1805            "        DNS Domain: corp.example.com\n",
1806            "Link 2 (wlp194s0)\n",
1807            "     Default Route: yes\n",
1808            "Link 3 (wt0)\n",
1809            "        DNS Domain: netbird.cloud\n",
1810        );
1811        let parsed = parse_resolvectl_domains(sample);
1812        assert_eq!(parsed.global, vec!["corp.example.com"]);
1813        assert_eq!(
1814            resolve_default_route_domain(&parsed, "wlp194s0"),
1815            DefaultRouteDomain::Managed(Some("corp.example.com".to_string()))
1816        );
1817    }
1818
1819    #[test]
1820    fn test_parse_resolvectl_domains_merges_both_domain_labels() {
1821        // A link reporting both labels yields one merged entry, not two.
1822        let sample = concat!(
1823            "Link 2 (eth0)\n",
1824            "        DNS Domain: a.example.com\n",
1825            "DNS Search Domains: b.example.com\n",
1826        );
1827        let parsed = parse_resolvectl_domains(sample);
1828        assert_eq!(parsed.links.len(), 1);
1829        assert_eq!(
1830            parsed.links[0].search,
1831            vec!["a.example.com", "b.example.com"]
1832        );
1833        assert_eq!(
1834            parse_resolvectl_search(sample),
1835            vec!["eth0: a.example.com, b.example.com"]
1836        );
1837    }
1838
1839    // ── Domain Search: one shape regardless of source ─────────────────────────
1840
1841    #[test]
1842    fn test_global_search_domains_match_per_link_shape() {
1843        // The fallback must render like the resolvectl path — "<scope>: a, b" — so the field
1844        // has one shape. Regression for the CI inconsistency where the same OS flipped format
1845        // depending on whether systemd-resolved was reachable (bare runner vs. container).
1846        let per_link = parse_resolvectl_search("Link 2 (eth0)\n  DNS Domain: a.example.com\n");
1847        assert_eq!(per_link, vec!["eth0: a.example.com"]);
1848
1849        let global = format_global_search_domains(&["a.example.com".to_string()]);
1850        assert_eq!(global, vec!["global: a.example.com"]);
1851
1852        // Same structural shape: exactly one entry, "<scope>: <domains>".
1853        assert_eq!(per_link.len(), global.len());
1854        for entry in per_link.iter().chain(global.iter()) {
1855            let (scope, domains) = entry.split_once(": ").expect("entry must carry a scope");
1856            assert!(!scope.is_empty() && !domains.is_empty());
1857        }
1858    }
1859
1860    #[test]
1861    fn test_global_search_domains_group_into_one_entry() {
1862        // The raw resolv.conf parse yields one element per domain, and the display prints one
1863        // line per element — so `search a b c` used to emit three bare `Domain Search:` lines
1864        // while resolvectl emitted one per interface. Now it is a single grouped entry.
1865        let parsed = parse_search_from_resolv_conf("search a.example.com b.example.com c.net\n");
1866        assert_eq!(parsed.len(), 3); // parser stays faithful to the file
1867        assert_eq!(
1868            format_global_search_domains(&parsed),
1869            vec!["global: a.example.com, b.example.com, c.net"]
1870        );
1871    }
1872
1873    #[test]
1874    fn test_global_search_domains_empty_yields_no_line() {
1875        // No search list -> no entry at all, so the field stays hidden (unchanged behaviour).
1876        assert!(format_global_search_domains(&[]).is_empty());
1877        assert!(format_global_search_domains(&parse_search_from_resolv_conf(
1878            "nameserver 1.1.1.1\n"
1879        ))
1880        .is_empty());
1881    }
1882
1883    #[test]
1884    fn test_default_route_interface_selection_matches_routing_table() {
1885        // The routing table is the source of truth for "default route" — not resolvectl's
1886        // per-link `Default Route:` flag, which is `yes` for both links in the VPN sample.
1887        // Real /proc/net/route from the reported machine: only wlp194s0 has dest+mask 0.
1888        let proc_net_route = concat!(
1889            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n",
1890            "wlp194s0\t00000000\t0156A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n",
1891            "wt0\t00006564\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0\n",
1892            "wlp194s0\t0056A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n",
1893        );
1894        assert_eq!(
1895            parse_proc_net_route(proc_net_route),
1896            Some("wlp194s0".to_string())
1897        );
1898    }
1899
1900    #[test]
1901    fn test_resolve_windows_default_domain() {
1902        let adapters = vec![
1903            WinAdapterDnsInfo {
1904                friendly_name: "Wi-Fi".to_string(),
1905                dns_suffix: "lan.home".to_string(),
1906                is_up: true,
1907                is_loopback: false,
1908                dns_servers: Vec::new(),
1909            },
1910            WinAdapterDnsInfo {
1911                friendly_name: "Ethernet".to_string(),
1912                dns_suffix: "corp.internal".to_string(),
1913                is_up: true,
1914                is_loopback: false,
1915                dns_servers: Vec::new(),
1916            },
1917        ];
1918
1919        // Active interface match
1920        assert_eq!(
1921            resolve_windows_default_domain(Some("Wi-Fi"), &adapters, None),
1922            Some("lan.home".to_string())
1923        );
1924
1925        // Case-insensitive active interface match
1926        assert_eq!(
1927            resolve_windows_default_domain(Some("wi-fi"), &adapters, None),
1928            Some("lan.home".to_string())
1929        );
1930
1931        // Active interface has no suffix -> falls back to global domain
1932        let adapters_no_suffix = vec![WinAdapterDnsInfo {
1933            friendly_name: "Wi-Fi".to_string(),
1934            dns_suffix: "".to_string(),
1935            is_up: true,
1936            is_loopback: false,
1937            dns_servers: Vec::new(),
1938        }];
1939        assert_eq!(
1940            resolve_windows_default_domain(
1941                Some("Wi-Fi"),
1942                &adapters_no_suffix,
1943                Some("global.example.com")
1944            ),
1945            Some("global.example.com".to_string())
1946        );
1947
1948        // Active interface unknown -> falls back to global domain
1949        assert_eq!(
1950            resolve_windows_default_domain(Some("Unknown"), &adapters, Some("global.example.com")),
1951            Some("global.example.com".to_string())
1952        );
1953    }
1954
1955    #[test]
1956    fn test_parse_windows_domain_search() {
1957        let adapters = vec![
1958            WinAdapterDnsInfo {
1959                friendly_name: "Wi-Fi".to_string(),
1960                dns_suffix: "lan.home".to_string(),
1961                is_up: true,
1962                is_loopback: false,
1963                dns_servers: Vec::new(),
1964            },
1965            WinAdapterDnsInfo {
1966                friendly_name: "vEthernet".to_string(),
1967                dns_suffix: "netbird.cloud".to_string(),
1968                is_up: true,
1969                is_loopback: false,
1970                dns_servers: Vec::new(),
1971            },
1972            WinAdapterDnsInfo {
1973                friendly_name: "Loopback Pseudo-Interface 1".to_string(),
1974                dns_suffix: "ignore.me".to_string(),
1975                is_up: true,
1976                is_loopback: true,
1977                dns_servers: Vec::new(),
1978            },
1979            WinAdapterDnsInfo {
1980                friendly_name: "Disconnected".to_string(),
1981                dns_suffix: "offline.local".to_string(),
1982                is_up: false,
1983                is_loopback: false,
1984                dns_servers: Vec::new(),
1985            },
1986        ];
1987
1988        let result = parse_windows_domain_search(Some("search1.com, search2.com"), &adapters);
1989        assert_eq!(
1990            result,
1991            vec![
1992                "global: search1.com, search2.com",
1993                "Wi-Fi: lan.home",
1994                "vEthernet: netbird.cloud"
1995            ]
1996        );
1997    }
1998
1999    #[test]
2000    #[cfg(target_os = "windows")]
2001    fn test_ip_adapter_addresses_layout() {
2002        use std::mem::{offset_of, size_of};
2003
2004        #[repr(C)]
2005        #[allow(non_snake_case)]
2006        struct IpAdapterAddresses {
2007            Length: u32,
2008            IfIndex: u32,
2009            Next: *mut IpAdapterAddresses,
2010            AdapterName: *const i8,
2011            FirstUnicastAddress: *const std::ffi::c_void,
2012            FirstAnycastAddress: *const std::ffi::c_void,
2013            FirstMulticastAddress: *const std::ffi::c_void,
2014            FirstDnsServerAddress: *const std::ffi::c_void,
2015            DnsSuffix: *const u16,
2016            Description: *const u16,
2017            FriendlyName: *const u16,
2018            PhysicalAddress: [u8; 8],
2019            PhysicalAddressLength: u32,
2020            Flags: u32,
2021            Mtu: u32,
2022            IfType: u32,
2023            OperStatus: u32,
2024        }
2025
2026        if cfg!(target_pointer_width = "64") {
2027            assert_eq!(offset_of!(IpAdapterAddresses, Next), 8);
2028            assert_eq!(offset_of!(IpAdapterAddresses, AdapterName), 16);
2029            assert_eq!(offset_of!(IpAdapterAddresses, DnsSuffix), 56);
2030            assert_eq!(offset_of!(IpAdapterAddresses, FriendlyName), 72);
2031            assert_eq!(offset_of!(IpAdapterAddresses, OperStatus), 104);
2032            assert_eq!(size_of::<IpAdapterAddresses>(), 112);
2033        }
2034    }
2035}