use crate::core::Process;
use crate::error::{ProcError, Result};
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
Tcp,
Udp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortInfo {
pub port: u16,
pub protocol: Protocol,
pub pid: u32,
pub process_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
}
impl PortInfo {
pub fn get_all_listening() -> Result<Vec<PortInfo>> {
#[cfg(target_os = "macos")]
{
Self::get_listening_macos()
}
#[cfg(target_os = "linux")]
{
Self::get_listening_linux()
}
#[cfg(target_os = "windows")]
{
Self::get_listening_windows()
}
}
pub fn find_by_port(port: u16) -> Result<Option<PortInfo>> {
let ports = Self::get_all_listening()?;
Ok(ports.into_iter().find(|p| p.port == port))
}
pub fn get_process(&self) -> Result<Option<Process>> {
Process::find_by_pid(self.pid)
}
#[cfg(target_os = "macos")]
fn get_listening_macos() -> Result<Vec<PortInfo>> {
let output = Command::new("lsof")
.args(["-iTCP", "-sTCP:LISTEN", "-P", "-n"])
.output()
.map_err(|e| ProcError::SystemError(format!("Failed to run lsof: {}", e)))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut ports = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in stdout.lines().skip(1) {
if let Some(port_info) = Self::parse_lsof_line(line) {
let key = (port_info.port, port_info.pid);
if seen.insert(key) {
ports.push(port_info);
}
}
}
Ok(ports)
}
#[cfg(target_os = "macos")]
fn parse_lsof_line(line: &str) -> Option<PortInfo> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 9 {
return None;
}
let process_name = parts[0].to_string();
let pid: u32 = parts[1].parse().ok()?;
let name_col = parts.iter().skip(8).find(|p| p.contains(':'))?;
let addr_port =
name_col.trim_end_matches(|c: char| c == ')' || c.is_alphabetic() || c == '(');
let last_colon = addr_port.rfind(':')?;
let port_str = &addr_port[last_colon + 1..];
let port: u16 = port_str.parse().ok()?;
let addr_part = &addr_port[..last_colon];
let address = Some(if addr_part == "*" || addr_part.is_empty() {
"0.0.0.0".to_string()
} else {
addr_part.to_string()
});
Some(PortInfo {
port,
protocol: Protocol::Tcp,
pid,
process_name,
address,
})
}
#[cfg(target_os = "linux")]
fn get_listening_linux() -> Result<Vec<PortInfo>> {
let output = Command::new("ss")
.args(["-tlnp"])
.output()
.map_err(|e| ProcError::SystemError(format!("Failed to run ss: {}", e)))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut ports = Vec::new();
for line in stdout.lines().skip(1) {
if let Some(port_info) = Self::parse_ss_line(line) {
ports.push(port_info);
}
}
Ok(ports)
}
#[cfg(target_os = "linux")]
fn parse_ss_line(line: &str) -> Option<PortInfo> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 6 {
return None;
}
let local_addr = parts[3];
let port_str = local_addr.rsplit(':').next()?;
let port: u16 = port_str.parse().ok()?;
let address = local_addr.rsplit(':').nth(1).map(|s| {
if s == "*" {
"0.0.0.0".to_string()
} else {
s.to_string()
}
});
let proc_info = parts.last()?;
let pid = Self::extract_pid_from_ss(proc_info)?;
let process_name =
Self::extract_name_from_ss(proc_info).unwrap_or_else(|| "unknown".to_string());
Some(PortInfo {
port,
protocol: Protocol::Tcp,
pid,
process_name,
address,
})
}
#[cfg(target_os = "linux")]
fn extract_pid_from_ss(info: &str) -> Option<u32> {
let pid_marker = "pid=";
let start = info.find(pid_marker)? + pid_marker.len();
let rest = &info[start..];
let end = rest.find(|c: char| !c.is_ascii_digit())?;
rest[..end].parse().ok()
}
#[cfg(target_os = "linux")]
fn extract_name_from_ss(info: &str) -> Option<String> {
let start = info.find("((\"")? + 3;
let rest = &info[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
#[cfg(target_os = "windows")]
fn get_listening_windows() -> Result<Vec<PortInfo>> {
let output = Command::new("netstat")
.args(["-ano", "-p", "TCP"])
.output()
.map_err(|e| ProcError::SystemError(format!("Failed to run netstat: {}", e)))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut ports = Vec::new();
for line in stdout.lines() {
if line.contains("LISTENING") {
if let Some(port_info) = Self::parse_netstat_line(line) {
ports.push(port_info);
}
}
}
Ok(ports)
}
#[cfg(target_os = "windows")]
fn parse_netstat_line(line: &str) -> Option<PortInfo> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 5 {
return None;
}
let local_addr = parts[1];
let port_str = local_addr.rsplit(':').next()?;
let port: u16 = port_str.parse().ok()?;
let address = local_addr.rsplit(':').nth(1).map(String::from);
let pid: u32 = parts.last()?.parse().ok()?;
let process_name =
Self::get_process_name_windows(pid).unwrap_or_else(|| "unknown".to_string());
Some(PortInfo {
port,
protocol: Protocol::Tcp,
pid,
process_name,
address,
})
}
#[cfg(target_os = "windows")]
fn get_process_name_windows(pid: u32) -> Option<String> {
let output = Command::new("tasklist")
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().next()?;
let name = line.split(',').next()?;
Some(name.trim_matches('"').to_string())
}
}
pub fn parse_port(input: &str) -> Result<u16> {
let cleaned = input.trim().trim_start_matches(':');
cleaned
.parse()
.map_err(|_| ProcError::InvalidInput(format!("Invalid port: '{}'", input)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_port() {
assert_eq!(parse_port(":3000").unwrap(), 3000);
assert_eq!(parse_port("3000").unwrap(), 3000);
assert_eq!(parse_port(" :8080 ").unwrap(), 8080);
}
#[test]
fn test_parse_port_invalid() {
assert!(parse_port("abc").is_err());
assert!(parse_port("").is_err());
}
#[test]
fn test_get_listening_ports() {
let result = PortInfo::get_all_listening();
assert!(result.is_ok());
}
}