Skip to main content

retch_sysinfo/
network.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Network interface detection, IP resolution, Wi-Fi, and related helpers.
5
6use owo_colors::OwoColorize;
7use sysinfo::Networks;
8
9/// Detects the local IP address and active network interface name.
10pub fn detect_active_interface_and_local_ip() -> (Option<String>, Option<String>) {
11    let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
12        .ok()
13        .and_then(|socket| {
14            socket.connect("8.8.8.8:53").ok()?;
15            socket.local_addr().ok().map(|addr| addr.ip().to_string())
16        });
17
18    let active_interface = {
19        #[cfg(target_os = "linux")]
20        {
21            let native_iface = std::fs::read_to_string("/proc/net/route")
22                .ok()
23                .and_then(|content| parse_proc_net_route(&content));
24
25            native_iface.or_else(|| {
26                std::process::Command::new("ip")
27                    .args(["route", "show", "default"])
28                    .output()
29                    .ok()
30                    .and_then(|o| String::from_utf8(o.stdout).ok())
31                    .and_then(|s| {
32                        s.split_whitespace()
33                            .position(|w| w == "dev")
34                            .and_then(|i| s.split_whitespace().nth(i + 1))
35                            .map(|s| s.to_string())
36                    })
37            })
38        }
39        #[cfg(target_os = "macos")]
40        {
41            std::process::Command::new("route")
42                .args(["-n", "get", "default"])
43                .output()
44                .ok()
45                .and_then(|o| String::from_utf8(o.stdout).ok())
46                .and_then(|s| {
47                    s.lines()
48                        .find(|l| l.contains("interface:"))
49                        .and_then(|l| l.split_whitespace().last())
50                        .map(|s| s.to_string())
51                })
52        }
53        #[cfg(target_os = "windows")]
54        {
55            // Identify the active (default-route) interface as the adapter whose
56            // assigned IPs include the outbound `local_ip` we just resolved via the
57            // UDP-connect trick. This avoids spawning PowerShell `Get-NetRoute`,
58            // which costs ~1s of startup on Windows and dominated `--short` runtime.
59            // sysinfo already exposes per-interface IPs on Windows (see
60            // `detect_networks`), so no process spawn or extra API call is needed.
61            local_ip
62                .as_deref()
63                .and_then(|ip| ip.parse::<std::net::IpAddr>().ok())
64                .and_then(|target| {
65                    let networks = Networks::new_with_refreshed_list();
66                    match_active_interface(
67                        networks.iter().map(|(name, data)| {
68                            (
69                                name.to_string(),
70                                data.ip_networks().iter().map(|n| n.addr).collect(),
71                            )
72                        }),
73                        target,
74                    )
75                })
76        }
77        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
78        {
79            None
80        }
81    };
82
83    (local_ip, active_interface)
84}
85
86/// Returns the name of the interface whose assigned IPs include `local_ip`.
87///
88/// Used on Windows to identify the active (default-route) interface without a
89/// slow `Get-NetRoute` PowerShell spawn: the outbound local IP the OS picks to
90/// reach the internet uniquely belongs to the adapter carrying the default
91/// route, so matching it against each adapter's IP set yields the same answer.
92#[cfg(any(target_os = "windows", test))]
93fn match_active_interface(
94    ifaces: impl Iterator<Item = (String, Vec<std::net::IpAddr>)>,
95    local_ip: std::net::IpAddr,
96) -> Option<String> {
97    ifaces
98        .into_iter()
99        .find(|(_, ips)| ips.contains(&local_ip))
100        .map(|(name, _)| name)
101}
102
103/// Fetches the public IP address via an external service (best-effort, 2s timeout).
104pub fn detect_public_ip() -> Option<String> {
105    std::process::Command::new("curl")
106        .args(["-s", "--max-time", "2", "https://api.ipify.org"])
107        .output()
108        .ok()
109        .and_then(|o| String::from_utf8(o.stdout).ok())
110        .map(|s| s.trim().to_string())
111        .filter(|s| !s.is_empty())
112}
113
114/// Builds the formatted list of network interfaces with IP addresses and RX/TX stats.
115pub fn detect_networks(active_interface: Option<&str>, local_ip: Option<&str>) -> Vec<String> {
116    Networks::new_with_refreshed_list()
117        .iter()
118        .map(|(name, data)| {
119            let rx = format_bytes(data.total_received());
120            let tx = format_bytes(data.total_transmitted());
121            let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
122                || data.total_received() > 0
123                || data.total_transmitted() > 0;
124            let status = if is_up {
125                "Up".green().to_string()
126            } else {
127                "Down".red().to_string()
128            };
129
130            let mut ipv4_addresses = Vec::new();
131            let mut ipv6_addresses = Vec::new();
132
133            if is_up {
134                for ip_net in data.ip_networks() {
135                    let ip = ip_net.addr;
136                    let name_lower = name.to_lowercase();
137                    let is_loopback_iface =
138                        name_lower.starts_with("lo") || name_lower.contains("loopback");
139                    if ip.is_loopback() && !is_loopback_iface {
140                        continue;
141                    }
142                    match ip {
143                        std::net::IpAddr::V4(v4) => {
144                            ipv4_addresses.push(v4.to_string());
145                        }
146                        std::net::IpAddr::V6(v6) => {
147                            if !v6.is_unicast_link_local() {
148                                ipv6_addresses.push(v6.to_string());
149                            }
150                        }
151                    }
152                }
153
154                // Fallback to active interface UDP-resolved local IP if no IPs detected by sysinfo
155                if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
156                    if let (Some(active), Some(ip)) = (active_interface, local_ip) {
157                        if name == active {
158                            ipv4_addresses.push(ip.to_string());
159                        }
160                    }
161                }
162            }
163
164            let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
165                let mut combined = Vec::new();
166                if !ipv4_addresses.is_empty() {
167                    combined.push(ipv4_addresses.join(", "));
168                }
169                if !ipv6_addresses.is_empty() {
170                    combined.push(ipv6_addresses.join(", "));
171                }
172                format!(" ({})", combined.join(", "))
173            } else {
174                String::new()
175            };
176
177            format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
178        })
179        .collect()
180}
181
182/// Formats a byte count into human-readable form (KB, MB, GB, etc.)
183pub fn format_bytes(bytes: u64) -> String {
184    const KB: u64 = 1024;
185    const MB: u64 = KB * 1024;
186    const GB: u64 = MB * 1024;
187
188    if bytes >= GB {
189        format!("{:.1} GB", bytes as f64 / GB as f64)
190    } else if bytes >= MB {
191        format!("{:.1} MB", bytes as f64 / MB as f64)
192    } else if bytes >= KB {
193        format!("{:.1} KB", bytes as f64 / KB as f64)
194    } else {
195        format!("{} B", bytes)
196    }
197}
198
199/// Looks up a PCI vendor name from `/usr/share/hwdata/pci.ids` (or fallback paths).
200///
201/// `vendor_id` should be a lowercase hex string without the `0x` prefix.
202pub fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
203    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
204    let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
205    for path in &paths {
206        if let Ok(content) = std::fs::read_to_string(path) {
207            for line in content.lines() {
208                if line.starts_with('#') || line.is_empty() {
209                    continue;
210                }
211                if !line.starts_with('\t') {
212                    let parts: Vec<&str> = line.split_whitespace().collect();
213                    if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
214                        let name = line.strip_prefix(parts[0]).unwrap().trim();
215                        return Some(name.to_string());
216                    }
217                }
218            }
219        }
220    }
221    None
222}
223
224/// Detects the connected Wi-Fi network and link parameters.
225pub fn detect_wifi() -> Option<String> {
226    #[cfg(target_os = "linux")]
227    {
228        let mut wifi_interface = None;
229        if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
230            for entry in entries.filter_map(|e| e.ok()) {
231                let path = entry.path();
232                if path.join("wireless").exists() || path.join("phy80211").exists() {
233                    wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
234                    break;
235                }
236            }
237        }
238
239        if let Some(ref iface) = wifi_interface {
240            if let Ok(output) = std::process::Command::new("iw")
241                .args(["dev", iface, "link"])
242                .output()
243            {
244                if let Ok(stdout) = String::from_utf8(output.stdout) {
245                    let (ssid, links) = parse_iw_link_output(&stdout);
246                    if let Some(s) = ssid {
247                        let card_model = get_wifi_card_model(iface);
248                        let prefix = if let Some(m) = card_model {
249                            format!("{} [{}] - ", m, iface)
250                        } else {
251                            format!("[{}] - ", iface)
252                        };
253
254                        if !links.is_empty() {
255                            let mut link_strs = Vec::new();
256                            for link in links {
257                                let freq_str = link.freq.map(|f| {
258                                    let ghz_mhz = if f >= 1000.0 {
259                                        format!("{:.1} GHz", f / 1000.0)
260                                    } else {
261                                        format!("{} MHz", f)
262                                    };
263                                    if let Some(ch) = freq_to_channel(f) {
264                                        format!("{} ch{}", ghz_mhz, ch)
265                                    } else {
266                                        ghz_mhz
267                                    }
268                                });
269
270                                let mut rx_tx = Vec::new();
271                                if let Some(rx) = link.rx_rate {
272                                    if rx != "0"
273                                        && !rx.starts_with("0 ")
274                                        && rx != "0 Mbps"
275                                        && rx != "0 MBit/s"
276                                    {
277                                        rx_tx.push(format!("↓{}", clean_rate(&rx)));
278                                    }
279                                }
280                                if let Some(tx) = link.tx_rate {
281                                    if tx != "0"
282                                        && !tx.starts_with("0 ")
283                                        && tx != "0 Mbps"
284                                        && tx != "0 MBit/s"
285                                    {
286                                        rx_tx.push(format!("↑{}", clean_rate(&tx)));
287                                    }
288                                }
289
290                                match (freq_str, rx_tx.is_empty()) {
291                                    (Some(f), false) => {
292                                        link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
293                                    }
294                                    (Some(f), true) => link_strs.push(f),
295                                    (None, false) => link_strs.push(rx_tx.join(" ")),
296                                    _ => {}
297                                }
298                            }
299                            if !link_strs.is_empty() {
300                                return Some(format!(
301                                    "{}{}{} ({})",
302                                    prefix,
303                                    s,
304                                    "",
305                                    link_strs.join(", ")
306                                ));
307                            } else {
308                                return Some(format!("{}{}", prefix, s));
309                            }
310                        }
311                        return Some(format!("{}{}", prefix, s));
312                    }
313                }
314            }
315        }
316
317        // Fallback to nmcli (using --rescan no to avoid slow hardware channel scans)
318        if let Ok(output) = std::process::Command::new("nmcli")
319            .args([
320                "-t",
321                "-f",
322                "active,ssid,rate",
323                "device",
324                "wifi",
325                "list",
326                "--rescan",
327                "no",
328            ])
329            .output()
330        {
331            if let Ok(stdout) = String::from_utf8(output.stdout) {
332                for line in stdout.lines() {
333                    let line = line.trim();
334                    if let Some(rest) = line.strip_prefix("yes:") {
335                        if let Some(colon_idx) = rest.rfind(':') {
336                            let ssid = &rest[..colon_idx];
337                            let rate = rest[colon_idx + 1..].trim();
338                            if !ssid.is_empty() {
339                                if !rate.is_empty()
340                                    && rate != "0"
341                                    && !rate.starts_with("0 ")
342                                    && rate != "0 Mbit/s"
343                                    && rate != "0 Mbps"
344                                {
345                                    return Some(format!("{} ({})", ssid, clean_rate(rate)));
346                                } else {
347                                    return Some(ssid.to_string());
348                                }
349                            }
350                        } else if !rest.is_empty() {
351                            return Some(rest.to_string());
352                        }
353                    }
354                }
355            }
356        }
357
358        // Fallback to iwgetid
359        if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
360            if let Ok(stdout) = String::from_utf8(output.stdout) {
361                let ssid = stdout.trim();
362                if !ssid.is_empty() {
363                    return Some(ssid.to_string());
364                }
365            }
366        }
367        None
368    }
369
370    #[cfg(target_os = "macos")]
371    {
372        crate::macos_ffi::get_wifi_info().map(|(ssid, rate)| match rate {
373            Some(r) if r > 0 => format!("{} (↑{} Mbps)", ssid, r),
374            _ => ssid,
375        })
376    }
377
378    #[cfg(target_os = "windows")]
379    {
380        if let Ok(output) = std::process::Command::new("netsh")
381            .args(["wlan", "show", "interfaces"])
382            .output()
383        {
384            if let Ok(stdout) = String::from_utf8(output.stdout) {
385                return parse_netsh_output(&stdout);
386            }
387        }
388        None
389    }
390
391    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
392    {
393        None
394    }
395}
396
397#[cfg(any(target_os = "linux", test))]
398pub fn parse_proc_net_route(content: &str) -> Option<String> {
399    for line in content.lines().skip(1) {
400        let parts: Vec<&str> = line.split_whitespace().collect();
401        if parts.len() >= 8 {
402            let dest = parts[1];
403            let mask = parts[7];
404            if dest == "00000000" && mask == "00000000" {
405                return Some(parts[0].to_string());
406            }
407        }
408    }
409    None
410}
411
412#[allow(
413    clippy::manual_is_multiple_of,
414    clippy::manual_range_contains,
415    dead_code
416)]
417fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
418    let freq = freq_mhz.round() as u32;
419    if freq >= 2412 && freq <= 2472 {
420        Some((freq - 2407) / 5)
421    } else if freq == 2484 {
422        Some(14)
423    } else if freq >= 5160 && freq <= 5885 {
424        if (freq - 5000) % 5 == 0 {
425            Some((freq - 5000) / 5)
426        } else {
427            None
428        }
429    } else if freq >= 5955 && freq <= 7115 {
430        if (freq - 5950) % 5 == 0 {
431            Some((freq - 5950) / 5)
432        } else {
433            None
434        }
435    } else {
436        None
437    }
438}
439
440#[allow(dead_code)]
441fn get_wifi_card_model(iface: &str) -> Option<String> {
442    let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
443    let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
444    let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
445    let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
446
447    let vendor_name = lookup_pci_vendor(&vendor_clean);
448    let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
449
450    match (vendor_name, model_name) {
451        (Some(v), Some(m)) => {
452            let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
453            if m.to_lowercase().contains(&v_clean.to_lowercase())
454                || m.to_lowercase().contains(
455                    &v_clean
456                        .split_whitespace()
457                        .next()
458                        .unwrap_or("")
459                        .to_lowercase(),
460                )
461            {
462                Some(m)
463            } else {
464                Some(format!("{} {}", v_clean, m))
465            }
466        }
467        (None, Some(m)) => Some(m),
468        _ => None,
469    }
470}
471
472#[allow(dead_code)]
473fn clean_rate(rate: &str) -> String {
474    rate.replace("MBit/s", "Mbps")
475        .replace("GBit/s", "Gbps")
476        .replace("Bit/s", "bps")
477}
478
479#[derive(Debug, Clone)]
480pub struct WifiLink {
481    pub freq: Option<f64>,
482    pub rx_rate: Option<String>,
483    pub tx_rate: Option<String>,
484}
485
486#[allow(dead_code)]
487pub fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
488    let mut ssid = None;
489    let mut links = Vec::new();
490    let mut current_link = None;
491
492    for line in stdout.lines() {
493        let trimmed = line.trim();
494        if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
495            if let Some(link) = current_link.take() {
496                links.push(link);
497            }
498            current_link = Some(WifiLink {
499                freq: None,
500                rx_rate: None,
501                tx_rate: None,
502            });
503        } else if trimmed.starts_with("SSID:") {
504            ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
505        } else if trimmed.starts_with("freq:") {
506            if let Some(ref mut link) = current_link {
507                let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
508                link.freq = freq_str.parse::<f64>().ok();
509            }
510        } else if trimmed.starts_with("rx bitrate:") {
511            if let Some(ref mut link) = current_link {
512                let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
513                let rate = rx_str
514                    .split_whitespace()
515                    .take(2)
516                    .collect::<Vec<&str>>()
517                    .join(" ");
518                link.rx_rate = Some(rate);
519            }
520        } else if trimmed.starts_with("tx bitrate:") {
521            if let Some(ref mut link) = current_link {
522                let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
523                let rate = tx_str
524                    .split_whitespace()
525                    .take(2)
526                    .collect::<Vec<&str>>()
527                    .join(" ");
528                link.tx_rate = Some(rate);
529            }
530        }
531    }
532    if let Some(link) = current_link {
533        links.push(link);
534    }
535    (ssid, links)
536}
537
538#[allow(dead_code)]
539pub fn parse_netsh_output(stdout: &str) -> Option<String> {
540    let mut ssid = None;
541    let mut rx = None;
542    let mut tx = None;
543    let mut band = None;
544    for line in stdout.lines() {
545        let trimmed = line.trim();
546        if trimmed.starts_with("SSID") {
547            if let Some(idx) = trimmed.find(':') {
548                let val = trimmed[idx + 1..].trim().to_string();
549                if !val.is_empty() {
550                    ssid = Some(val);
551                }
552            }
553        } else if trimmed.starts_with("Receive rate (Mbps)") {
554            if let Some(idx) = trimmed.find(':') {
555                let val = trimmed[idx + 1..].trim().to_string();
556                if !val.is_empty() {
557                    rx = Some(val);
558                }
559            }
560        } else if trimmed.starts_with("Transmit rate (Mbps)") {
561            if let Some(idx) = trimmed.find(':') {
562                let val = trimmed[idx + 1..].trim().to_string();
563                if !val.is_empty() {
564                    tx = Some(val);
565                }
566            }
567        } else if trimmed.starts_with("Band") {
568            if let Some(idx) = trimmed.find(':') {
569                let val = trimmed[idx + 1..].trim().to_string();
570                if !val.is_empty() {
571                    band = Some(val);
572                }
573            }
574        }
575    }
576    if let Some(s) = ssid {
577        let mut rate_strs = Vec::new();
578        if let Some(rx_val) = rx {
579            if rx_val != "0" {
580                rate_strs.push(format!("↓{} Mbps", rx_val));
581            }
582        }
583        if let Some(tx_val) = tx {
584            if tx_val != "0" {
585                rate_strs.push(format!("↑{} Mbps", tx_val));
586            }
587        }
588        let info = match (band, rate_strs.is_empty()) {
589            (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
590            (Some(b), true) => b,
591            (None, false) => rate_strs.join(" "),
592            _ => String::new(),
593        };
594        if !info.is_empty() {
595            Some(format!("{} ({})", s, info))
596        } else {
597            Some(s)
598        }
599    } else {
600        None
601    }
602}
603
604/// Returns the list of configured DNS nameserver addresses.
605///
606/// Linux/macOS: parses `nameserver` lines from `/etc/resolv.conf`.
607/// Windows: runs PowerShell `Get-DnsClientServerAddress`.
608/// Returns an empty `Vec` if nothing is found.
609pub fn detect_dns() -> Vec<String> {
610    #[cfg(any(target_os = "linux", target_os = "macos"))]
611    {
612        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
613            return parse_resolv_conf(&content);
614        }
615    }
616    #[cfg(target_os = "windows")]
617    {
618        if let Ok(output) = std::process::Command::new("powershell")
619            .args([
620                "-Command",
621                "Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object -ExpandProperty ServerAddresses | Sort-Object -Unique",
622            ])
623            .output()
624        {
625            if let Ok(stdout) = String::from_utf8(output.stdout) {
626                let servers: Vec<String> = stdout
627                    .lines()
628                    .map(|l| l.trim().to_string())
629                    .filter(|l| !l.is_empty())
630                    .collect();
631                if !servers.is_empty() {
632                    return servers;
633                }
634            }
635        }
636    }
637    Vec::new()
638}
639
640#[cfg(any(target_os = "linux", target_os = "macos", test))]
641pub fn parse_resolv_conf(content: &str) -> Vec<String> {
642    content
643        .lines()
644        .filter_map(|line| {
645            let line = line.trim();
646            if line.starts_with('#') || line.starts_with(';') {
647                return None;
648            }
649            let mut parts = line.split_whitespace();
650            if parts.next()? == "nameserver" {
651                parts.next().map(|s| s.to_string())
652            } else {
653                None
654            }
655        })
656        .collect()
657}
658
659/// Returns the configured DNS domain name.
660///
661/// On Linux/macOS, reads the `domain` directive from `/etc/resolv.conf` (takes
662/// precedence), falling back to the first entry of the `search` directive. On
663/// Windows, queries the primary DNS domain via `GetComputerNameExW`
664/// (`ComputerNameDnsDomain`). Returns `None` when no domain is configured (e.g.
665/// a workgroup machine) or the source is unavailable.
666pub fn detect_domain() -> Option<String> {
667    #[cfg(any(target_os = "linux", target_os = "macos"))]
668    {
669        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
670            return parse_domain_from_resolv_conf(&content);
671        }
672        None
673    }
674    #[cfg(target_os = "windows")]
675    {
676        detect_domain_windows()
677    }
678    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
679    {
680        None
681    }
682}
683
684/// Windows: returns the host's primary DNS domain via `GetComputerNameExW`.
685///
686/// Uses the two-call size-probing convention: a first call with a null buffer
687/// reports the required length, the second fills the buffer. A workgroup
688/// (non-domain-joined) host reports an empty DNS domain, which
689/// [`clean_domain`] maps to `None`.
690#[cfg(target_os = "windows")]
691fn detect_domain_windows() -> Option<String> {
692    use std::os::windows::ffi::OsStringExt;
693
694    // COMPUTER_NAME_FORMAT::ComputerNameDnsDomain
695    const COMPUTER_NAME_DNS_DOMAIN: i32 = 2;
696
697    // kernel32 is linked by default on the MSVC target.
698    extern "system" {
699        fn GetComputerNameExW(name_type: i32, lp_buffer: *mut u16, n_size: *mut u32) -> i32;
700    }
701
702    // First call: null buffer + size 0 asks the OS for the required length
703    // (in wide chars, including the trailing NUL). It fails and sets `size`.
704    let mut size: u32 = 0;
705    // SAFETY: the null-buffer / zero-size form is the documented size probe;
706    // the OS only writes the required length back through `size`.
707    unsafe {
708        GetComputerNameExW(COMPUTER_NAME_DNS_DOMAIN, std::ptr::null_mut(), &mut size);
709    }
710    if size == 0 {
711        return None;
712    }
713
714    let mut buf = vec![0u16; size as usize];
715    // SAFETY: `buf` has `size` wide chars of capacity; `size` is passed by
716    // pointer so the OS reports the final length (excluding the NUL). The call
717    // writes at most `size` code units.
718    let ok = unsafe { GetComputerNameExW(COMPUTER_NAME_DNS_DOMAIN, buf.as_mut_ptr(), &mut size) };
719    if ok == 0 {
720        return None;
721    }
722
723    let s = std::ffi::OsString::from_wide(&buf[..size as usize])
724        .to_string_lossy()
725        .into_owned();
726    clean_domain(&s)
727}
728
729/// Trims a raw domain string and maps the empty string to `None`.
730///
731/// A non-domain-joined Windows host reports an empty DNS domain; treat that as
732/// "no domain configured" rather than surfacing an empty value.
733#[cfg(any(target_os = "windows", test))]
734fn clean_domain(raw: &str) -> Option<String> {
735    let trimmed = raw.trim();
736    if trimmed.is_empty() {
737        None
738    } else {
739        Some(trimmed.to_string())
740    }
741}
742
743/// Returns per-interface DNS search domain lists.
744///
745/// On Linux, tries `resolvectl status --no-pager` first and parses per-link
746/// DNS Domain / DNS Search Domains entries. Falls back to the global `search`
747/// list from `/etc/resolv.conf` when resolvectl is unavailable.
748/// On macOS, reads the global `search` list from `/etc/resolv.conf`.
749pub fn detect_domain_search() -> Vec<String> {
750    #[cfg(target_os = "linux")]
751    {
752        if let Ok(output) = std::process::Command::new("resolvectl")
753            .args(["status", "--no-pager"])
754            .output()
755        {
756            if output.status.success() {
757                let text = String::from_utf8_lossy(&output.stdout);
758                let result = parse_resolvectl_search(&text);
759                if !result.is_empty() {
760                    return result;
761                }
762            }
763        }
764        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
765            return parse_search_from_resolv_conf(&content);
766        }
767    }
768    #[cfg(target_os = "macos")]
769    {
770        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
771            return parse_search_from_resolv_conf(&content);
772        }
773    }
774    Vec::new()
775}
776
777/// Parses the `domain` directive (or first `search` entry as fallback) from
778/// `/etc/resolv.conf` content.
779#[cfg(any(target_os = "linux", target_os = "macos", test))]
780pub fn parse_domain_from_resolv_conf(content: &str) -> Option<String> {
781    let mut first_search: Option<String> = None;
782    for line in content.lines() {
783        let line = line.trim();
784        if line.starts_with('#') || line.starts_with(';') {
785            continue;
786        }
787        let mut parts = line.split_whitespace();
788        match parts.next() {
789            Some("domain") => {
790                if let Some(d) = parts.next() {
791                    return Some(d.to_string());
792                }
793            }
794            Some("search") if first_search.is_none() => {
795                if let Some(d) = parts.next() {
796                    first_search = Some(d.to_string());
797                }
798            }
799            _ => {}
800        }
801    }
802    first_search
803}
804
805/// Parses all entries from the `search` directive in `/etc/resolv.conf` content.
806#[cfg(any(target_os = "linux", target_os = "macos", test))]
807pub fn parse_search_from_resolv_conf(content: &str) -> Vec<String> {
808    for line in content.lines() {
809        let line = line.trim();
810        if line.starts_with('#') || line.starts_with(';') {
811            continue;
812        }
813        let mut parts = line.split_whitespace();
814        if parts.next() == Some("search") {
815            let domains: Vec<String> = parts.map(|s| s.to_string()).collect();
816            if !domains.is_empty() {
817                return domains;
818            }
819        }
820    }
821    Vec::new()
822}
823
824/// Parses `resolvectl status --no-pager` output into per-interface search domain strings.
825///
826/// Skips routing-only domains (`~.`). Returns entries like `"wlan0: home.local"`.
827#[cfg(any(target_os = "linux", test))]
828pub fn parse_resolvectl_search(content: &str) -> Vec<String> {
829    let mut results = Vec::new();
830    let mut current_iface: Option<String> = None;
831
832    for line in content.lines() {
833        let trimmed = line.trim();
834        // "Link N (ifname)" starts a new interface section
835        if trimmed.starts_with("Link ") {
836            if let (Some(start), Some(end)) = (trimmed.find('('), trimmed.find(')')) {
837                current_iface = Some(trimmed[start + 1..end].to_string());
838            }
839            continue;
840        }
841        if let Some(ref iface) = current_iface {
842            let domains_str = trimmed
843                .strip_prefix("DNS Domain:")
844                .or_else(|| trimmed.strip_prefix("DNS Search Domains:"))
845                .map(|v| v.trim());
846            if let Some(domains) = domains_str {
847                // Skip routing-only catch-all domain
848                let filtered: Vec<&str> =
849                    domains.split_whitespace().filter(|d| *d != "~.").collect();
850                if !filtered.is_empty() {
851                    results.push(format!("{}: {}", iface, filtered.join(", ")));
852                }
853            }
854        }
855    }
856    results
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862
863    #[test]
864    fn test_clean_domain() {
865        // Normal domain passes through.
866        assert_eq!(
867            clean_domain("corp.example.com"),
868            Some("corp.example.com".to_string())
869        );
870        // Surrounding whitespace is trimmed.
871        assert_eq!(
872            clean_domain("  example.org \n"),
873            Some("example.org".to_string())
874        );
875        // A workgroup host reports an empty domain -> None (not Some("")).
876        assert_eq!(clean_domain(""), None);
877        assert_eq!(clean_domain("   "), None);
878    }
879
880    #[test]
881    fn test_match_active_interface() {
882        use std::net::IpAddr;
883        let target: IpAddr = "192.168.1.50".parse().unwrap();
884        let ifaces = vec![
885            ("lo".to_string(), vec!["127.0.0.1".parse().unwrap()]),
886            (
887                "Ethernet".to_string(),
888                vec!["192.168.1.50".parse().unwrap(), "fe80::1".parse().unwrap()],
889            ),
890            ("Wi-Fi".to_string(), vec!["10.0.0.2".parse().unwrap()]),
891        ];
892        // Matches the adapter that actually holds the outbound local IP.
893        assert_eq!(
894            match_active_interface(ifaces.into_iter(), target),
895            Some("Ethernet".to_string())
896        );
897
898        // No adapter holds the target IP -> None (e.g. offline / unresolved).
899        let orphan: IpAddr = "8.8.8.8".parse().unwrap();
900        let ifaces2 = vec![(
901            "lo".to_string(),
902            vec!["127.0.0.1".parse::<IpAddr>().unwrap()],
903        )];
904        assert_eq!(match_active_interface(ifaces2.into_iter(), orphan), None);
905    }
906
907    #[test]
908    fn test_format_bytes() {
909        assert_eq!(format_bytes(500), "500 B");
910        assert_eq!(format_bytes(1024), "1.0 KB");
911        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
912        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
913        assert_eq!(format_bytes(1536), "1.5 KB");
914    }
915
916    #[test]
917    fn test_parse_proc_net_route() {
918        let sample =
919            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
920                      wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
921                      wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
922        assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
923
924        let sample_no_default =
925            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
926                                 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
927        assert_eq!(parse_proc_net_route(sample_no_default), None);
928    }
929
930    #[test]
931    fn test_parse_netsh_output() {
932        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";
933        assert_eq!(
934            parse_netsh_output(sample),
935            Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
936        );
937    }
938
939    #[test]
940    fn test_parse_iw_link_output() {
941        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";
942        let (ssid, links) = parse_iw_link_output(sample);
943        assert_eq!(ssid, Some("OfficeNet".to_string()));
944        assert_eq!(links.len(), 1);
945        assert_eq!(links[0].freq, Some(6135.0));
946        assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
947        assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
948
949        // MLO multi-link mock output
950        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";
951        let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
952        assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
953        assert_eq!(links_mlo.len(), 2);
954        assert_eq!(links_mlo[0].freq, Some(5180.0));
955        assert_eq!(links_mlo[1].freq, Some(6135.0));
956    }
957
958    #[test]
959    fn test_parse_resolv_conf() {
960        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";
961        assert_eq!(
962            parse_resolv_conf(sample),
963            vec!["192.168.1.1", "8.8.8.8", "2001:db8::1"]
964        );
965
966        let empty = "# no nameservers\nsearch local\n";
967        assert_eq!(parse_resolv_conf(empty), Vec::<String>::new());
968    }
969
970    #[test]
971    fn test_parse_domain_from_resolv_conf_domain_directive() {
972        let s = "# test\ndomain example.com\nsearch fallback.com\nnameserver 1.1.1.1\n";
973        assert_eq!(
974            parse_domain_from_resolv_conf(s),
975            Some("example.com".to_string())
976        );
977    }
978
979    #[test]
980    fn test_parse_domain_from_resolv_conf_search_fallback() {
981        let s = "# no domain directive\nsearch local.lan other.lan\nnameserver 1.1.1.1\n";
982        assert_eq!(
983            parse_domain_from_resolv_conf(s),
984            Some("local.lan".to_string())
985        );
986    }
987
988    #[test]
989    fn test_parse_domain_from_resolv_conf_none() {
990        let s = "# no domain or search\nnameserver 1.1.1.1\n";
991        assert_eq!(parse_domain_from_resolv_conf(s), None);
992    }
993
994    #[test]
995    fn test_parse_search_from_resolv_conf() {
996        let s = "search home.local corp.example.com\nnameserver 1.1.1.1\n";
997        assert_eq!(
998            parse_search_from_resolv_conf(s),
999            vec!["home.local", "corp.example.com"]
1000        );
1001    }
1002
1003    #[test]
1004    fn test_parse_resolvectl_search_basic() {
1005        let sample = "Global\n\
1006            Link 2 (lo)\n\
1007              Current Scopes: none\n\
1008            Link 3 (wlan0)\n\
1009              Current Scopes: DNS\n\
1010              DNS Domain: home.local\n\
1011            Link 4 (eth0)\n\
1012              Current Scopes: DNS\n\
1013              DNS Search Domains: corp.example.com internal.net\n";
1014        let result = parse_resolvectl_search(sample);
1015        assert_eq!(
1016            result,
1017            vec!["wlan0: home.local", "eth0: corp.example.com, internal.net"]
1018        );
1019    }
1020
1021    #[test]
1022    fn test_parse_resolvectl_search_skips_routing_domain() {
1023        let sample = "Link 2 (wlan0)\n  DNS Domain: ~.\nLink 3 (eth0)\n  DNS Domain: corp.net\n";
1024        let result = parse_resolvectl_search(sample);
1025        assert_eq!(result, vec!["eth0: corp.net"]);
1026    }
1027
1028    #[test]
1029    fn test_parse_resolvectl_search_empty() {
1030        let sample = "Global\n  DNS Servers: 1.1.1.1\n";
1031        assert!(parse_resolvectl_search(sample).is_empty());
1032    }
1033}