use crate::core::{escape_control, Process};
use crate::error::{ProcError, Result};
use serde::{Deserialize, Serialize};
#[cfg(target_os = "linux")]
use std::path::Path;
#[cfg(not(target_os = "linux"))]
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")]
let mut ports = Self::get_listening_macos()?;
#[cfg(target_os = "linux")]
let mut ports = Self::get_listening_linux()?;
#[cfg(target_os = "windows")]
let mut ports = Self::get_listening_windows()?;
ports.sort_by_key(|p| (p.port, p.pid));
Ok(ports)
}
pub fn holders_of(port: u16) -> Result<Vec<PortInfo>> {
Ok(holders_in(Self::get_all_listening()?, 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 = crate::ui::interrupt::unblocked_in_child(&mut Command::new("lsof"))
.args(["+c", "0", "-iTCP", "-sTCP:LISTEN", "-P", "-n"])
.output()
.map_err(|e| ProcError::SystemError(format!("Failed to run lsof: {}", e)))?;
Ok(Self::parse_lsof_output(&String::from_utf8_lossy(
&output.stdout,
)))
}
#[cfg(any(target_os = "macos", test))]
fn parse_lsof_output(stdout: &str) -> Vec<PortInfo> {
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);
}
}
}
ports
}
#[cfg(any(target_os = "macos", test))]
fn unescape_lsof(name: &str) -> String {
fn hex(b: u8) -> Option<u8> {
(b as char).to_digit(16).map(|d| d as u8)
}
let bytes = name.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if let [b'\\', b'x', hi, lo, ..] = bytes[i..] {
if let (Some(hi), Some(lo)) = (hex(hi), hex(lo)) {
let decoded = hi << 4 | lo;
if (0x20..=0x7e).contains(&decoded) {
out.push(decoded);
i += 4;
continue;
}
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(any(target_os = "macos", test))]
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 = escape_control(&Self::unescape_lsof(parts[0])).into_owned();
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 (address, port) = split_listen_address(addr_port)?;
Some(PortInfo {
port,
protocol: Protocol::Tcp,
pid,
process_name,
address: Some(address),
})
}
#[cfg(target_os = "linux")]
fn get_listening_linux() -> Result<Vec<PortInfo>> {
Self::listening_from(Path::new("/proc/net"), Path::new("/proc"))
}
#[cfg(target_os = "linux")]
fn listening_from(net: &Path, procs: &Path) -> Result<Vec<PortInfo>> {
let mut sockets = Vec::new();
let mut read_any = false;
for (table, v6) in [("tcp", false), ("tcp6", true)] {
let Ok(text) = std::fs::read_to_string(net.join(table)) else {
continue;
};
read_any = true;
sockets.extend(
text.lines()
.skip(1)
.filter_map(|line| Self::parse_proc_net_line(line, v6)),
);
}
if !read_any {
return Err(ProcError::SystemError(
"Can't read /proc/net/tcp to look up ports".to_string(),
));
}
let wanted: std::collections::HashSet<u64> = sockets.iter().map(|s| s.0).collect();
let owners = Self::socket_owners(&wanted, procs);
let mut ports = Vec::new();
let mut seen = std::collections::HashSet::new();
for (inode, port, address) in sockets {
let Some(pids) = owners.get(&inode) else {
continue;
};
for &pid in pids {
if !seen.insert((port, pid)) {
continue;
}
let process_name = std::fs::read_to_string(procs.join(format!("{pid}/comm")))
.map(|name| {
let name = name.strip_suffix('\n').unwrap_or(&name);
escape_control(name).into_owned()
})
.unwrap_or_else(|_| "unknown".to_string());
ports.push(PortInfo {
port,
protocol: Protocol::Tcp,
pid,
process_name,
address: Some(address.clone()),
});
}
}
Ok(ports)
}
#[cfg(target_os = "linux")]
fn parse_proc_net_line(line: &str, v6: bool) -> Option<(u64, u16, String)> {
const LISTEN: &str = "0A";
let fields: Vec<&str> = line.split_whitespace().collect();
let (local, state, inode) = (fields.get(1)?, fields.get(3)?, fields.get(9)?);
if *state != LISTEN {
return None;
}
let (addr_hex, port_hex) = local.split_once(':')?;
let port = u16::from_str_radix(port_hex, 16).ok()?;
let inode: u64 = inode.parse().ok()?;
let word = |i: usize| -> Option<[u8; 4]> {
let hex = addr_hex.get(i * 8..i * 8 + 8)?;
Some(u32::from_str_radix(hex, 16).ok()?.to_ne_bytes())
};
let address = if v6 {
if addr_hex.len() != 32 {
return None;
}
let mut bytes = [0u8; 16];
for i in 0..4 {
bytes[i * 4..i * 4 + 4].copy_from_slice(&word(i)?);
}
std::net::Ipv6Addr::from(bytes).to_string()
} else {
if addr_hex.len() != 8 {
return None;
}
std::net::Ipv4Addr::from(word(0)?).to_string()
};
Some((inode, port, address))
}
#[cfg(target_os = "linux")]
fn socket_owners(
wanted: &std::collections::HashSet<u64>,
procs: &Path,
) -> std::collections::HashMap<u64, Vec<u32>> {
let mut owners = std::collections::HashMap::new();
let Ok(procs) = std::fs::read_dir(procs) else {
return owners;
};
for entry in procs.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|s| s.parse::<u32>().ok())
else {
continue;
};
let Ok(fds) = std::fs::read_dir(entry.path().join("fd")) else {
continue;
};
for fd in fds.flatten() {
let Ok(target) = std::fs::read_link(fd.path()) else {
continue;
};
let inode = target
.to_str()
.and_then(|t| t.strip_prefix("socket:["))
.and_then(|t| t.strip_suffix(']'))
.and_then(|t| t.parse::<u64>().ok());
if let Some(inode) = inode.filter(|i| wanted.contains(i)) {
let pids = owners.entry(inode).or_insert_with(Vec::new);
if !pids.contains(&pid) {
pids.push(pid);
}
}
}
}
for pids in owners.values_mut() {
pids.sort_unstable();
}
owners
}
#[cfg(target_os = "windows")]
fn get_listening_windows() -> Result<Vec<PortInfo>> {
let v4 = Command::new("netstat")
.args(["-ano", "-p", "TCP"])
.output()
.map_err(|e| ProcError::SystemError(format!("Failed to run netstat: {}", e)))?;
let v6 = Command::new("netstat")
.args(["-ano", "-p", "TCPv6"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
let names = Self::process_names_windows();
Ok(Self::parse_netstat_output(
&[&String::from_utf8_lossy(&v4.stdout), &v6],
|pid| names.get(&pid).cloned(),
))
}
#[cfg(any(target_os = "windows", test))]
fn parse_netstat_output(
outputs: &[&str],
name_of: impl Fn(u32) -> Option<String>,
) -> Vec<PortInfo> {
let mut ports = Vec::new();
for line in outputs.iter().flat_map(|o| o.lines()) {
if line.contains("LISTENING") {
if let Some(port_info) = Self::parse_netstat_line(line, &name_of) {
ports.push(port_info);
}
}
}
ports
}
#[cfg(any(target_os = "windows", test))]
fn parse_netstat_line(
line: &str,
name_of: &impl Fn(u32) -> Option<String>,
) -> Option<PortInfo> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 5 {
return None;
}
let (address, port) = split_listen_address(parts[1])?;
let pid: u32 = parts.last()?.parse().ok()?;
let process_name = name_of(pid).unwrap_or_else(|| "unknown".to_string());
Some(PortInfo {
port,
protocol: Protocol::Tcp,
pid,
process_name,
address: Some(address),
})
}
#[cfg(target_os = "windows")]
fn process_names_windows() -> std::collections::HashMap<u32, String> {
let mut sys = sysinfo::System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
sys.processes()
.iter()
.map(|(pid, p)| {
let name = p.name().to_string_lossy();
(pid.as_u32(), escape_control(&name).into_owned())
})
.collect()
}
pub fn is_exposed(&self) -> bool {
self.address
.as_ref()
.map(|a| a == "0.0.0.0" || a == "::" || a == "*")
.unwrap_or(true)
}
pub fn is_local(&self) -> bool {
self.address
.as_ref()
.map(|a| a == "127.0.0.1" || a == "::1")
.unwrap_or(false)
}
}
fn holders_in(listening: Vec<PortInfo>, port: u16) -> Vec<PortInfo> {
let mut seen = std::collections::HashSet::new();
listening
.into_iter()
.filter(|p| p.port == port && seen.insert(p.pid))
.collect()
}
#[cfg(any(target_os = "macos", target_os = "windows", test))]
fn split_listen_address(local: &str) -> Option<(String, u16)> {
let (host, port) = local.rsplit_once(':')?;
let port = port.parse().ok()?;
let host = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
let address = if host.is_empty() || host == "*" {
"0.0.0.0"
} else {
host
};
Some((address.to_string(), port))
}
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_parse_lsof_line_keeps_the_full_name() {
let line = "Activity\\x20Monitor 644 zee 8u IPv4 0x3cc8c443bf3e8f62 0t0 TCP 127.0.0.1:7768 (LISTEN)";
let info = PortInfo::parse_lsof_line(line).unwrap();
assert_eq!(info.process_name, "Activity Monitor");
assert_eq!(info.pid, 644);
assert_eq!(info.port, 7768);
}
#[test]
fn test_split_listen_address() {
for (local, address, port) in [
("0.0.0.0:22", "0.0.0.0", 22),
("127.0.0.1:8080", "127.0.0.1", 8080),
("*:80", "0.0.0.0", 80),
("[::]:80", "::", 80),
("[::1]:631", "::1", 631),
("[::ffff:127.0.0.1]:8080", "::ffff:127.0.0.1", 8080),
("[fe80::1%eth0]:22", "fe80::1%eth0", 22),
("127.0.0.53%lo:53", "127.0.0.53%lo", 53),
(":::22", "::", 22),
] {
assert_eq!(
split_listen_address(local),
Some((address.to_string(), port)),
"{local}"
);
}
for bad in ["[::1]", "[::1]:http", "3000", ""] {
assert_eq!(split_listen_address(bad), None, "{bad}");
}
}
#[test]
fn test_parse_lsof_line_ipv6_has_no_brackets() {
let line =
"python3.12 4242 zee 3u IPv6 0x3cc8c443bf3e8f62 0t0 TCP [::1]:8000 (LISTEN)";
let info = PortInfo::parse_lsof_line(line).unwrap();
assert_eq!(info.address.as_deref(), Some("::1"));
assert_eq!(info.port, 8000);
assert_eq!(info.pid, 4242);
assert!(info.is_local() && !info.is_exposed());
}
#[test]
fn test_parse_netstat_line_keeps_the_whole_ipv6_address() {
let any = " TCP [::]:135 [::]:0 LISTENING 1092";
let info = PortInfo::parse_netstat_line(any, &|_| None).unwrap();
assert_eq!(info.address.as_deref(), Some("::"));
assert_eq!(info.port, 135);
assert_eq!(info.pid, 1092);
assert!(info.is_exposed() && !info.is_local());
let loopback =
" TCP [::1]:8080 [::]:0 LISTENING 4242";
let info = PortInfo::parse_netstat_line(loopback, &|_| None).unwrap();
assert_eq!(info.address.as_deref(), Some("::1"));
assert_eq!(info.port, 8080);
assert!(info.is_local() && !info.is_exposed());
let v4 = " TCP 127.0.0.1:8081 0.0.0.0:0 LISTENING 4242";
let info = PortInfo::parse_netstat_line(v4, &|_| None).unwrap();
assert_eq!(info.address.as_deref(), Some("127.0.0.1"));
assert!(info.is_local() && !info.is_exposed());
}
#[test]
fn exposed_and_local_read_the_address() {
let on = |address: Option<&str>| PortInfo {
port: 1,
protocol: Protocol::Tcp,
pid: 1,
process_name: "x".to_string(),
address: address.map(String::from),
};
for exposed in ["0.0.0.0", "::", "*"] {
assert!(on(Some(exposed)).is_exposed(), "{exposed}");
assert!(!on(Some(exposed)).is_local(), "{exposed}");
}
for local in ["127.0.0.1", "::1"] {
assert!(on(Some(local)).is_local(), "{local}");
assert!(!on(Some(local)).is_exposed(), "{local}");
}
let one = on(Some("192.168.1.5"));
assert!(!one.is_exposed() && !one.is_local());
assert!(on(None).is_exposed() && !on(None).is_local());
}
#[test]
fn test_unescape_lsof_is_safe_for_any_name() {
assert_eq!(PortInfo::unescape_lsof("evil\\x1b[2J"), "evil\\x1b[2J");
assert_eq!(PortInfo::unescape_lsof("a\\x7fb"), "a\\x7fb");
assert_eq!(
PortInfo::unescape_lsof("Activity\\x20Monitor"),
"Activity Monitor"
);
assert_eq!(PortInfo::unescape_lsof("x\\xé1"), "x\\xé1");
assert_eq!(PortInfo::unescape_lsof("x\\x"), "x\\x");
assert_eq!(PortInfo::unescape_lsof("x\\x4"), "x\\x4");
}
#[cfg(target_os = "linux")]
#[test]
fn test_parse_proc_net_tcp_lines() {
let v4 = " 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 123456 1 0000000000000000 100 0 0 10 0";
assert_eq!(
PortInfo::parse_proc_net_line(v4, false),
Some((123456, 8080, "127.0.0.1".to_string()))
);
let any = " 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 777 1 0000000000000000 100 0 0 10 0";
assert_eq!(
PortInfo::parse_proc_net_line(any, false),
Some((777, 22, "0.0.0.0".to_string()))
);
let established = " 2: 0100007F:1F90 0100007F:D431 01 00000000:00000000 00:00000000 00000000 1000 0 888 1 0000000000000000 20 4 30 10 -1";
assert_eq!(PortInfo::parse_proc_net_line(established, false), None);
let v6_any = " 0: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 222 1 0000000000000000 100 0 0 10 0";
assert_eq!(
PortInfo::parse_proc_net_line(v6_any, true),
Some((222, 80, "::".to_string()))
);
let v6_local = " 1: 00000000000000000000000001000000:0BB8 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 333 1 0000000000000000 100 0 0 10 0";
assert_eq!(
PortInfo::parse_proc_net_line(v6_local, true),
Some((333, 3000, "::1".to_string()))
);
assert_eq!(PortInfo::parse_proc_net_line("garbage", false), None);
assert_eq!(
PortInfo::parse_proc_net_line(" 0: ZZ:1F90 0:0 0A", false),
None
);
assert_eq!(PortInfo::parse_proc_net_line(v4, true), None);
assert_eq!(PortInfo::parse_proc_net_line(v6_any, false), None);
}
#[cfg(target_os = "linux")]
struct FakeProc {
dir: tempfile::TempDir,
}
#[cfg(target_os = "linux")]
impl FakeProc {
const HEADER: &'static str = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n";
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("net")).unwrap();
FakeProc { dir }
}
fn table(&self, name: &str, rows: &[(&str, u64)]) {
let mut text = Self::HEADER.to_string();
for (local, inode) in rows {
text.push_str(&format!(
" 0: {local} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 {inode} 1 0000000000000000 100 0 0 10 0\n"
));
}
std::fs::write(self.dir.path().join("net").join(name), text).unwrap();
}
fn process(&self, pid: &str, comm: Option<&str>, fds: &[&str]) {
let p = self.dir.path().join(pid);
std::fs::create_dir_all(p.join("fd")).unwrap();
if let Some(comm) = comm {
std::fs::write(p.join("comm"), comm).unwrap();
}
for (i, target) in fds.iter().enumerate() {
std::os::unix::fs::symlink(target, p.join("fd").join(i.to_string())).unwrap();
}
}
fn listening(&self) -> Result<Vec<PortInfo>> {
PortInfo::listening_from(&self.dir.path().join("net"), self.dir.path())
}
}
#[cfg(target_os = "linux")]
#[test]
fn listening_ports_are_matched_to_the_process_holding_the_socket() {
let fake = FakeProc::new();
fake.table(
"tcp",
&[
("0100007F:1F90", 100), ("00000000:0016", 200), ("00000000:2328", 300), ],
);
fake.table("tcp6", &[("00000000000000000000000000000000:1F90", 101)]);
fake.process(
"4242",
Some("myapp\n"),
&["socket:[100]", "socket:[101]", "/dev/null", "socket:[x]"],
);
fake.process("4243", None, &["socket:[300]", "socket:[999]"]);
std::fs::create_dir(fake.dir.path().join("self")).unwrap();
std::fs::create_dir(fake.dir.path().join("4244")).unwrap();
let mut ports = fake.listening().unwrap();
ports.sort_by_key(|p| p.port);
let seen: Vec<_> = ports
.iter()
.map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
.collect();
assert_eq!(
seen,
vec![
(8080, 4242, "myapp", Some("127.0.0.1")),
(9000, 4243, "unknown", Some("0.0.0.0")),
]
);
}
#[cfg(target_os = "linux")]
#[test]
fn every_process_with_a_listen_socket_open_holds_the_port() {
let fake = FakeProc::new();
fake.table("tcp", &[("0100007F:1F90", 100)]);
fake.process("4242", Some("nginx\n"), &["socket:[100]", "socket:[100]"]);
fake.process("4250", Some("nginx\n"), &["socket:[100]"]);
fake.process("4251", Some("nginx\n"), &["socket:[100]"]);
fake.process("4300", Some("other\n"), &["socket:[555]"]);
let ports = fake.listening().unwrap();
let mut pids: Vec<u32> = ports.iter().map(|p| p.pid).collect();
pids.sort_unstable();
assert_eq!(pids, vec![4242, 4250, 4251]);
assert!(ports.iter().all(|p| p.port == 8080));
}
#[cfg(target_os = "linux")]
#[test]
fn a_missing_ipv6_table_is_not_an_error() {
let fake = FakeProc::new();
fake.table("tcp", &[("0100007F:1F90", 100)]);
fake.process("4242", Some("myapp\n"), &["socket:[100]"]);
let ports = fake.listening().unwrap();
assert_eq!(ports.len(), 1);
assert_eq!(ports[0].port, 8080);
}
#[cfg(target_os = "linux")]
#[test]
fn no_socket_tables_is_an_error() {
let fake = FakeProc::new();
let err = fake.listening().unwrap_err();
assert!(
matches!(&err, ProcError::SystemError(m) if m == "Can't read /proc/net/tcp to look up ports"),
"{err:?}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn unreadable_processes_leave_no_owners() {
let fake = FakeProc::new();
fake.table("tcp", &[("0100007F:1F90", 100)]);
let missing = fake.dir.path().join("no-such-proc-dir");
let ports = PortInfo::listening_from(&fake.dir.path().join("net"), &missing).unwrap();
assert!(ports.is_empty());
}
#[test]
fn lsof_output_lists_each_listener_once() {
let out = "\
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
node 100 zee 20u IPv4 0xa 0t0 TCP *:3000 (LISTEN)
node 100 zee 21u IPv6 0xb 0t0 TCP *:3000 (LISTEN)
node 100 zee 22u IPv4 0xc 0t0 TCP 127.0.0.1:3001 (LISTEN)
Activity\\x20Monitor 7 zee 8u IPv6 0xd 0t0 TCP [::1]:3002 (LISTEN)
short line
node abc zee 23u IPv4 0xe 0t0 TCP *:3003 (LISTEN)
node 101 zee 24u IPv4 0xf 0t0 TCP noport (LISTEN)
node 101 zee 25u IPv4 0xf 0t0 TCP *:notaport (LISTEN)
";
let ports = PortInfo::parse_lsof_output(out);
let seen: Vec<_> = ports
.iter()
.map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
.collect();
assert_eq!(
seen,
vec![
(3000, 100, "node", Some("0.0.0.0")),
(3001, 100, "node", Some("127.0.0.1")),
(3002, 7, "Activity Monitor", Some("::1")),
]
);
}
#[test]
fn netstat_output_lists_the_listening_rows() {
let out = "\
Active Connections
Proto Local Address Foreign Address State PID
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1032
TCP 127.0.0.1:8080 0.0.0.0:0 LISTENING 4242
TCP 127.0.0.1:50000 127.0.0.1:8080 ESTABLISHED 5000
TCP 0.0.0.0:99 LISTENING
TCP 0.0.0.0:notaport 0.0.0.0:0 LISTENING 1
TCP 0.0.0.0:136 0.0.0.0:0 LISTENING notapid
";
let name_of = |pid: u32| (pid == 1032).then(|| "svchost.exe".to_string());
let ports = PortInfo::parse_netstat_output(&[out], name_of);
let seen: Vec<_> = ports
.iter()
.map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
.collect();
assert_eq!(
seen,
vec![
(135, 1032, "svchost.exe", Some("0.0.0.0")),
(8080, 4242, "unknown", Some("127.0.0.1")),
]
);
}
#[test]
fn netstat_lists_ipv4_and_ipv6_listeners_from_both_calls() {
let v4 = "\
Active Connections
Proto Local Address Foreign Address State PID
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1032
TCP 127.0.0.1:8080 0.0.0.0:0 LISTENING 4242
";
let v6 = "\
Active Connections
Proto Local Address Foreign Address State PID
TCP [::]:135 [::]:0 LISTENING 1032
TCP [::1]:8080 [::]:0 LISTENING 4242
TCP [fe80::1%12]:9000 [::]:0 LISTENING 4242
TCP [::1]:50000 [::1]:8080 ESTABLISHED 5000
";
let name_of = |pid: u32| (pid == 1032).then(|| "svchost.exe".to_string());
let ports = PortInfo::parse_netstat_output(&[v4, v6], name_of);
let seen: Vec<_> = ports
.iter()
.map(|p| (p.port, p.address.as_deref().unwrap()))
.collect();
assert_eq!(
seen,
vec![
(135, "0.0.0.0"),
(8080, "127.0.0.1"),
(135, "::"),
(8080, "::1"),
(9000, "fe80::1%12"),
]
);
assert!(ports[2].is_exposed() && ports[3].is_local());
let ports = PortInfo::parse_netstat_output(&[v4, ""], |_| None);
assert_eq!(ports.len(), 2);
}
#[test]
fn a_port_held_through_two_sockets_has_two_holders() {
let listener = |port: u16, pid: u32, address: &str| PortInfo {
port,
protocol: Protocol::Tcp,
pid,
process_name: format!("p{pid}"),
address: Some(address.to_string()),
};
let listening = vec![
listener(8080, 10, "127.0.0.1"),
listener(9000, 12, "0.0.0.0"),
listener(8080, 11, "::1"),
listener(8080, 10, "::1"),
];
let holders = holders_in(listening.clone(), 8080);
let seen: Vec<_> = holders
.iter()
.map(|p| (p.pid, p.address.as_deref().unwrap()))
.collect();
assert_eq!(seen, vec![(10, "127.0.0.1"), (11, "::1")]);
assert!(holders_in(listening, 7).is_empty());
}
#[test]
fn a_port_gives_its_own_process() {
let me = PortInfo {
port: 1,
protocol: Protocol::Tcp,
pid: std::process::id(),
process_name: "me".to_string(),
address: None,
};
assert_eq!(me.get_process().unwrap().unwrap().pid, std::process::id());
}
#[test]
fn test_get_listening_ports() {
let result = PortInfo::get_all_listening();
assert!(result.is_ok());
}
#[test]
fn listening_ports_come_by_port_then_pid_and_a_process_has_its_ports_ascending() {
let held: Vec<std::net::TcpListener> = (0..4)
.map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap())
.collect();
let ports = PortInfo::get_all_listening().unwrap();
let keys: Vec<(u16, u32)> = ports.iter().map(|p| (p.port, p.pid)).collect();
assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
let me = std::process::id();
let mine: Vec<u16> = ports
.iter()
.filter(|p| p.pid == me)
.map(|p| p.port)
.collect();
assert!(mine.windows(2).all(|w| w[0] <= w[1]), "{mine:?}");
for listener in &held {
let port = listener.local_addr().unwrap().port();
assert!(mine.contains(&port), "{port} is not in {mine:?}");
let holders: Vec<u32> = PortInfo::holders_of(port)
.unwrap()
.iter()
.map(|h| h.pid)
.collect();
assert!(holders.windows(2).all(|w| w[0] < w[1]), "{holders:?}");
}
}
}