Skip to main content

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    /// Transmission Control Protocol - reliable, ordered delivery
16    Tcp,
17    /// User Datagram Protocol - fast, connectionless delivery
18    Udp,
19}
20
21/// Information about a listening port
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PortInfo {
24    /// Port number
25    pub port: u16,
26    /// Protocol (TCP/UDP)
27    pub protocol: Protocol,
28    /// Process ID using this port
29    pub pid: u32,
30    /// Process name
31    pub process_name: String,
32    /// Bind address (e.g., "0.0.0.0", "127.0.0.1", "::")
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub address: Option<String>,
35}
36
37impl PortInfo {
38    /// Get all listening ports on the system
39    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    /// Find which process is listening on a specific port
55    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    /// Get the full process info for this port's process
61    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        // Use lsof on macOS - only TCP LISTEN sockets
68        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            // Skip header
79            if let Some(port_info) = Self::parse_lsof_line(line) {
80                // Deduplicate (same port can appear multiple times for IPv4/IPv6)
81                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        // lsof output format:
94        // COMMAND  PID USER  FD  TYPE  DEVICE  SIZE/OFF  NODE  NAME
95        // rapportd 643 zee   8u  IPv4  0x...   0t0       TCP   *:52633 (LISTEN)
96        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        // Find the NAME column - it's after the NODE (TCP/UDP) column
105        // The NAME looks like "*:3000" or "127.0.0.1:8080" or "*:52633 (LISTEN)"
106        // Find the column that contains a colon and looks like an address:port
107        let name_col = parts.iter().skip(8).find(|p| p.contains(':'))?;
108
109        // Remove any trailing state like "(LISTEN)" by taking just the address:port part
110        let addr_port =
111            name_col.trim_end_matches(|c: char| c == ')' || c.is_alphabetic() || c == '(');
112
113        // Split address and port
114        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        // Use ss on Linux (more modern than netstat)
137        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        // Local address is typically in column 4 (e.g., "0.0.0.0:22" or "*:80")
162        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        // Process info is in the last column, format: users:(("name",pid=1234,fd=5))
175        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        // Format: users:(("sshd",pid=1234,fd=3))
192        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        // Format: users:(("sshd",pid=1234,fd=3))
202        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        // Use netstat on Windows
211        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        // Local address is column 2 (e.g., "0.0.0.0:135")
238        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        // PID is the last column
245        let pid: u32 = parts.last()?.parse().ok()?;
246
247        // Get process name from PID
248        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
274/// Parse a port from various formats (":3000", "3000", etc.)
275pub 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        // This test may or may not find ports depending on the system
302        let result = PortInfo::get_all_listening();
303        assert!(result.is_ok());
304    }
305}