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