Skip to main content

retch_sysinfo/
network.rs

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