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