Skip to main content

nd_300/diagnostics/
interfaces.rs

1use serde::Serialize;
2use std::collections::HashMap;
3#[cfg(any(target_os = "macos", test))]
4use std::collections::HashSet;
5use sysinfo::Networks;
6
7use super::DiagnosticResult;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct InterfaceInfo {
11    pub name: String,
12    pub mac: String,
13    pub ip_addresses: Vec<String>,
14    pub is_up: bool,
15    pub interface_type: String,
16    pub rx_bytes: u64,
17    pub tx_bytes: u64,
18}
19
20/// Owned, Send-safe per-interface fields extracted from `sysinfo::Networks`
21/// inside the blocking closure, so the (blocking) enumeration runs off the
22/// async runtime while the data crosses back to the async loop below.
23struct RawInterface {
24    name: String,
25    mac_bytes: [u8; 6],
26    ip_addrs: Vec<String>,
27    rx_bytes: u64,
28    tx_bytes: u64,
29}
30
31/// Names which identify the same OS interface. On Windows, `default-net`
32/// exposes the opaque IP Helper `AdapterName` (normally a GUID) as `name`,
33/// while `sysinfo` keys its network rows by the human-readable FriendlyName.
34/// Keep all aliases so the default route is matched to the correct detail row.
35#[derive(Default)]
36struct InterfaceAliases {
37    values: Vec<String>,
38}
39
40impl InterfaceAliases {
41    fn from_default_interface(interface: default_net::Interface) -> Self {
42        let mut values = vec![interface.name];
43        values.extend(interface.friendly_name);
44        values.extend(interface.description);
45        Self { values }
46    }
47
48    fn is_empty(&self) -> bool {
49        self.values.is_empty()
50    }
51
52    fn matches(&self, name: &str) -> bool {
53        self.values
54            .iter()
55            .any(|alias| interface_aliases_match(alias, name))
56    }
57
58    fn identifies_type(&self, interface_type: &str) -> bool {
59        self.values
60            .iter()
61            .any(|alias| detect_interface_type(alias) == interface_type)
62    }
63}
64
65pub async fn check() -> (DiagnosticResult, Vec<InterfaceInfo>) {
66    // `Networks::new_with_refreshed_list` is a synchronous, blocking system
67    // enumeration — the heaviest sync call in the core diagnostics. Run it off
68    // the async runtime and return owned data. A JoinError falls back to an
69    // empty list (same as no interfaces).
70    let (raw_interfaces, default_interface, operational_states): (
71        Vec<RawInterface>,
72        InterfaceAliases,
73        HashMap<String, bool>,
74    ) = tokio::task::spawn_blocking(|| {
75        let networks = Networks::new_with_refreshed_list();
76        let interfaces = networks
77            .iter()
78            .map(|(name, data)| RawInterface {
79                name: name.clone(),
80                mac_bytes: data.mac_address().0,
81                ip_addrs: data
82                    .ip_networks()
83                    .iter()
84                    .map(|n| n.addr.to_string())
85                    .collect(),
86                rx_bytes: data.total_received(),
87                tx_bytes: data.total_transmitted(),
88            })
89            .collect();
90        let os_interfaces = default_net::get_interfaces();
91        let default_interface = default_net::get_default_interface()
92            .ok()
93            .map(InterfaceAliases::from_default_interface)
94            .unwrap_or_default();
95        let mut operational_states = HashMap::new();
96        for interface in os_interfaces {
97            // `default-net` reports native interface flags on Unix. Linux's
98            // IFF_UP is only administrative state, so require IFF_RUNNING as
99            // carrier evidence. Windows synthesizes IFF_UP from OperStatus.
100            let is_up = operational_from_flags(interface.flags, cfg!(target_os = "linux"));
101            insert_operational_alias(&mut operational_states, &interface.name, is_up);
102            if let Some(name) = interface.friendly_name {
103                insert_operational_alias(&mut operational_states, &name, is_up);
104            }
105            if let Some(description) = interface.description {
106                insert_operational_alias(&mut operational_states, &description, is_up);
107            }
108        }
109        (interfaces, default_interface, operational_states)
110    })
111    .await
112    .unwrap_or_default();
113
114    #[cfg(target_os = "macos")]
115    let macos_topology = collect_macos_topology().await;
116
117    let mut details = Vec::new();
118    let mut active_count = 0;
119    let mut wifi_info = String::new();
120
121    for raw in raw_interfaces {
122        let mac = format_mac(raw.mac_bytes);
123        let ip_addrs = raw.ip_addrs;
124        // Traffic counters describe history, not current link state. A quiet
125        // but configured link is still up; conversely a dormant tunnel may
126        // retain counters long after it stopped carrying traffic.
127        #[cfg(target_os = "macos")]
128        let is_up = macos_topology
129            .operational
130            .get(&raw.name)
131            .copied()
132            .or_else(|| {
133                operational_states
134                    .get(&normalize_interface_alias(&raw.name))
135                    .copied()
136            })
137            .unwrap_or_else(|| has_usable_address(&ip_addrs));
138        #[cfg(target_os = "macos")]
139        let is_up = if raw.name.to_ascii_lowercase().starts_with("utun") {
140            // Keep the fail-closed tunnel rule even if ifconfig parsing was
141            // unavailable and the fallback flag source only reported IFF_UP.
142            is_up && macos_topology.active_interfaces.contains(&raw.name)
143        } else {
144            is_up
145        };
146        #[cfg(not(target_os = "macos"))]
147        let is_up = operational_states
148            .get(&normalize_interface_alias(&raw.name))
149            .copied()
150            .unwrap_or_else(|| fallback_operational_state(&ip_addrs));
151
152        #[cfg(target_os = "macos")]
153        let iface_type = macos_topology
154            .hardware_ports
155            .get(&raw.name)
156            .map(|port| classify_hardware_port(port))
157            .unwrap_or_else(|| detect_interface_type(&raw.name));
158        #[cfg(not(target_os = "macos"))]
159        let iface_type = detect_interface_type(&raw.name);
160
161        let is_default = default_interface.matches(&raw.name);
162        #[cfg(target_os = "macos")]
163        let hardware_mapped = macos_topology.hardware_ports.contains_key(&raw.name);
164        #[cfg(not(target_os = "macos"))]
165        let hardware_mapped = false;
166        if is_up
167            && has_non_link_local_address(&ip_addrs)
168            && is_user_uplink(&raw.name, &iface_type, is_default, hardware_mapped)
169        {
170            active_count += 1;
171            if iface_type == "Wi-Fi"
172                && wifi_info.is_empty()
173                && (is_default || default_interface.is_empty())
174            {
175                // Core mode must stay fast and must not invoke the deprecated
176                // private `airport` tool. Rich radio data belongs to the deep
177                // system_profiler-backed Wi-Fi diagnostic.
178                #[cfg(target_os = "macos")]
179                {
180                    wifi_info = "Wi-Fi".to_string();
181                }
182                #[cfg(not(target_os = "macos"))]
183                {
184                    wifi_info = get_wifi_summary().await;
185                }
186            }
187        }
188
189        details.push(InterfaceInfo {
190            name: raw.name,
191            mac,
192            ip_addresses: ip_addrs,
193            is_up,
194            interface_type: iface_type,
195            rx_bytes: raw.rx_bytes,
196            tx_bytes: raw.tx_bytes,
197        });
198    }
199
200    let default_active = details
201        .iter()
202        .find(|interface| interface.is_up && default_interface.matches(&interface.name));
203    let underlying_types: Vec<&str> = details
204        .iter()
205        .filter(|interface| {
206            interface.is_up
207                && has_non_link_local_address(&interface.ip_addresses)
208                && matches!(
209                    interface.interface_type.as_str(),
210                    "Wi-Fi" | "Ethernet" | "Bluetooth"
211                )
212        })
213        .map(|interface| interface.interface_type.as_str())
214        .collect();
215    let default_is_tunnel = default_active
216        .is_some_and(|interface| interface.interface_type == "VPN/Tunnel")
217        || default_interface.identifies_type("VPN/Tunnel");
218    let result = if default_is_tunnel {
219        let description = describe_tunnel_over(&underlying_types);
220        DiagnosticResult::ok("Network", format!("Connected via {description}"))
221    } else if active_count == 0 {
222        DiagnosticResult::fail("Network", "No active network interfaces found")
223    } else if let Some(interface) = default_active {
224        let description = if interface.interface_type == "Wi-Fi" && !wifi_info.is_empty() {
225            wifi_info.clone()
226        } else {
227            interface.interface_type.clone()
228        };
229        DiagnosticResult::ok("Network", format!("Connected via {description}"))
230    } else if !wifi_info.is_empty() {
231        DiagnosticResult::ok("Network", format!("Connected via {}", wifi_info))
232    } else if active_count == 1 {
233        let active = default_active.or_else(|| details.iter().find(|i| i.is_up));
234        let desc = match active {
235            Some(iface) => format!("Connected via {}", iface.interface_type),
236            None => "Connected".to_string(),
237        };
238        DiagnosticResult::ok("Network", desc)
239    } else {
240        DiagnosticResult::ok("Network", format!("{} active interfaces", active_count))
241    };
242
243    (result, details)
244}
245
246fn detect_interface_type(name: &str) -> String {
247    let lower = name.to_lowercase();
248    if lower == "lo" || lower == "lo0" || (lower.starts_with("lo") && lower.len() <= 3) {
249        "Loopback".to_string()
250    } else if lower.contains("tailscale")
251        || lower.contains("wintun")
252        || lower.contains("wireguard")
253        || lower.contains("nordlynx")
254        || lower.contains("anyconnect")
255        || lower.contains("cisco secure")
256        || lower.contains("globalprotect")
257        || lower.contains("pangp")
258        || lower.contains("palo alto")
259        || lower.contains("fortinet")
260        || lower.contains("forticlient")
261        || lower.contains("zscaler")
262        || lower.contains("check point")
263        || lower.contains("checkpoint")
264        || lower.contains("juniper")
265        || lower.contains("zerotier")
266        || lower.contains("hamachi")
267        || lower.contains("openvpn")
268        || lower.contains("vpn")
269        || lower.contains("utun")
270        || lower.contains("tap-windows")
271        || lower.starts_with("tap")
272        || lower.starts_with("tun")
273        || lower.starts_with("wg")
274        || lower.starts_with("ppp")
275    {
276        "VPN/Tunnel".to_string()
277    } else if lower.contains("vethernet")
278        || lower.contains("hyper-v")
279        || lower.contains("hyperv")
280        || lower.contains("default switch")
281        || lower.contains("vmware")
282        || lower.contains("vmnet")
283        || lower.contains("virtualbox")
284        || lower.contains("vbox")
285        || lower.contains("wsl")
286        || lower.contains("docker")
287        || lower.contains("veth")
288        || lower.starts_with("br-")
289    {
290        // Check virtual adapters before the generic Ethernet token: Windows
291        // exposes Hyper-V/WSL links as "vEthernet (...)".
292        "Virtual".to_string()
293    } else if lower.contains("wi-fi")
294        || lower.contains("wifi")
295        || lower.contains("wireless")
296        || lower.contains("wlan")
297        || lower.contains("wlp")
298    {
299        "Wi-Fi".to_string()
300    } else if lower.contains("eth")
301        || lower.contains("enp")
302        || lower.contains("eno")
303        || lower.contains("ethernet")
304        || lower.contains("gbe")
305        || lower.contains("realtek")
306    {
307        "Ethernet".to_string()
308    } else if lower.contains("bluetooth") || lower.contains("bnep") {
309        "Bluetooth".to_string()
310    } else {
311        "Unknown".to_string()
312    }
313}
314
315#[cfg(any(target_os = "macos", target_os = "windows"))]
316fn has_usable_address(addrs: &[String]) -> bool {
317    addrs.iter().any(|addr| {
318        let bare = addr.split('%').next().unwrap_or(addr);
319        bare.parse::<std::net::IpAddr>()
320            .map(|ip| !ip.is_unspecified())
321            .unwrap_or(false)
322    })
323}
324
325fn normalize_interface_alias(name: &str) -> String {
326    let normalized = name.trim().to_ascii_lowercase();
327    let normalized = normalized
328        .strip_prefix(r"\device\tcpip_")
329        .unwrap_or(&normalized);
330    normalized
331        .strip_prefix('{')
332        .and_then(|value| value.strip_suffix('}'))
333        .unwrap_or(normalized)
334        .to_string()
335}
336
337fn interface_aliases_match(left: &str, right: &str) -> bool {
338    normalize_interface_alias(left) == normalize_interface_alias(right)
339}
340
341fn insert_operational_alias(states: &mut HashMap<String, bool>, name: &str, is_up: bool) {
342    states.insert(normalize_interface_alias(name), is_up);
343}
344
345fn operational_from_flags(flags: u32, require_running: bool) -> bool {
346    const IFF_UP: u32 = 0x1;
347    const IFF_RUNNING: u32 = 0x40;
348    flags & IFF_UP != 0 && (!require_running || flags & IFF_RUNNING != 0)
349}
350
351#[cfg(target_os = "linux")]
352fn fallback_operational_state(_addrs: &[String]) -> bool {
353    // An assigned address does not prove carrier. If OS flag enumeration did
354    // not produce this Linux interface, fail closed rather than turning an
355    // administratively-up but unplugged link into an operational one.
356    false
357}
358
359#[cfg(target_os = "windows")]
360fn fallback_operational_state(addrs: &[String]) -> bool {
361    has_usable_address(addrs)
362}
363
364fn describe_tunnel_over(underlying_types: &[&str]) -> String {
365    let mut types = underlying_types.to_vec();
366    types.sort_unstable();
367    types.dedup();
368    match types.as_slice() {
369        [] => "VPN/Tunnel".to_string(),
370        [interface_type] => format!("VPN/Tunnel over {interface_type}"),
371        _ => format!(
372            "VPN/Tunnel over ambiguous active uplinks ({})",
373            types.join(", ")
374        ),
375    }
376}
377
378fn has_non_link_local_address(addrs: &[String]) -> bool {
379    addrs.iter().any(|address| {
380        let bare = address.split('%').next().unwrap_or(address);
381        match bare.parse::<std::net::IpAddr>() {
382            Ok(std::net::IpAddr::V4(ip)) => {
383                !ip.is_unspecified() && !ip.is_loopback() && !ip.is_link_local()
384            }
385            Ok(std::net::IpAddr::V6(ip)) => {
386                !ip.is_unspecified() && !ip.is_loopback() && !ip.is_unicast_link_local()
387            }
388            Err(_) => false,
389        }
390    })
391}
392
393fn is_user_uplink(
394    name: &str,
395    interface_type: &str,
396    is_default: bool,
397    hardware_mapped: bool,
398) -> bool {
399    let lower = name.to_ascii_lowercase();
400    if lower == "bridge0" {
401        // A Thunderbolt Bridge is a real hardware-mapped uplink on macOS even
402        // though its BSD device name resembles a virtual bridge. Count it only
403        // when it actually owns the default route; ordinary software bridges
404        // remain excluded.
405        return hardware_mapped && is_default && interface_type == "Ethernet";
406    }
407    if matches!(lower.as_str(), "lo" | "lo0" | "awdl0" | "llw0" | "ap1")
408        || lower.starts_with("anpi")
409        || lower.starts_with("utun")
410        || lower.starts_with("gif")
411        || lower.starts_with("stf")
412    {
413        return false;
414    }
415    matches!(interface_type, "Wi-Fi" | "Ethernet" | "Bluetooth")
416        || (is_default
417            && interface_type != "Loopback"
418            && interface_type != "Virtual"
419            && interface_type != "VPN/Tunnel")
420}
421
422#[cfg(any(target_os = "macos", test))]
423fn classify_hardware_port(port: &str) -> String {
424    let lower = port.to_ascii_lowercase();
425    if lower.contains("wi-fi") || lower.contains("airport") {
426        "Wi-Fi".to_string()
427    } else if lower.contains("ethernet") || lower.contains("thunderbolt") {
428        "Ethernet".to_string()
429    } else if lower.contains("bluetooth") {
430        "Bluetooth".to_string()
431    } else {
432        "Unknown".to_string()
433    }
434}
435
436#[cfg(target_os = "macos")]
437#[derive(Default)]
438struct MacosTopology {
439    hardware_ports: HashMap<String, String>,
440    operational: HashMap<String, bool>,
441    active_interfaces: HashSet<String>,
442}
443
444#[cfg(target_os = "macos")]
445async fn collect_macos_topology() -> MacosTopology {
446    let mut hardware_cmd = tokio::process::Command::new("/usr/sbin/networksetup");
447    hardware_cmd.arg("-listallhardwareports");
448    let ifconfig_cmd = tokio::process::Command::new("/sbin/ifconfig");
449    let mut nwi_cmd = tokio::process::Command::new("/usr/sbin/scutil");
450    nwi_cmd.arg("--nwi");
451    let mut routes4_cmd = tokio::process::Command::new("/usr/sbin/netstat");
452    routes4_cmd.args(["-rn", "-f", "inet"]);
453    let mut routes6_cmd = tokio::process::Command::new("/usr/sbin/netstat");
454    routes6_cmd.args(["-rn", "-f", "inet6"]);
455    let (hardware, ifconfig, nwi, routes4, routes6) = tokio::join!(
456        super::util::run_with_timeout(hardware_cmd, super::util::QUICK),
457        super::util::run_with_timeout(ifconfig_cmd, super::util::QUICK),
458        super::util::run_with_timeout(nwi_cmd, super::util::QUICK),
459        super::util::run_with_timeout(routes4_cmd, super::util::QUICK),
460        super::util::run_with_timeout(routes6_cmd, super::util::QUICK),
461    );
462
463    let mut active_interfaces = nwi
464        .map(|out| parse_macos_nwi_interfaces(&String::from_utf8_lossy(&out.stdout)))
465        .unwrap_or_default();
466    for output in [routes4, routes6].into_iter().flatten() {
467        active_interfaces.extend(parse_macos_routed_interfaces(&String::from_utf8_lossy(
468            &output.stdout,
469        )));
470    }
471
472    MacosTopology {
473        hardware_ports: hardware
474            .map(|out| parse_macos_hardware_ports(&String::from_utf8_lossy(&out.stdout)))
475            .unwrap_or_default(),
476        operational: ifconfig
477            .map(|out| {
478                parse_macos_operational(&String::from_utf8_lossy(&out.stdout), &active_interfaces)
479            })
480            .unwrap_or_default(),
481        active_interfaces,
482    }
483}
484
485#[cfg(any(target_os = "macos", test))]
486fn parse_macos_hardware_ports(text: &str) -> HashMap<String, String> {
487    let mut ports = HashMap::new();
488    let mut hardware_port: Option<String> = None;
489    for line in text.lines().map(str::trim) {
490        if let Some(port) = line.strip_prefix("Hardware Port:") {
491            hardware_port = Some(port.trim().to_string());
492        } else if let Some(device) = line.strip_prefix("Device:") {
493            if let Some(port) = hardware_port.take() {
494                ports.insert(device.trim().to_string(), port);
495            }
496        } else if line.is_empty() {
497            hardware_port = None;
498        }
499    }
500    ports
501}
502
503#[cfg(any(target_os = "macos", test))]
504fn parse_macos_nwi_interfaces(text: &str) -> HashSet<String> {
505    let mut active = HashSet::new();
506    let mut current: Option<&str> = None;
507    for line in text.lines().map(str::trim) {
508        if let Some(interfaces) = line.strip_prefix("Network interfaces:") {
509            active.extend(interfaces.split_whitespace().map(str::to_string));
510        } else if line.contains(" : flags") || line.contains(": flags") {
511            current = line.split_once(':').map(|(name, _)| name.trim());
512        } else if line.starts_with("reach") && line.contains("Reachable") {
513            if let Some(name) = current {
514                active.insert(name.to_string());
515            }
516        }
517    }
518    active
519}
520
521#[cfg(any(target_os = "macos", test))]
522fn parse_macos_routed_interfaces(text: &str) -> HashSet<String> {
523    text.lines()
524        .filter_map(|line| {
525            let fields: Vec<&str> = line.split_whitespace().collect();
526            (fields.len() >= 4
527                && !matches!(
528                    fields[0],
529                    "Destination" | "Routing" | "Internet:" | "Internet6:"
530                )
531                && meaningful_macos_route(fields[0], fields[1]))
532            .then(|| fields[3].to_string())
533        })
534        .collect()
535}
536
537#[cfg(any(target_os = "macos", test))]
538fn meaningful_macos_route(destination: &str, gateway: &str) -> bool {
539    let destination = destination.to_ascii_lowercase();
540    let gateway = gateway.to_ascii_lowercase();
541
542    if destination == "default" {
543        // macOS installs interface-scoped IPv6 defaults on dormant system
544        // tunnels. A link-local gateway alone is not positive tunnel evidence.
545        return !gateway.starts_with("fe80::");
546    }
547
548    !(destination.starts_with("fe80")
549        || destination.starts_with("ff")
550        || destination.starts_with("169.254")
551        || destination.starts_with("::1")
552        || destination.starts_with("link#"))
553}
554
555#[cfg(any(target_os = "macos", test))]
556fn parse_macos_operational(
557    text: &str,
558    active_interfaces: &HashSet<String>,
559) -> HashMap<String, bool> {
560    let mut states = HashMap::new();
561    let mut current: Option<String> = None;
562    let mut flags_up = false;
563    let mut explicit_status: Option<bool> = None;
564    let mut has_address = false;
565
566    let flush = |states: &mut HashMap<String, bool>,
567                 current: &mut Option<String>,
568                 flags_up: bool,
569                 explicit_status: Option<bool>,
570                 has_address: bool,
571                 active_interfaces: &HashSet<String>| {
572        if let Some(name) = current.take() {
573            let mut operational = flags_up && explicit_status.unwrap_or(has_address);
574            if name.to_ascii_lowercase().starts_with("utun") {
575                // Darwin retains dormant utun devices with UP/RUNNING and a
576                // link-local address. Require routing or NetworkInformation
577                // reachability evidence before calling one operational.
578                operational &= active_interfaces.contains(&name);
579            }
580            states.insert(name, operational);
581        }
582    };
583
584    for line in text.lines() {
585        if !line.starts_with(char::is_whitespace) && line.contains(": flags=") {
586            flush(
587                &mut states,
588                &mut current,
589                flags_up,
590                explicit_status,
591                has_address,
592                active_interfaces,
593            );
594            current = line.split_once(':').map(|(name, _)| name.to_string());
595            flags_up = line
596                .split_once('<')
597                .and_then(|(_, rest)| rest.split_once('>'))
598                .is_some_and(|(flags, _)| flags.split(',').any(|flag| flag == "UP"));
599            explicit_status = None;
600            has_address = false;
601        } else {
602            let trimmed = line.trim();
603            if let Some(status) = trimmed.strip_prefix("status:") {
604                explicit_status = Some(status.trim().eq_ignore_ascii_case("active"));
605            } else if trimmed.starts_with("inet ") || trimmed.starts_with("inet6 ") {
606                has_address = true;
607            }
608        }
609    }
610    flush(
611        &mut states,
612        &mut current,
613        flags_up,
614        explicit_status,
615        has_address,
616        active_interfaces,
617    );
618    states
619}
620
621fn format_mac(bytes: [u8; 6]) -> String {
622    if bytes == [0, 0, 0, 0, 0, 0] {
623        return "N/A".to_string();
624    }
625    format!(
626        "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
627        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
628    )
629}
630
631#[cfg(not(target_os = "macos"))]
632async fn get_wifi_summary() -> String {
633    #[cfg(windows)]
634    {
635        let mut cmd = tokio::process::Command::new("netsh");
636        cmd.args(["wlan", "show", "interfaces"]);
637        match super::util::run_with_timeout(cmd, super::util::QUICK).await {
638            Some(output) => {
639                let text = String::from_utf8_lossy(&output.stdout);
640                let mut band = String::new();
641                let mut ssid = String::new();
642
643                for line in text.lines() {
644                    let line = line.trim();
645                    if line.starts_with("SSID")
646                        && !line.starts_with("SSID B")
647                        && !line.starts_with("SSID name")
648                    {
649                        if let Some(val) = line.split(':').nth(1) {
650                            ssid = val.trim().to_string();
651                        }
652                    }
653                    if line.starts_with("Radio type") || line.starts_with("Band") {
654                        if let Some(val) = line.split(':').nth(1) {
655                            let val = val.trim();
656                            if val.contains("6 GHz") || val.contains("6E") {
657                                band = "6 GHz".to_string();
658                            } else if val.contains("5 GHz")
659                                || val.contains("802.11a")
660                                || val.contains("802.11ac")
661                            {
662                                band = "5 GHz".to_string();
663                            } else if val.contains("802.11ax") {
664                                // 802.11ax can be 2.4/5/6 GHz; leave band empty to rely on channel
665                                band = String::new();
666                            } else {
667                                band = "2.4 GHz".to_string();
668                            }
669                        }
670                    }
671                    // Also check "Channel" for band detection fallback
672                    if line.starts_with("Channel") {
673                        if let Some(val) = line.split(':').nth(1) {
674                            if let Ok(ch) = val.trim().parse::<u32>() {
675                                if band.is_empty() {
676                                    if ch > 14 && ch <= 177 {
677                                        band = "5 GHz".to_string();
678                                    } else if ch <= 14 {
679                                        band = "2.4 GHz".to_string();
680                                    }
681                                }
682                            }
683                        }
684                    }
685                }
686
687                if !ssid.is_empty() {
688                    if !band.is_empty() {
689                        format!("Wi-Fi ({}) - {}", band, ssid)
690                    } else {
691                        format!("Wi-Fi - {}", ssid)
692                    }
693                } else {
694                    "Wi-Fi".to_string()
695                }
696            }
697            None => "Wi-Fi".to_string(),
698        }
699    }
700
701    #[cfg(target_os = "linux")]
702    {
703        let mut cmd = tokio::process::Command::new("iwgetid");
704        cmd.args(["-r"]);
705        match super::util::run_with_timeout(cmd, super::util::QUICK).await {
706            Some(output) => {
707                let ssid = String::from_utf8_lossy(&output.stdout).trim().to_string();
708                // Try to get frequency
709                let mut freq_cmd = tokio::process::Command::new("iwgetid");
710                freq_cmd.args(["--freq", "-r"]);
711                let band = match super::util::run_with_timeout(freq_cmd, super::util::QUICK).await {
712                    Some(freq_out) => {
713                        let freq_str = String::from_utf8_lossy(&freq_out.stdout).trim().to_string();
714                        if let Ok(freq) = freq_str.parse::<f64>() {
715                            if freq > 5.0 {
716                                "5 GHz".to_string()
717                            } else if freq > 2.0 {
718                                "2.4 GHz".to_string()
719                            } else {
720                                String::new()
721                            }
722                        } else {
723                            String::new()
724                        }
725                    }
726                    None => String::new(),
727                };
728
729                if !ssid.is_empty() {
730                    if !band.is_empty() {
731                        format!("Wi-Fi ({}) - {}", band, ssid)
732                    } else {
733                        format!("Wi-Fi - {}", ssid)
734                    }
735                } else {
736                    "Wi-Fi".to_string()
737                }
738            }
739            None => "Wi-Fi".to_string(),
740        }
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    #[test]
749    fn macos_hardware_ports_identify_en0_wifi() {
750        let fixture = "Hardware Port: Ethernet Adapter (en3)\nDevice: en3\n\nHardware Port: Wi-Fi\nDevice: en0\n\nVLAN Configurations\n";
751        let ports = parse_macos_hardware_ports(fixture);
752        assert_eq!(ports.get("en0").map(String::as_str), Some("Wi-Fi"));
753        assert_eq!(classify_hardware_port(&ports["en0"]), "Wi-Fi");
754        assert_eq!(classify_hardware_port(&ports["en3"]), "Ethernet");
755    }
756
757    #[test]
758    fn macos_operational_uses_status_not_historical_traffic() {
759        let fixture = "en0: flags=8863<UP,BROADCAST,RUNNING> mtu 1500\n\tinet 10.1.0.91 netmask 0xffffff00\n\tstatus: active\nen3: flags=8863<UP,BROADCAST,RUNNING> mtu 1500\n\tstatus: inactive\nutun0: flags=8051<UP,POINTOPOINT,RUNNING> mtu 1380\n\tinet6 fe80::1%utun0 prefixlen 64\nutun7: flags=8051<UP,POINTOPOINT,RUNNING> mtu 1380\n\tinet6 fe80::2%utun7 prefixlen 64\n";
760        let active = ["utun7".to_string()].into_iter().collect();
761        let states = parse_macos_operational(fixture, &active);
762        assert_eq!(states.get("en0"), Some(&true));
763        assert_eq!(states.get("en3"), Some(&false));
764        assert_eq!(states.get("utun0"), Some(&false));
765        assert_eq!(states.get("utun7"), Some(&true));
766    }
767
768    #[test]
769    fn macos_nwi_and_routes_exclude_dormant_link_local_tunnels() {
770        let nwi = "IPv4 network interface information\n     en0 : flags : 0x5 (IPv4,DNS)\n           reach : 0x00000002 (Reachable)\nNetwork interfaces: en0 utun7\n";
771        let mut active = parse_macos_nwi_interfaces(nwi);
772        let routes = "Routing tables\n\nInternet:\nDestination Gateway Flags Netif Expire\ndefault link#20 UCS utun7\n100.64/10 link#21 UCS utun8 !\nInternet6:\nDestination Gateway Flags Netif Expire\ndefault fe80::%utun0 UGcIg utun0\nfe80::%utun0/64 fe80::1 UcI utun0\nff00::/8 ::1 UmCI utun1\n";
773        active.extend(parse_macos_routed_interfaces(routes));
774        assert!(active.contains("en0"));
775        assert!(active.contains("utun7"));
776        assert!(active.contains("utun8"));
777        assert!(!active.contains("utun0"));
778        assert!(!active.contains("utun1"));
779    }
780
781    #[test]
782    fn active_total_excludes_apple_peer_and_dormant_tunnels() {
783        assert!(is_user_uplink("en0", "Wi-Fi", true, true));
784        assert!(!is_user_uplink("awdl0", "Unknown", false, false));
785        assert!(!is_user_uplink("utun7", "VPN/Tunnel", true, false));
786        assert!(!is_user_uplink("lo0", "Loopback", false, false));
787        assert!(!has_non_link_local_address(&["fe80::1".to_string()]));
788        assert!(has_non_link_local_address(&["10.1.0.91".to_string()]));
789        assert!(has_non_link_local_address(&[
790            "fd48:7b1c:8406::1".to_string()
791        ]));
792    }
793
794    #[test]
795    fn hardware_mapped_default_thunderbolt_bridge_is_a_physical_uplink() {
796        assert_eq!(classify_hardware_port("Thunderbolt Bridge"), "Ethernet");
797        assert!(is_user_uplink("bridge0", "Ethernet", true, true));
798        assert!(!is_user_uplink("bridge0", "Ethernet", false, true));
799        assert!(!is_user_uplink("bridge0", "Ethernet", true, false));
800    }
801
802    #[test]
803    fn windows_default_adapter_aliases_match_friendly_sysinfo_name() {
804        let aliases = InterfaceAliases {
805            values: vec![
806                "{89ABCDEF-0123-4567-89AB-CDEF01234567}".to_string(),
807                "Wi-Fi".to_string(),
808                "Intel(R) Wi-Fi 7 BE200".to_string(),
809            ],
810        };
811        assert!(aliases.matches("wi-fi"));
812        assert!(aliases.matches(r"\DEVICE\TCPIP_{89ABCDEF-0123-4567-89AB-CDEF01234567}"));
813        assert!(!aliases.matches("Ethernet"));
814
815        let details = [
816            InterfaceInfo {
817                name: "Ethernet".to_string(),
818                mac: "N/A".to_string(),
819                ip_addresses: vec!["192.0.2.2".to_string()],
820                is_up: true,
821                interface_type: "Ethernet".to_string(),
822                rx_bytes: 0,
823                tx_bytes: 0,
824            },
825            InterfaceInfo {
826                name: "Wi-Fi".to_string(),
827                mac: "N/A".to_string(),
828                ip_addresses: vec!["192.0.2.3".to_string()],
829                is_up: true,
830                interface_type: "Wi-Fi".to_string(),
831                rx_bytes: 0,
832                tx_bytes: 0,
833            },
834        ];
835        let selected = details
836            .iter()
837            .find(|interface| interface.is_up && aliases.matches(&interface.name));
838        assert_eq!(
839            selected.map(|interface| interface.name.as_str()),
840            Some("Wi-Fi")
841        );
842    }
843
844    #[test]
845    fn windows_virtual_and_vpn_adapters_are_not_physical_uplinks() {
846        for name in [
847            "vEthernet (Default Switch)",
848            "vEthernet (WSL)",
849            "Hyper-V Virtual Ethernet Adapter",
850            "VMware Network Adapter VMnet8",
851            "VirtualBox Host-Only Network",
852            "DockerNAT",
853        ] {
854            assert_eq!(detect_interface_type(name), "Virtual", "{name}");
855            assert!(!is_user_uplink(name, "Virtual", true, false), "{name}");
856        }
857
858        for name in [
859            "Tailscale",
860            "Wintun Userspace Tunnel",
861            "WireGuard Tunnel",
862            "NordLynx Tunnel",
863            "TAP-Windows Adapter V9",
864            "OpenVPN Data Channel Offload",
865            "Cisco AnyConnect Secure Mobility Client Connection",
866            "PANGP Virtual Ethernet Adapter #2",
867            "GlobalProtect",
868            "Fortinet SSL VPN Virtual Ethernet Adapter",
869        ] {
870            assert_eq!(detect_interface_type(name), "VPN/Tunnel", "{name}");
871            assert!(!is_user_uplink(name, "VPN/Tunnel", true, false), "{name}");
872        }
873
874        for name in [
875            "Ethernet",
876            "Intel(R) Ethernet Connection",
877            "Realtek PCIe GbE Family Controller",
878        ] {
879            assert_eq!(detect_interface_type(name), "Ethernet", "{name}");
880            assert!(is_user_uplink(name, "Ethernet", false, false), "{name}");
881        }
882    }
883
884    #[test]
885    fn operational_flags_are_independent_of_traffic_and_addresses() {
886        assert!(operational_from_flags(0x1, false));
887        assert!(operational_from_flags(0x8863, false));
888        assert!(!operational_from_flags(0, false));
889        assert!(!operational_from_flags(0x1, true));
890        assert!(operational_from_flags(0x1 | 0x40, true));
891    }
892
893    #[test]
894    fn tunnel_summary_retains_underlying_uplink() {
895        assert_eq!(describe_tunnel_over(&["Wi-Fi"]), "VPN/Tunnel over Wi-Fi");
896        assert_eq!(describe_tunnel_over(&[]), "VPN/Tunnel");
897        assert_eq!(
898            describe_tunnel_over(&["Wi-Fi", "Ethernet"]),
899            "VPN/Tunnel over ambiguous active uplinks (Ethernet, Wi-Fi)"
900        );
901    }
902}