1use crate::core::Process;
7use crate::error::{ProcError, Result};
8use serde::{Deserialize, Serialize};
9use std::process::Command;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Protocol {
15 Tcp,
17 Udp,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PortInfo {
24 pub port: u16,
26 pub protocol: Protocol,
28 pub pid: u32,
30 pub process_name: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub address: Option<String>,
35}
36
37impl PortInfo {
38 pub fn get_all_listening() -> Result<Vec<PortInfo>> {
40 #[cfg(target_os = "macos")]
41 {
42 Self::get_listening_macos()
43 }
44 #[cfg(target_os = "linux")]
45 {
46 Self::get_listening_linux()
47 }
48 #[cfg(target_os = "windows")]
49 {
50 Self::get_listening_windows()
51 }
52 }
53
54 pub fn find_by_port(port: u16) -> Result<Option<PortInfo>> {
56 let ports = Self::get_all_listening()?;
57 Ok(ports.into_iter().find(|p| p.port == port))
58 }
59
60 pub fn get_process(&self) -> Result<Option<Process>> {
62 Process::find_by_pid(self.pid)
63 }
64
65 #[cfg(target_os = "macos")]
66 fn get_listening_macos() -> Result<Vec<PortInfo>> {
67 let output = Command::new("lsof")
69 .args(["-iTCP", "-sTCP:LISTEN", "-P", "-n"])
70 .output()
71 .map_err(|e| ProcError::SystemError(format!("Failed to run lsof: {}", e)))?;
72
73 let stdout = String::from_utf8_lossy(&output.stdout);
74 let mut ports = Vec::new();
75 let mut seen = std::collections::HashSet::new();
76
77 for line in stdout.lines().skip(1) {
78 if let Some(port_info) = Self::parse_lsof_line(line) {
80 let key = (port_info.port, port_info.pid);
82 if seen.insert(key) {
83 ports.push(port_info);
84 }
85 }
86 }
87
88 Ok(ports)
89 }
90
91 #[cfg(target_os = "macos")]
92 fn parse_lsof_line(line: &str) -> Option<PortInfo> {
93 let parts: Vec<&str> = line.split_whitespace().collect();
97 if parts.len() < 9 {
98 return None;
99 }
100
101 let process_name = parts[0].to_string();
102 let pid: u32 = parts[1].parse().ok()?;
103
104 let name_col = parts.iter().skip(8).find(|p| p.contains(':'))?;
108
109 let addr_port =
111 name_col.trim_end_matches(|c: char| c == ')' || c.is_alphabetic() || c == '(');
112
113 let last_colon = addr_port.rfind(':')?;
115 let port_str = &addr_port[last_colon + 1..];
116 let port: u16 = port_str.parse().ok()?;
117
118 let addr_part = &addr_port[..last_colon];
119 let address = Some(if addr_part == "*" || addr_part.is_empty() {
120 "0.0.0.0".to_string()
121 } else {
122 addr_part.to_string()
123 });
124
125 Some(PortInfo {
126 port,
127 protocol: Protocol::Tcp,
128 pid,
129 process_name,
130 address,
131 })
132 }
133
134 #[cfg(target_os = "linux")]
135 fn get_listening_linux() -> Result<Vec<PortInfo>> {
136 let output = Command::new("ss")
138 .args(["-tlnp"])
139 .output()
140 .map_err(|e| ProcError::SystemError(format!("Failed to run ss: {}", e)))?;
141
142 let stdout = String::from_utf8_lossy(&output.stdout);
143 let mut ports = Vec::new();
144
145 for line in stdout.lines().skip(1) {
146 if let Some(port_info) = Self::parse_ss_line(line) {
147 ports.push(port_info);
148 }
149 }
150
151 Ok(ports)
152 }
153
154 #[cfg(target_os = "linux")]
155 fn parse_ss_line(line: &str) -> Option<PortInfo> {
156 let parts: Vec<&str> = line.split_whitespace().collect();
157 if parts.len() < 6 {
158 return None;
159 }
160
161 let local_addr = parts[3];
163 let port_str = local_addr.rsplit(':').next()?;
164 let port: u16 = port_str.parse().ok()?;
165
166 let address = local_addr.rsplit(':').nth(1).map(|s| {
167 if s == "*" {
168 "0.0.0.0".to_string()
169 } else {
170 s.to_string()
171 }
172 });
173
174 let proc_info = parts.last()?;
176 let pid = Self::extract_pid_from_ss(proc_info)?;
177 let process_name =
178 Self::extract_name_from_ss(proc_info).unwrap_or_else(|| "unknown".to_string());
179
180 Some(PortInfo {
181 port,
182 protocol: Protocol::Tcp,
183 pid,
184 process_name,
185 address,
186 })
187 }
188
189 #[cfg(target_os = "linux")]
190 fn extract_pid_from_ss(info: &str) -> Option<u32> {
191 let pid_marker = "pid=";
193 let start = info.find(pid_marker)? + pid_marker.len();
194 let rest = &info[start..];
195 let end = rest.find(|c: char| !c.is_ascii_digit())?;
196 rest[..end].parse().ok()
197 }
198
199 #[cfg(target_os = "linux")]
200 fn extract_name_from_ss(info: &str) -> Option<String> {
201 let start = info.find("((\"")? + 3;
203 let rest = &info[start..];
204 let end = rest.find('"')?;
205 Some(rest[..end].to_string())
206 }
207
208 #[cfg(target_os = "windows")]
209 fn get_listening_windows() -> Result<Vec<PortInfo>> {
210 let output = Command::new("netstat")
212 .args(["-ano", "-p", "TCP"])
213 .output()
214 .map_err(|e| ProcError::SystemError(format!("Failed to run netstat: {}", e)))?;
215
216 let stdout = String::from_utf8_lossy(&output.stdout);
217 let mut ports = Vec::new();
218
219 for line in stdout.lines() {
220 if line.contains("LISTENING") {
221 if let Some(port_info) = Self::parse_netstat_line(line) {
222 ports.push(port_info);
223 }
224 }
225 }
226
227 Ok(ports)
228 }
229
230 #[cfg(target_os = "windows")]
231 fn parse_netstat_line(line: &str) -> Option<PortInfo> {
232 let parts: Vec<&str> = line.split_whitespace().collect();
233 if parts.len() < 5 {
234 return None;
235 }
236
237 let local_addr = parts[1];
239 let port_str = local_addr.rsplit(':').next()?;
240 let port: u16 = port_str.parse().ok()?;
241
242 let address = local_addr.rsplit(':').nth(1).map(String::from);
243
244 let pid: u32 = parts.last()?.parse().ok()?;
246
247 let process_name =
249 Self::get_process_name_windows(pid).unwrap_or_else(|| "unknown".to_string());
250
251 Some(PortInfo {
252 port,
253 protocol: Protocol::Tcp,
254 pid,
255 process_name,
256 address,
257 })
258 }
259
260 #[cfg(target_os = "windows")]
261 fn get_process_name_windows(pid: u32) -> Option<String> {
262 let output = Command::new("tasklist")
263 .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
264 .output()
265 .ok()?;
266
267 let stdout = String::from_utf8_lossy(&output.stdout);
268 let line = stdout.lines().next()?;
269 let name = line.split(',').next()?;
270 Some(name.trim_matches('"').to_string())
271 }
272}
273
274pub fn parse_port(input: &str) -> Result<u16> {
276 let cleaned = input.trim().trim_start_matches(':');
277 cleaned
278 .parse()
279 .map_err(|_| ProcError::InvalidInput(format!("Invalid port: '{}'", input)))
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_parse_port() {
288 assert_eq!(parse_port(":3000").unwrap(), 3000);
289 assert_eq!(parse_port("3000").unwrap(), 3000);
290 assert_eq!(parse_port(" :8080 ").unwrap(), 8080);
291 }
292
293 #[test]
294 fn test_parse_port_invalid() {
295 assert!(parse_port("abc").is_err());
296 assert!(parse_port("").is_err());
297 }
298
299 #[test]
300 fn test_get_listening_ports() {
301 let result = PortInfo::get_all_listening();
303 assert!(result.is_ok());
304 }
305}