1use serde::Serialize;
2
3use super::shared_cache::SharedCache;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct VpnAdapter {
7 pub name: String,
8 pub adapter_type: String,
9 pub status: String,
10 pub ip_address: Option<String>,
11 pub vendor: Option<String>,
12 pub is_enterprise: bool,
13 pub interface_name: Option<String>,
14}
15
16pub async fn collect_with_cache(cache: &SharedCache) -> Option<Vec<VpnAdapter>> {
17 let mut vpns = Vec::new();
18
19 #[cfg(windows)]
20 {
21 if let Some(ref ic) = cache.ipconfig {
22 parse_vpn_from_ipconfig(&ic.raw, &mut vpns);
23 } else {
24 collect_windows_ipconfig(&mut vpns).await;
25 }
26 collect_windows_wmi(&mut vpns).await;
27 }
28
29 #[cfg(target_os = "macos")]
30 {
31 let _ = cache;
32 let active_interfaces = collect_macos_active_interfaces().await;
33 collect_macos_ifconfig(&mut vpns, &active_interfaces).await;
34 collect_macos_scutil(&mut vpns).await;
35 }
36
37 #[cfg(target_os = "linux")]
38 {
39 let _ = cache;
40 collect_linux_ip_link(&mut vpns).await;
41 collect_linux_nmcli(&mut vpns).await;
42 collect_linux_wireguard(&mut vpns).await;
43 }
44
45 vpns.dedup_by(|a, b| {
46 if let (Some(ref ai), Some(ref bi)) = (&a.interface_name, &b.interface_name) {
47 ai == bi
48 } else {
49 a.name == b.name
50 }
51 });
52
53 if vpns.is_empty() {
54 None
55 } else {
56 Some(vpns)
57 }
58}
59
60pub async fn collect() -> Option<Vec<VpnAdapter>> {
61 let mut vpns = Vec::new();
62
63 #[cfg(windows)]
64 {
65 collect_windows_ipconfig(&mut vpns).await;
66 collect_windows_wmi(&mut vpns).await;
67 }
68
69 #[cfg(target_os = "macos")]
70 {
71 let active_interfaces = collect_macos_active_interfaces().await;
72 collect_macos_ifconfig(&mut vpns, &active_interfaces).await;
73 collect_macos_scutil(&mut vpns).await;
74 }
75
76 #[cfg(target_os = "linux")]
77 {
78 collect_linux_ip_link(&mut vpns).await;
79 collect_linux_nmcli(&mut vpns).await;
80 collect_linux_wireguard(&mut vpns).await;
81 }
82
83 vpns.dedup_by(|a, b| {
85 if let (Some(ref ai), Some(ref bi)) = (&a.interface_name, &b.interface_name) {
86 ai == bi
87 } else {
88 a.name == b.name
89 }
90 });
91
92 if vpns.is_empty() {
93 None
94 } else {
95 Some(vpns)
96 }
97}
98
99#[cfg(windows)]
102fn parse_vpn_from_ipconfig(text: &str, vpns: &mut Vec<VpnAdapter>) {
103 let mut current_name = String::new();
104 let mut current_ip = None;
105
106 for line in text.lines() {
107 if !line.starts_with(' ') && !line.starts_with('\t') && line.contains("adapter") {
108 let name = line.trim().trim_end_matches(':');
109 let lower = name.to_lowercase();
110 if lower.contains("vpn")
111 || lower.contains("tap")
112 || lower.contains("tun")
113 || lower.contains("wireguard")
114 || lower.contains("wintun")
115 || lower.contains("fortinet")
116 || lower.contains("cisco")
117 || lower.contains("palo alto")
118 || lower.contains("global protect")
119 || lower.contains("nordlynx")
120 || lower.contains("expressvpn")
121 || lower.contains("mullvad")
122 || lower.contains("tailscale")
123 || lower.contains("zscaler")
124 || lower.contains("pulse")
125 {
126 if !current_name.is_empty() {
127 let vendor = detect_vendor(¤t_name);
128 let is_enterprise = is_enterprise_vendor(¤t_name, vendor.as_deref());
129 vpns.push(VpnAdapter {
130 name: current_name.clone(),
131 adapter_type: detect_vpn_type(¤t_name),
132 status: if current_ip.is_some() {
133 "Connected"
134 } else {
135 "Disconnected"
136 }
137 .to_string(),
138 ip_address: current_ip.take(),
139 vendor,
140 is_enterprise,
141 interface_name: None,
142 });
143 }
144 current_name = name.to_string();
145 current_ip = None;
146 } else {
147 current_name.clear();
148 }
149 } else if !current_name.is_empty() {
150 let trimmed = line.trim();
151 if trimmed.contains("IPv4 Address")
152 || (trimmed.contains("IP Address") && !trimmed.contains("Autoconfiguration"))
153 {
154 current_ip = trimmed
155 .split(':')
156 .nth(1)
157 .map(|s| s.trim().trim_end_matches("(Preferred)").trim().to_string());
158 }
159 }
160 }
161
162 if !current_name.is_empty() {
163 let vendor = detect_vendor(¤t_name);
164 let is_enterprise = is_enterprise_vendor(¤t_name, vendor.as_deref());
165 vpns.push(VpnAdapter {
166 name: current_name.clone(),
167 adapter_type: detect_vpn_type(¤t_name),
168 status: if current_ip.is_some() {
169 "Connected"
170 } else {
171 "Disconnected"
172 }
173 .to_string(),
174 ip_address: current_ip,
175 vendor,
176 is_enterprise,
177 interface_name: None,
178 });
179 }
180}
181
182#[cfg(windows)]
183async fn collect_windows_ipconfig(vpns: &mut Vec<VpnAdapter>) {
184 let mut cmd = tokio::process::Command::new("ipconfig");
185 cmd.args(["/all"]);
186 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
187 let text = String::from_utf8_lossy(&output.stdout);
188 parse_vpn_from_ipconfig(&text, vpns);
189 }
190}
191
192#[cfg(windows)]
193async fn collect_windows_wmi(vpns: &mut Vec<VpnAdapter>) {
194 use std::collections::HashMap;
195 use wmi::{COMLibrary, WMIConnection};
196
197 let wmi_rows: Vec<(String, Option<String>, u16)> = tokio::task::spawn_blocking(|| {
199 let com = match COMLibrary::new() {
200 Ok(c) => c,
201 Err(_) => return Vec::new(),
202 };
203 let wmi = match WMIConnection::new(com) {
204 Ok(w) => w,
205 Err(_) => return Vec::new(),
206 };
207
208 let query = r#"SELECT Name, NetConnectionID, Description, NetConnectionStatus FROM Win32_NetworkAdapter WHERE Description LIKE '%TAP%' OR Description LIKE '%TUN%' OR Description LIKE '%Wintun%' OR Description LIKE '%WireGuard%' OR Description LIKE '%VPN%' OR Description LIKE '%NordLynx%' OR Description LIKE '%ExpressVPN%' OR Description LIKE '%Tailscale%'"#;
209 let results: Vec<HashMap<String, wmi::Variant>> = wmi.raw_query(query).unwrap_or_default();
210
211 results.into_iter().filter_map(|row| {
212 let description = match row.get("Description") {
213 Some(wmi::Variant::String(s)) => s.clone(),
214 _ => return None,
215 };
216 let net_id = match row.get("NetConnectionID") {
217 Some(wmi::Variant::String(s)) => Some(s.clone()),
218 _ => None,
219 };
220 let status_val = match row.get("NetConnectionStatus") {
221 Some(wmi::Variant::UI2(n)) => *n,
222 Some(wmi::Variant::I4(n)) => *n as u16,
223 _ => 0,
224 };
225 Some((description, net_id, status_val))
226 }).collect()
227 })
228 .await
229 .unwrap_or_default();
230
231 for (description, net_id, status_val) in wmi_rows {
232 let name_for_check = net_id.clone().unwrap_or_else(|| description.clone());
234 if vpns
235 .iter()
236 .any(|v| v.name == name_for_check || v.name == description)
237 {
238 continue;
239 }
240
241 let vendor = detect_vendor(&description);
242 let is_enterprise = is_enterprise_vendor(&description, vendor.as_deref());
243
244 vpns.push(VpnAdapter {
245 name: name_for_check,
246 adapter_type: detect_vpn_type(&description),
247 status: if status_val == 2 {
248 "Connected"
249 } else {
250 "Disconnected"
251 }
252 .to_string(),
253 ip_address: None,
254 vendor,
255 is_enterprise,
256 interface_name: net_id,
257 });
258 }
259}
260
261#[cfg(target_os = "macos")]
264async fn collect_macos_active_interfaces() -> std::collections::HashSet<String> {
265 let mut cmd = tokio::process::Command::new("/usr/sbin/scutil");
266 cmd.arg("--nwi");
267 let mut routes4 = tokio::process::Command::new("netstat");
268 routes4.args(["-rn", "-f", "inet"]);
269 let mut routes6 = tokio::process::Command::new("netstat");
270 routes6.args(["-rn", "-f", "inet6"]);
271 let (nwi, routes4, routes6) = tokio::join!(
272 super::util::run_with_timeout(cmd, super::util::QUICK),
273 super::util::run_with_timeout(routes4, super::util::QUICK),
274 super::util::run_with_timeout(routes6, super::util::QUICK),
275 );
276 let mut active = nwi
277 .map(|output| parse_macos_nwi(&String::from_utf8_lossy(&output.stdout)))
278 .unwrap_or_default();
279 for output in [routes4, routes6].into_iter().flatten() {
280 active.extend(parse_macos_route_interfaces(&String::from_utf8_lossy(
281 &output.stdout,
282 )));
283 }
284 active
285}
286
287#[cfg(any(target_os = "macos", test))]
288fn parse_macos_nwi(text: &str) -> std::collections::HashSet<String> {
289 let mut active = std::collections::HashSet::new();
290 for line in text.lines().map(str::trim) {
291 if let Some(interfaces) = line.strip_prefix("Network interfaces:") {
292 active.extend(interfaces.split_whitespace().map(str::to_string));
293 } else if line.contains(": flags") && line.contains("Reachable") {
294 if let Some((name, _)) = line.split_once(':') {
295 active.insert(name.trim().to_string());
296 }
297 }
298 }
299 active
300}
301
302#[cfg(any(target_os = "macos", test))]
303fn parse_macos_route_interfaces(text: &str) -> std::collections::HashSet<String> {
304 text.lines()
305 .filter_map(|line| {
306 let fields: Vec<&str> = line.split_whitespace().collect();
307 (fields.len() >= 4
308 && !matches!(
309 fields[0],
310 "Destination" | "Routing" | "Internet:" | "Internet6:"
311 )
312 && meaningful_macos_route(fields[0], fields[1], fields[2]))
313 .then(|| fields[3].to_string())
314 })
315 .collect()
316}
317
318#[cfg(any(target_os = "macos", test))]
319fn meaningful_macos_route(destination: &str, gateway: &str, _flags: &str) -> bool {
320 let destination = destination.to_ascii_lowercase();
321 let gateway = gateway.to_ascii_lowercase();
322
323 if destination == "default" {
324 return !gateway.starts_with("fe80::");
329 }
330
331 !(destination.starts_with("fe80")
332 || destination.starts_with("ff")
333 || destination.starts_with("169.254")
334 || destination.starts_with("::1")
335 || destination.starts_with("link#"))
336}
337
338#[cfg(target_os = "macos")]
339async fn collect_macos_ifconfig(
340 vpns: &mut Vec<VpnAdapter>,
341 active_interfaces: &std::collections::HashSet<String>,
342) {
343 let cmd = tokio::process::Command::new("ifconfig");
344 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
345 let text = String::from_utf8_lossy(&output.stdout);
346 vpns.extend(parse_macos_ifconfig(&text, active_interfaces));
347 }
348}
349
350#[cfg(any(target_os = "macos", test))]
351fn parse_macos_ifconfig(
352 text: &str,
353 active_interfaces: &std::collections::HashSet<String>,
354) -> Vec<VpnAdapter> {
355 let mut rows = Vec::new();
356 let mut current_iface = String::new();
357 let mut current_ip: Option<String> = None;
358
359 let flush = |rows: &mut Vec<VpnAdapter>, iface: &str, ip: Option<String>| {
360 if !is_vpn_interface(iface) {
361 return;
362 }
363 if !active_interfaces.contains(iface) {
368 return;
369 }
370 let vendor = detect_vendor(iface);
371 let is_enterprise = is_enterprise_vendor(iface, vendor.as_deref());
372 rows.push(VpnAdapter {
373 name: iface.to_string(),
374 adapter_type: detect_vpn_type(iface),
375 status: "Connected".to_string(),
376 ip_address: ip,
377 vendor,
378 is_enterprise,
379 interface_name: Some(iface.to_string()),
380 });
381 };
382
383 for line in text.lines() {
384 if !line.starts_with(char::is_whitespace) {
385 flush(&mut rows, ¤t_iface, current_ip.take());
386 current_iface = line
387 .split_once(':')
388 .map(|(name, _)| name)
389 .unwrap_or("")
390 .to_string();
391 } else {
392 let trimmed = line.trim();
393 if let Some(value) = trimmed.strip_prefix("inet ") {
394 let address = value.split_whitespace().next().unwrap_or("");
395 if !address.starts_with("127.") && !address.is_empty() {
396 current_ip = Some(address.to_string());
397 }
398 } else if let Some(value) = trimmed.strip_prefix("inet6 ") {
399 let address = value.split_whitespace().next().unwrap_or("");
400 if !address.starts_with("fe80:")
401 && address != "::1"
402 && !address.is_empty()
403 && current_ip.is_none()
404 {
405 current_ip = Some(address.to_string());
406 }
407 }
408 }
409 }
410 flush(&mut rows, ¤t_iface, current_ip);
411 rows
412}
413
414#[cfg(target_os = "macos")]
415async fn collect_macos_scutil(vpns: &mut Vec<VpnAdapter>) {
416 let mut cmd = tokio::process::Command::new("scutil");
417 cmd.args(["--nc", "list"]);
418 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
419 let text = String::from_utf8_lossy(&output.stdout);
420 for row in parse_macos_scutil(&text) {
421 if !vpns.iter().any(|vpn| vpn.name == row.name) {
423 vpns.push(row);
424 }
425 }
426 }
427}
428
429#[cfg(any(target_os = "macos", test))]
430fn parse_macos_scutil(text: &str) -> Vec<VpnAdapter> {
431 text.lines()
432 .filter_map(|line| {
433 let trimmed = line.trim();
436 let status = if trimmed.contains("(Connected)") {
437 "Connected"
438 } else if trimmed.contains("(Disconnected)") {
439 "Disconnected"
440 } else {
441 return None;
442 };
443
444 let (start, end) = (trimmed.find('"')?, trimmed.rfind('"')?);
445 (start < end).then(|| {
446 let name = &trimmed[start + 1..end];
447 let adapter_type = trimmed
448 .rfind('[')
449 .zip(trimmed.rfind(']'))
450 .filter(|(open, close)| open < close)
451 .map(|(open, close)| trimmed[open + 1..close].to_string())
452 .unwrap_or_else(|| "VPN".to_string());
453 let vendor = detect_vendor(name);
454 let is_enterprise = is_enterprise_vendor(name, vendor.as_deref());
455 VpnAdapter {
456 name: name.to_string(),
457 adapter_type,
458 status: status.to_string(),
459 ip_address: None,
460 vendor,
461 is_enterprise,
462 interface_name: None,
463 }
464 })
465 })
466 .collect()
467}
468
469#[cfg(target_os = "linux")]
472async fn collect_linux_ip_link(vpns: &mut Vec<VpnAdapter>) {
473 let mut cmd = tokio::process::Command::new("ip");
474 cmd.args(["link", "show"]);
475 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
476 let text = String::from_utf8_lossy(&output.stdout);
477 for line in text.lines() {
478 let parts: Vec<&str> = line.split_whitespace().collect();
479 if parts.len() >= 2 {
480 let name = parts[1].trim_end_matches(':');
481 if is_vpn_interface(name) {
482 let is_up = line.contains("state UP");
483 let vendor = detect_vendor(name);
484 let is_enterprise = is_enterprise_vendor(name, vendor.as_deref());
485 vpns.push(VpnAdapter {
486 name: name.to_string(),
487 adapter_type: detect_vpn_type(name),
488 status: if is_up { "Connected" } else { "Disconnected" }.to_string(),
489 ip_address: None,
490 vendor,
491 is_enterprise,
492 interface_name: Some(name.to_string()),
493 });
494 }
495 }
496 }
497 }
498}
499
500#[cfg(target_os = "linux")]
501async fn collect_linux_nmcli(vpns: &mut Vec<VpnAdapter>) {
502 let mut cmd = tokio::process::Command::new("nmcli");
503 cmd.args([
504 "-t",
505 "-f",
506 "TYPE,NAME,DEVICE",
507 "connection",
508 "show",
509 "--active",
510 ]);
511 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
512 let text = String::from_utf8_lossy(&output.stdout);
513 for line in text.lines() {
514 let parts: Vec<&str> = line.splitn(3, ':').collect();
515 if parts.len() >= 2 {
516 let conn_type = parts[0];
517 let conn_name = parts[1];
518 let device = if parts.len() >= 3 {
519 Some(parts[2])
520 } else {
521 None
522 };
523
524 if conn_type.contains("vpn") || conn_type.contains("wireguard") {
526 if vpns.iter().any(|v| v.name == conn_name) {
527 continue;
528 }
529 let vendor = detect_vendor(conn_name);
530 let is_enterprise = is_enterprise_vendor(conn_name, vendor.as_deref());
531 vpns.push(VpnAdapter {
532 name: conn_name.to_string(),
533 adapter_type: conn_type.to_string(),
534 status: "Connected".to_string(),
535 ip_address: None,
536 vendor,
537 is_enterprise,
538 interface_name: device.map(|d| d.to_string()),
539 });
540 }
541 }
542 }
543 }
544}
545
546#[cfg(target_os = "linux")]
547async fn collect_linux_wireguard(vpns: &mut Vec<VpnAdapter>) {
548 let mut cmd = tokio::process::Command::new("wg");
549 cmd.args(["show", "interfaces"]);
550 if let Some(output) = super::util::run_with_timeout(cmd, super::util::QUICK).await {
551 if output.status.success() {
552 let text = String::from_utf8_lossy(&output.stdout);
553 for iface in text.split_whitespace() {
554 if vpns
555 .iter()
556 .any(|v| v.interface_name.as_deref() == Some(iface))
557 {
558 continue;
559 }
560 vpns.push(VpnAdapter {
561 name: iface.to_string(),
562 adapter_type: "WireGuard".to_string(),
563 status: "Connected".to_string(),
564 ip_address: None,
565 vendor: Some("WireGuard".to_string()),
566 is_enterprise: false,
567 interface_name: Some(iface.to_string()),
568 });
569 }
570 }
571 }
572}
573
574#[cfg(any(unix, test))]
577fn is_vpn_interface(name: &str) -> bool {
578 let lower = name.to_lowercase();
579 lower.starts_with("tun")
580 || lower.starts_with("tap")
581 || lower.starts_with("utun")
582 || lower.starts_with("wg")
583 || lower.starts_with("ppp")
584 || lower.contains("vpn")
585 || lower.contains("wireguard")
586 || lower.contains("wintun")
587}
588
589fn detect_vpn_type(name: &str) -> String {
590 let lower = name.to_lowercase();
591 if lower.contains("wireguard") || lower.starts_with("wg") || lower.contains("wintun") {
592 "WireGuard".to_string()
593 } else if lower.starts_with("tun") || lower.starts_with("utun") {
594 "TUN Tunnel".to_string()
595 } else if lower.starts_with("tap") {
596 "TAP Tunnel".to_string()
597 } else if lower.starts_with("ppp") {
598 "PPP".to_string()
599 } else if lower.contains("cisco") || lower.contains("anyconnect") {
600 "Cisco AnyConnect".to_string()
601 } else if lower.contains("fortinet") || lower.contains("forticlient") {
602 "FortiClient".to_string()
603 } else if lower.contains("global protect")
604 || lower.contains("globalprotect")
605 || lower.contains("palo alto")
606 {
607 "GlobalProtect".to_string()
608 } else if lower.contains("zscaler") {
609 "Zscaler".to_string()
610 } else if lower.contains("pulse") {
611 "Pulse Secure".to_string()
612 } else {
613 "VPN".to_string()
614 }
615}
616
617fn detect_vendor(name: &str) -> Option<String> {
618 let lower = name.to_lowercase();
619 if lower.contains("nord") || lower.contains("nordlynx") {
620 Some("NordVPN".to_string())
621 } else if lower.contains("expressvpn") {
622 Some("ExpressVPN".to_string())
623 } else if lower.contains("mullvad") {
624 Some("Mullvad".to_string())
625 } else if lower.contains("tailscale") {
626 Some("Tailscale".to_string())
627 } else if lower.contains("wireguard") || lower.starts_with("wg") {
628 Some("WireGuard".to_string())
629 } else if lower.contains("cisco") || lower.contains("anyconnect") {
630 Some("Cisco".to_string())
631 } else if lower.contains("globalprotect")
632 || lower.contains("global protect")
633 || lower.contains("palo alto")
634 {
635 Some("Palo Alto".to_string())
636 } else if lower.contains("fortinet") || lower.contains("forticlient") {
637 Some("Fortinet".to_string())
638 } else if lower.contains("zscaler") {
639 Some("Zscaler".to_string())
640 } else if lower.contains("pulse") {
641 Some("Pulse Secure".to_string())
642 } else {
643 None
644 }
645}
646
647fn is_enterprise_vendor(name: &str, vendor: Option<&str>) -> bool {
648 let lower = name.to_lowercase();
649 let vendor_lower = vendor.unwrap_or("").to_lowercase();
650
651 let enterprise_patterns = [
652 "cisco",
653 "anyconnect",
654 "globalprotect",
655 "palo alto",
656 "zscaler",
657 "forticlient",
658 "fortinet",
659 "pulse secure",
660 "juniper",
661 "f5 ",
662 "big-ip",
663 "checkpoint",
664 "corp",
665 "enterprise",
666 "mdm",
667 "company",
668 ];
669
670 enterprise_patterns
671 .iter()
672 .any(|p| lower.contains(p) || vendor_lower.contains(p))
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn macos_nwi_identifies_only_reachable_interfaces() {
681 let text = "IPv4 network interface information\n en0 : flags : 0x5 (IPv4,DNS)\n reach : 0x00000002 (Reachable)\nNetwork interfaces: en0 utun7\n";
682 let active = parse_macos_nwi(text);
683 assert!(active.contains("en0"));
684 assert!(active.contains("utun7"));
685 assert!(!active.contains("utun0"));
686 }
687
688 #[test]
689 fn macos_routes_identify_split_and_default_tunnels() {
690 let text = "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";
691 let routed = parse_macos_route_interfaces(text);
692 assert!(routed.contains("utun7"));
693 assert!(routed.contains("utun8"));
694 assert!(!routed.contains("!"));
695 assert!(!routed.contains("utun0"));
696 assert!(!routed.contains("utun1"));
697 }
698
699 #[test]
700 fn dormant_link_local_utuns_are_not_reported_as_vpns() {
701 let text = "utun0: 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\nutun8: flags=8051<UP,POINTOPOINT,RUNNING> mtu 1380\n\tinet 100.64.0.2 --> 100.64.0.1 netmask 0xffffffff\n";
702 let active = ["utun7".to_string()].into_iter().collect();
703 let rows = parse_macos_ifconfig(text, &active);
704 assert_eq!(rows.len(), 1);
705 assert_eq!(rows[0].interface_name.as_deref(), Some("utun7"));
706 }
707
708 #[test]
709 fn macos_scutil_parses_modern_network_extension_service() {
710 let text = "Available network connection services in the current set (*=enabled):\n* (Disconnected) 38349A36 VPN (io.tailscale.ipn.macos) \"Tailscale\" [VPN:io.tailscale.ipn.macos]\n";
711 let rows = parse_macos_scutil(text);
712 assert_eq!(rows.len(), 1);
713 assert_eq!(rows[0].name, "Tailscale");
714 assert_eq!(rows[0].status, "Disconnected");
715 assert_eq!(rows[0].vendor.as_deref(), Some("Tailscale"));
716 }
717}