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