use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortOwner {
pub pid: u32,
pub process_name: String,
pub uptime_s: Option<u64>,
}
pub fn find_listener(port: u16) -> Result<Option<PortOwner>, String> {
#[cfg(target_os = "windows")]
{
find_listener_windows(port)
}
#[cfg(not(target_os = "windows"))]
{
find_listener_unix(port)
}
}
pub fn is_port_in_use(port: u16) -> bool {
let addr = format!("127.0.0.1:{port}");
std::net::TcpStream::connect_timeout(
&match addr.parse() {
Ok(a) => a,
Err(_) => return false,
},
Duration::from_millis(200),
)
.is_ok()
}
#[cfg(not(target_os = "windows"))]
fn find_listener_unix(port: u16) -> Result<Option<PortOwner>, String> {
let output = std::process::Command::new("lsof")
.arg("-nP")
.arg(format!("-iTCP:{port}"))
.arg("-sTCP:LISTEN")
.arg("-Fpcn")
.output()
.map_err(|e| format!("running lsof: {e}"))?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_lsof_output(&stdout, port))
}
#[cfg(not(target_os = "windows"))]
fn parse_lsof_output(output: &str, _port: u16) -> Option<PortOwner> {
let mut pid: Option<u32> = None;
let mut name = String::new();
for line in output.lines() {
if let Some(rest) = line.strip_prefix('p') {
if pid.is_some() {
break;
}
pid = rest.trim().parse().ok();
} else if let Some(rest) = line.strip_prefix('c')
&& pid.is_some()
&& name.is_empty()
{
rest.trim().clone_into(&mut name);
}
}
let pid = pid?;
let uptime_s = process_uptime_s(pid);
Some(PortOwner {
pid,
process_name: name,
uptime_s,
})
}
#[cfg(not(target_os = "windows"))]
fn process_uptime_s(pid: u32) -> Option<u64> {
let output = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "etimes="])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.trim().parse().ok()
}
#[cfg(target_os = "windows")]
fn find_listener_windows(port: u16) -> Result<Option<PortOwner>, String> {
let output = std::process::Command::new("netstat")
.args(["-ano", "-p", "tcp"])
.output()
.map_err(|e| format!("running netstat: {e}"))?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let pid = match parse_netstat_pid(&stdout, port) {
Some(p) => p,
None => return Ok(None),
};
let process_name = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
.output()
.ok()
.and_then(|o| {
if !o.status.success() {
return None;
}
let s = String::from_utf8_lossy(&o.stdout).into_owned();
let first = s
.lines()
.find(|line| !line.trim().is_empty() && !line.trim_start().starts_with("INFO:"))?
.split(',')
.next()?
.trim();
if !first.starts_with('"') {
return None;
}
let stripped = first.trim_matches('"');
if stripped.is_empty() {
None
} else {
Some(stripped.to_owned())
}
})
.unwrap_or_default();
Ok(Some(PortOwner {
pid,
process_name,
uptime_s: None,
}))
}
#[cfg(target_os = "windows")]
fn parse_netstat_pid(output: &str, port: u16) -> Option<u32> {
let needle = format!(":{port}");
for line in output.lines() {
let line = line.trim();
if !line.starts_with("TCP") {
continue;
}
if !line.contains("LISTENING") {
continue;
}
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() < 5 {
continue;
}
if !cols[1].ends_with(&needle) {
continue;
}
if let Ok(pid) = cols[4].parse::<u32>() {
return Some(pid);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_port_in_use_returns_true_for_active_listener() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
assert!(is_port_in_use(port));
}
#[test]
fn is_port_in_use_returns_false_for_closed_port() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
assert!(!is_port_in_use(port));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn parse_lsof_output_extracts_pid_and_command() {
let raw = "p4242\ncfirefox\nnTCP *:6000 (LISTEN)\n";
let owner = parse_lsof_output(raw, 6000).unwrap();
assert_eq!(owner.pid, 4242);
assert_eq!(owner.process_name, "firefox");
}
#[cfg(not(target_os = "windows"))]
#[test]
fn parse_lsof_output_returns_none_when_no_pid() {
let raw = "";
let owner = parse_lsof_output(raw, 6000);
assert!(owner.is_none());
}
#[cfg(target_os = "windows")]
#[test]
fn parse_netstat_pid_extracts_pid_for_listening_row() {
let raw = "Active Connections\n\
\n\
Proto Local Address Foreign Address State PID\n\
TCP 127.0.0.1:6000 0.0.0.0:0 LISTENING 4242\n";
assert_eq!(parse_netstat_pid(raw, 6000), Some(4242));
}
#[cfg(target_os = "windows")]
#[test]
fn parse_netstat_pid_returns_none_for_non_listening() {
let raw = " TCP 127.0.0.1:6000 0.0.0.0:0 ESTABLISHED 1\n";
assert_eq!(parse_netstat_pid(raw, 6000), None);
}
}