#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortOwner {
Child,
Foreign,
Unknown,
}
#[must_use]
pub fn owner_of_listening_port(port: u16, pid: u32) -> PortOwner {
#[cfg(target_os = "linux")]
{
linux::owner_of_listening_port(port, pid)
}
#[cfg(not(target_os = "linux"))]
{
let _ = (port, pid);
PortOwner::Unknown
}
}
#[must_use]
pub fn listening_pids(port: u16) -> Vec<(u32, String)> {
#[cfg(target_os = "linux")]
{
linux::listening_pids(port)
}
#[cfg(not(target_os = "linux"))]
{
let _ = port;
Vec::new()
}
}
#[must_use]
pub const fn attribution_available() -> bool {
cfg!(target_os = "linux")
}
#[cfg(target_os = "linux")]
mod linux {
use super::PortOwner;
use std::collections::HashSet;
const TCP_LISTEN: &str = "0A";
const MAX_PROCS: usize = 65_536;
pub fn owner_of_listening_port(port: u16, pid: u32) -> PortOwner {
let Some(inodes) = listening_inodes(port) else {
return PortOwner::Unknown;
};
if inodes.is_empty() {
return PortOwner::Foreign;
}
for candidate in pid_and_descendants(pid) {
if holds_any_inode(candidate, &inodes) {
return PortOwner::Child;
}
}
PortOwner::Foreign
}
pub fn listening_pids(port: u16) -> Vec<(u32, String)> {
let Some(inodes) = listening_inodes(port) else {
return Vec::new();
};
if inodes.is_empty() {
return Vec::new();
}
let mut holders = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return holders;
};
for entry in entries.flatten().take(MAX_PROCS) {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<u32>() else {
continue;
};
if holds_any_inode(pid, &inodes) {
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm"))
.map_or_else(|_| "?".to_string(), |c| c.trim().to_string());
holders.push((pid, comm));
}
}
holders
}
fn listening_inodes(port: u16) -> Option<HashSet<u64>> {
let mut found = HashSet::new();
let mut readable = false;
for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
let Ok(text) = std::fs::read_to_string(table) else {
continue;
};
readable = true;
for line in text.lines().skip(1) {
if let Some(inode) = listening_inode_on_port(line, port) {
found.insert(inode);
}
}
}
readable.then_some(found)
}
fn listening_inode_on_port(line: &str, port: u16) -> Option<u64> {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() < 10 {
return None;
}
if cols[3] != TCP_LISTEN {
return None;
}
let local_port_hex = cols[1].rsplit(':').next()?;
let local_port = u16::from_str_radix(local_port_hex, 16).ok()?;
if local_port != port {
return None;
}
cols[9].parse::<u64>().ok()
}
fn pid_and_descendants(pid: u32) -> Vec<u32> {
let mut parents: Vec<(u32, u32)> = Vec::new(); if let Ok(entries) = std::fs::read_dir("/proc") {
for entry in entries.flatten().take(MAX_PROCS) {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(candidate) = name.parse::<u32>() else {
continue;
};
if let Some(ppid) = parent_of(candidate) {
parents.push((candidate, ppid));
}
}
}
let mut subtree = vec![pid];
let mut seen: HashSet<u32> = HashSet::from([pid]);
let mut cursor = 0;
while cursor < subtree.len() {
let parent = subtree[cursor];
cursor += 1;
for &(child, child_parent) in &parents {
if child_parent == parent && seen.insert(child) {
subtree.push(child);
}
}
}
subtree
}
fn parent_of(pid: u32) -> Option<u32> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rfind(')').map(|i| &stat[i + 1..])?;
after_comm.split_whitespace().nth(1)?.parse::<u32>().ok()
}
fn holds_any_inode(pid: u32, inodes: &HashSet<u64>) -> bool {
let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else {
return false;
};
for fd in fds.flatten() {
let Ok(target) = std::fs::read_link(fd.path()) else {
continue;
};
let target = target.to_string_lossy();
let Some(rest) = target.strip_prefix("socket:[") else {
continue;
};
let Some(digits) = rest.strip_suffix(']') else {
continue;
};
if digits.parse::<u64>().is_ok_and(|ino| inodes.contains(&ino)) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const LISTEN_ROW: &str = " 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 \
00:00000000 00000000 1000 0 4242424 1 0000000000000000 \
100 0 0 10 0";
const ESTABLISHED_ROW: &str = " 1: 0100007F:1F90 0100007F:C000 01 00000000:00000000 \
00:00000000 00000000 1000 0 4242425 1 \
0000000000000000 20 0 0 10 -1";
#[test]
fn listen_row_on_the_asked_for_port_yields_its_inode() {
assert_eq!(listening_inode_on_port(LISTEN_ROW, 8080), Some(4_242_424));
}
#[test]
fn listen_row_on_a_different_port_is_not_matched() {
assert_eq!(listening_inode_on_port(LISTEN_ROW, 8081), None);
}
#[test]
fn established_row_is_not_a_listener() {
assert_eq!(listening_inode_on_port(ESTABLISHED_ROW, 8080), None);
}
#[test]
fn header_and_garbage_rows_are_ignored() {
for junk in [
" sl local_address rem_address st tx_queue rx_queue",
"",
" 0: 0100007F:1F90 0A",
] {
assert_eq!(listening_inode_on_port(junk, 8080), None, "junk: {junk:?}");
}
}
#[test]
fn ppid_parse_survives_a_comm_containing_spaces_and_parens() {
let stat = "1234 (evil ) 1 999999 (name) S 4321 1234 1234 0 -1 4194304";
let after = stat.rfind(')').map(|i| &stat[i + 1..]).expect("has )");
let ppid: u32 = after
.split_whitespace()
.nth(1)
.and_then(|f| f.parse().ok())
.expect("ppid parses");
assert_eq!(ppid, 4321, "ppid must come from after the LAST )");
}
#[test]
fn a_listener_this_process_holds_is_attributed_to_this_process() {
let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind ephemeral port");
let port = listener.local_addr().expect("local_addr").port();
assert_eq!(
owner_of_listening_port(port, std::process::id()),
PortOwner::Child,
"the process that owns the socket must be recognised as owning it"
);
drop(listener);
}
#[test]
fn a_listener_held_by_someone_else_is_foreign() {
let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind ephemeral port");
let port = listener.local_addr().expect("local_addr").port();
let mut other = std::process::Command::new("sleep")
.arg("30")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn sleep");
assert_eq!(
owner_of_listening_port(port, other.id()),
PortOwner::Foreign,
"a socket held by an unrelated process must NEVER be attributed to it"
);
let _ = other.kill();
let _ = other.wait();
drop(listener);
}
#[test]
fn a_port_with_no_listener_is_foreign() {
let port = {
let l = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind ephemeral port");
l.local_addr().expect("local_addr").port()
};
assert_eq!(
owner_of_listening_port(port, std::process::id()),
PortOwner::Foreign
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unavailable_attribution_never_claims_child() {
if !attribution_available() {
assert_eq!(
owner_of_listening_port(8080, std::process::id()),
PortOwner::Unknown,
"a platform without socket->pid tables must say Unknown, not guess"
);
}
}
#[test]
fn attribution_availability_matches_the_platform() {
assert_eq!(attribution_available(), cfg!(target_os = "linux"));
}
}