proc_cli/core/
port.rs

1//! Port discovery and management
2//!
3//! Provides cross-platform utilities for discovering which processes
4//! are listening on network ports.
5
6use crate::core::Process;
7use crate::error::{ProcError, Result};
8use serde::{Deserialize, Serialize};
9use std::process::Command;
10
11/// Network protocol
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Protocol {
15    Tcp,
16    Udp,
17}
18
19/// Information about a listening port
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PortInfo {
22    /// Port number
23    pub port: u16,
24    /// Protocol (TCP/UDP)
25    pub protocol: Protocol,
26    /// Process ID using this port
27    pub pid: u32,
28    /// Process name
29    pub process_name: String,
30    /// Bind address (e.g., "0.0.0.0", "127.0.0.1", "::")
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub address: Option<String>,
33}
34
35impl PortInfo {
36    /// Get all listening ports on the system
37    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    /// Find which process is listening on a specific port
53    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    /// Get the full process info for this port's process
59    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        // Use lsof on macOS - only TCP LISTEN sockets
66        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            // Skip header
77            if let Some(port_info) = Self::parse_lsof_line(line) {
78                // Deduplicate (same port can appear multiple times for IPv4/IPv6)
79                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        // lsof output format:
92        // COMMAND  PID USER  FD  TYPE  DEVICE  SIZE/OFF  NODE  NAME
93        // rapportd 643 zee   8u  IPv4  0x...   0t0       TCP   *:52633 (LISTEN)
94        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        // Find the NAME column - it's after the NODE (TCP/UDP) column
103        // The NAME looks like "*:3000" or "127.0.0.1:8080" or "*:52633 (LISTEN)"
104        // Find the column that contains a colon and looks like an address:port
105        let name_col = parts.iter().skip(8).find(|p| p.contains(':'))?;
106
107        // Remove any trailing state like "(LISTEN)" by taking just the address:port part
108        let addr_port =
109            name_col.trim_end_matches(|c: char| c == ')' || c.is_alphabetic() || c == '(');
110
111        // Split address and port
112        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        // Use ss on Linux (more modern than netstat)
135        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        // Local address is typically in column 4 (e.g., "0.0.0.0:22" or "*:80")
160        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        // Process info is in the last column, format: users:(("name",pid=1234,fd=5))
173        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        // Format: users:(("sshd",pid=1234,fd=3))
190        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        // Format: users:(("sshd",pid=1234,fd=3))
200        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        // Use netstat on Windows
209        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        // Local address is column 2 (e.g., "0.0.0.0:135")
236        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        // PID is the last column
243        let pid: u32 = parts.last()?.parse().ok()?;
244
245        // Get process name from PID
246        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
272/// Parse a port from various formats (":3000", "3000", etc.)
273pub 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        // This test may or may not find ports depending on the system
300        let result = PortInfo::get_all_listening();
301        assert!(result.is_ok());
302    }
303}