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
20struct 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#[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 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 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 #[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 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 #[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 "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 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 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 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 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 band = String::new();
666 } else {
667 band = "2.4 GHz".to_string();
668 }
669 }
670 }
671 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 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}