Skip to main content

nd_300/diagnostics/
shared_cache.rs

1use std::collections::HashMap;
2
3/// Pre-fetched data shared across technician-mode diagnostic modules.
4/// Built once before `tokio::join!` and passed by reference into each
5/// module's `collect_with_cache()` to avoid duplicate subprocess calls.
6pub struct SharedCache {
7    pub netstat: Option<NetstatCache>,
8    /// Machine-readable `lsof` TCP records used on macOS when Darwin's
9    /// fixed-width `netstat` output truncates long IPv6 endpoints. Always
10    /// `None` on other platforms.
11    pub macos_lsof_tcp: Option<Vec<String>>,
12    pub ipconfig: Option<IpconfigCache>,
13    pub sysinfo_networks: Option<sysinfo::Networks>,
14    pub gateway_ip: Option<String>,
15}
16
17/// Parsed `netstat -ano` output plus PID-to-process-name map.
18pub struct NetstatCache {
19    pub lines: Vec<String>,
20    pub process_map: HashMap<u32, String>,
21}
22
23/// `ipconfig /all` output stored as raw text.
24pub struct IpconfigCache {
25    pub raw: String,
26}
27
28impl SharedCache {
29    /// Run all pre-fetches concurrently. Each field is `None` on failure
30    /// so consumers can fall back to their own subprocess call.
31    pub async fn build_for_tech_mode() -> Self {
32        let (netstat, macos_lsof_tcp, ipconfig, networks, gateway) = tokio::join!(
33            fetch_netstat(),
34            fetch_macos_lsof_tcp_lines(),
35            fetch_ipconfig(),
36            fetch_sysinfo_networks(),
37            fetch_gateway(),
38        );
39
40        Self {
41            netstat,
42            macos_lsof_tcp,
43            ipconfig,
44            sysinfo_networks: networks,
45            gateway_ip: gateway,
46        }
47    }
48}
49
50/// Darwin 25/macOS 26 still truncates long IPv6 addresses in `netstat` even
51/// with `-W`. `lsof` field output is stable, machine-readable, and retains the
52/// complete bracketed endpoints. The shared diagnostic timeout kills and
53/// reaps `lsof` if the kernel query stalls.
54#[cfg(target_os = "macos")]
55pub(super) async fn fetch_macos_lsof_tcp_lines() -> Option<Vec<String>> {
56    let mut cmd = tokio::process::Command::new("/usr/sbin/lsof");
57    cmd.args(["-nP", "-iTCP", "-FpcPnT"]);
58    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
59    if !output.status.success() {
60        return None;
61    }
62
63    let lines: Vec<String> = String::from_utf8_lossy(&output.stdout)
64        .lines()
65        .map(str::to_string)
66        .collect();
67    (!lines.is_empty()).then_some(lines)
68}
69
70#[cfg(not(target_os = "macos"))]
71async fn fetch_macos_lsof_tcp_lines() -> Option<Vec<String>> {
72    None
73}
74
75// ── Netstat ────────────────────────────────────────────────────────────────
76
77#[cfg(windows)]
78async fn fetch_netstat() -> Option<NetstatCache> {
79    use sysinfo::System;
80
81    let mut cmd = tokio::process::Command::new("netstat");
82    cmd.args(["-ano"]);
83    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
84
85    let text = String::from_utf8_lossy(&output.stdout);
86    let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
87
88    // Build PID->process-name map from a single sysinfo refresh
89    let process_map = tokio::task::spawn_blocking(|| {
90        let mut sys = System::new();
91        sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
92        let mut map = HashMap::new();
93        for (pid, process) in sys.processes() {
94            map.insert(pid.as_u32(), process.name().to_string_lossy().to_string());
95        }
96        map
97    })
98    .await
99    .unwrap_or_default();
100
101    Some(NetstatCache { lines, process_map })
102}
103
104#[cfg(target_os = "macos")]
105async fn fetch_netstat() -> Option<NetstatCache> {
106    let mut cmd = tokio::process::Command::new("netstat");
107    // `-W` is still useful for Darwin's other wide columns. Long IPv6
108    // endpoints remain fixed-width on macOS 26, so `connections` prefers the
109    // bounded lsof field cache above when it is available.
110    cmd.args(["-anW", "-p", "tcp"]);
111    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
112
113    let text = String::from_utf8_lossy(&output.stdout);
114    let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
115
116    Some(NetstatCache {
117        lines,
118        process_map: HashMap::new(),
119    })
120}
121
122#[cfg(target_os = "linux")]
123async fn fetch_netstat() -> Option<NetstatCache> {
124    // Linux modules use `ss` and `netstat -an`, not `netstat -ano`.
125    // We still fetch `netstat -an` for connection_states.
126    let mut cmd = tokio::process::Command::new("netstat");
127    cmd.args(["-an"]);
128    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
129
130    let text = String::from_utf8_lossy(&output.stdout);
131    let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
132
133    Some(NetstatCache {
134        lines,
135        process_map: HashMap::new(),
136    })
137}
138
139// ── Ipconfig ───────────────────────────────────────────────────────────────
140
141#[cfg(windows)]
142async fn fetch_ipconfig() -> Option<IpconfigCache> {
143    let mut cmd = tokio::process::Command::new("ipconfig");
144    cmd.args(["/all"]);
145    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
146
147    let raw = String::from_utf8_lossy(&output.stdout).to_string();
148    Some(IpconfigCache { raw })
149}
150
151#[cfg(not(windows))]
152async fn fetch_ipconfig() -> Option<IpconfigCache> {
153    // ipconfig /all is Windows-only; other platforms don't use it
154    None
155}
156
157// ── Sysinfo Networks ──────────────────────────────────────────────────────
158
159async fn fetch_sysinfo_networks() -> Option<sysinfo::Networks> {
160    // Blocking system enumeration — run it off the async runtime. `Networks`
161    // is Send, so the object itself crosses back (the cache contract needs the
162    // whole object). A JoinError falls back to None, identical to a fetch
163    // failure, so consumers use their own subprocess fallback.
164    tokio::task::spawn_blocking(|| Some(sysinfo::Networks::new_with_refreshed_list()))
165        .await
166        .unwrap_or(None)
167}
168
169// ── Gateway IP ─────────────────────────────────────────────────────────────
170
171async fn fetch_gateway() -> Option<String> {
172    // Blocking syscall — run it off the async runtime. A JoinError falls back
173    // to None, identical to the Err path.
174    tokio::task::spawn_blocking(|| {
175        default_net::get_default_gateway()
176            .ok()
177            .map(|gw| gw.ip_addr.to_string())
178    })
179    .await
180    .unwrap_or(None)
181}