1use serde::Serialize;
2
3use super::shared_cache::SharedCache;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct ConnectionEntry {
7 pub protocol: String,
8 pub local_addr: String,
9 pub remote_addr: String,
10 pub state: String,
11 pub pid: Option<u32>,
12 pub process_name: Option<String>,
13}
14
15pub async fn collect_with_cache(cache: &SharedCache) -> Option<Vec<ConnectionEntry>> {
16 #[cfg(windows)]
17 {
18 if let Some(ref nc) = cache.netstat {
19 return Some(parse_windows_connections(&nc.lines, &nc.process_map));
20 }
21 }
22
23 #[cfg(target_os = "macos")]
24 {
25 if let Some(ref lines) = cache.macos_lsof_tcp {
26 let entries = parse_macos_lsof_connections(lines);
27 if !entries.is_empty() {
28 return Some(entries);
29 }
30 }
31 if let Some(ref nc) = cache.netstat {
32 return Some(parse_macos_connections(&nc.lines));
33 }
34 }
35
36 let _ = cache;
38 collect().await
39}
40
41pub async fn collect() -> Option<Vec<ConnectionEntry>> {
42 #[cfg(windows)]
43 {
44 collect_windows().await
45 }
46
47 #[cfg(target_os = "macos")]
48 {
49 collect_macos().await
50 }
51
52 #[cfg(target_os = "linux")]
53 {
54 collect_linux().await
55 }
56}
57
58#[cfg(windows)]
59fn parse_windows_connections(
60 lines: &[String],
61 process_map: &std::collections::HashMap<u32, String>,
62) -> Vec<ConnectionEntry> {
63 let mut entries = Vec::new();
64
65 for line in lines {
66 let line = line.trim();
67 let parts: Vec<&str> = line.split_whitespace().collect();
68
69 if parts.len() >= 4 && (parts[0] == "TCP" || parts[0] == "UDP") {
70 let pid = parts.last().and_then(|s| s.parse::<u32>().ok());
71 let state = if parts[0] == "TCP" && parts.len() >= 5 {
72 parts[3].to_string()
73 } else {
74 String::new()
75 };
76
77 let process_name = pid.and_then(|p| process_map.get(&p).cloned());
78
79 entries.push(ConnectionEntry {
80 protocol: parts[0].to_string(),
81 local_addr: parts[1].to_string(),
82 remote_addr: parts[2].to_string(),
83 state,
84 pid,
85 process_name,
86 });
87 }
88 }
89
90 entries
91}
92
93#[cfg(windows)]
94async fn collect_windows() -> Option<Vec<ConnectionEntry>> {
95 use sysinfo::System;
96
97 let mut cmd = tokio::process::Command::new("netstat");
98 cmd.args(["-ano"]);
99 let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
100
101 let text = String::from_utf8_lossy(&output.stdout);
102 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
103
104 let mut sys = System::new();
105 sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
106 let mut process_map = std::collections::HashMap::new();
107 for (pid, process) in sys.processes() {
108 process_map.insert(pid.as_u32(), process.name().to_string_lossy().to_string());
109 }
110
111 Some(parse_windows_connections(&lines, &process_map))
112}
113
114#[cfg(any(target_os = "macos", test))]
115fn parse_macos_connections(lines: &[String]) -> Vec<ConnectionEntry> {
116 let mut entries = Vec::new();
117
118 for line in lines {
119 let parts: Vec<&str> = line.split_whitespace().collect();
120 if parts.len() >= 6 && parts[0].starts_with("tcp") {
121 entries.push(ConnectionEntry {
122 protocol: "TCP".to_string(),
123 local_addr: parts[3].to_string(),
124 remote_addr: parts[4].to_string(),
125 state: parts[5].to_string(),
126 pid: None,
127 process_name: None,
128 });
129 }
130 }
131
132 entries
133}
134
135#[cfg(any(target_os = "macos", test))]
136#[derive(Default)]
137struct MacosLsofTcpRecord {
138 name: Option<String>,
139 state: Option<String>,
140}
141
142#[cfg(any(target_os = "macos", test))]
143fn push_macos_lsof_record(
144 entries: &mut Vec<ConnectionEntry>,
145 seen: &mut std::collections::HashSet<(String, String, String, Option<u32>)>,
146 pid: Option<u32>,
147 process_name: &Option<String>,
148 record: &mut MacosLsofTcpRecord,
149) {
150 let Some(name) = record.name.take() else {
151 record.state = None;
152 return;
153 };
154 let (local_addr, remote_addr) = name
155 .split_once("->")
156 .map(|(local, remote)| (local.to_string(), remote.to_string()))
157 .unwrap_or_else(|| (name, "*:*".to_string()));
158 let state = record.state.take().unwrap_or_default();
159 let key = (local_addr.clone(), remote_addr.clone(), state.clone(), pid);
160 if seen.insert(key) {
161 entries.push(ConnectionEntry {
162 protocol: "TCP".to_string(),
163 local_addr,
164 remote_addr,
165 state,
166 pid,
167 process_name: process_name.clone(),
168 });
169 }
170}
171
172#[cfg(any(target_os = "macos", test))]
176fn parse_macos_lsof_connections(lines: &[String]) -> Vec<ConnectionEntry> {
177 let mut entries = Vec::new();
178 let mut seen = std::collections::HashSet::new();
179 let mut pid = None;
180 let mut process_name = None;
181 let mut record = MacosLsofTcpRecord::default();
182
183 for line in lines {
184 if let Some(value) = line.strip_prefix('p') {
185 push_macos_lsof_record(&mut entries, &mut seen, pid, &process_name, &mut record);
186 pid = value.parse().ok();
187 process_name = None;
188 } else if let Some(value) = line.strip_prefix('c') {
189 process_name = Some(value.to_string());
190 } else if line.starts_with('f') {
191 push_macos_lsof_record(&mut entries, &mut seen, pid, &process_name, &mut record);
192 } else if let Some(value) = line.strip_prefix('n') {
193 record.name = Some(value.to_string());
194 } else if let Some(value) = line.strip_prefix("TST=") {
195 record.state = Some(value.to_string());
196 }
197 }
198 push_macos_lsof_record(&mut entries, &mut seen, pid, &process_name, &mut record);
199
200 entries
201}
202
203#[cfg(target_os = "macos")]
204async fn collect_macos() -> Option<Vec<ConnectionEntry>> {
205 if let Some(lines) = super::shared_cache::fetch_macos_lsof_tcp_lines().await {
206 let entries = parse_macos_lsof_connections(&lines);
207 if !entries.is_empty() {
208 return Some(entries);
209 }
210 }
211
212 let mut cmd = tokio::process::Command::new("netstat");
213 cmd.args(["-anW", "-p", "tcp"]);
214 let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
215
216 let text = String::from_utf8_lossy(&output.stdout);
217 let lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
218 Some(parse_macos_connections(&lines))
219}
220
221#[cfg(target_os = "linux")]
222async fn collect_linux() -> Option<Vec<ConnectionEntry>> {
223 let mut cmd = tokio::process::Command::new("ss");
224 cmd.args(["-tupn"]);
225 let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
226
227 let text = String::from_utf8_lossy(&output.stdout);
228 let mut entries = Vec::new();
229
230 for line in text.lines().skip(1) {
231 let parts: Vec<&str> = line.split_whitespace().collect();
232 if parts.len() >= 5 {
233 let (pid, pname) = parse_ss_process(parts.get(6).unwrap_or(&""));
234
235 entries.push(ConnectionEntry {
236 protocol: parts[0].to_uppercase(),
237 local_addr: parts[4].to_string(),
238 remote_addr: parts.get(5).unwrap_or(&"*:*").to_string(),
239 state: parts[1].to_string(),
240 pid,
241 process_name: pname,
242 });
243 }
244 }
245
246 Some(entries)
247}
248
249#[cfg(target_os = "linux")]
250fn parse_ss_process(s: &str) -> (Option<u32>, Option<String>) {
251 if let Some(start) = s.find("pid=") {
253 let after = &s[start + 4..];
254 let pid_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
255 let pid = pid_str.parse().ok();
256
257 let pname = if let Some(name_start) = s.find("((\"") {
258 let after = &s[name_start + 3..];
259 let name: String = after.chars().take_while(|c| *c != '"').collect();
260 Some(name)
261 } else {
262 None
263 };
264
265 (pid, pname)
266 } else {
267 (None, None)
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn macos_wide_ipv6_endpoints_are_preserved() {
277 let lines = vec![
278 "tcp6 0 0 fe80::26fd:c41b:b5bc:ff7%utun12.1024 fe80::91b5:8f5b:1234%utun12.1024 SYN_SENT"
279 .to_string(),
280 ];
281 let entries = parse_macos_connections(&lines);
282 assert_eq!(entries.len(), 1);
283 assert_eq!(
284 entries[0].local_addr,
285 "fe80::26fd:c41b:b5bc:ff7%utun12.1024"
286 );
287 assert_eq!(entries[0].state, "SYN_SENT");
288 }
289
290 #[test]
291 fn macos_26_lsof_field_output_preserves_complete_ipv6_and_process() {
292 let fixture = [
296 "p7850",
297 "cidentityservicesd",
298 "f9",
299 "PTCP",
300 "n[fe80:1b::26fd:c41b:b5bc:ff7]:1024->[fe80:1b::91b5:8f5b:be19:6cf3]:1024",
301 "TST=ESTABLISHED",
302 "TQR=0",
303 "TQS=0",
304 "f10",
305 "PTCP",
306 "n*:51140",
307 "TST=LISTEN",
308 ]
309 .map(str::to_string);
310
311 let entries = parse_macos_lsof_connections(&fixture);
312 assert_eq!(entries.len(), 2);
313 assert_eq!(entries[0].local_addr, "[fe80:1b::26fd:c41b:b5bc:ff7]:1024");
314 assert_eq!(
315 entries[0].remote_addr,
316 "[fe80:1b::91b5:8f5b:be19:6cf3]:1024"
317 );
318 assert_eq!(entries[0].pid, Some(7850));
319 assert_eq!(
320 entries[0].process_name.as_deref(),
321 Some("identityservicesd")
322 );
323 assert_eq!(entries[1].remote_addr, "*:*");
324 assert_eq!(entries[1].state, "LISTEN");
325 }
326
327 #[test]
328 fn macos_lsof_duplicate_socket_records_are_deduplicated() {
329 let fixture = [
330 "p42",
331 "cdaemon",
332 "f9",
333 "PTCP",
334 "n[fd00::1]:5000->[fd00::2]:443",
335 "TST=ESTABLISHED",
336 "f10",
337 "PTCP",
338 "n[fd00::1]:5000->[fd00::2]:443",
339 "TST=ESTABLISHED",
340 ]
341 .map(str::to_string);
342 assert_eq!(parse_macos_lsof_connections(&fixture).len(), 1);
343 }
344}