use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessRef {
pub pid: i32,
pub start_time: u64,
pub comm: String,
pub fd: i32,
}
#[derive(Debug, Default)]
pub struct SocketOwnerMap {
map: HashMap<u32, Vec<ProcessRef>>,
}
impl SocketOwnerMap {
pub fn new() -> Self {
Self::default()
}
pub fn scan() -> Self {
Self::scan_with_root("/proc")
}
pub fn scan_with_root(proc_root: impl AsRef<Path>) -> Self {
let proc_root = proc_root.as_ref();
let mut map: HashMap<u32, Vec<ProcessRef>> = HashMap::new();
let Ok(proc_dir) = fs::read_dir(proc_root) else {
return Self { map };
};
for entry in proc_dir.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue; };
let pid_dir = proc_root.join(name);
let Ok(fds) = fs::read_dir(pid_dir.join("fd")) else {
continue; };
let mut identity: Option<(String, u64)> = None;
for fd_entry in fds.flatten() {
let Ok(target) = fs::read_link(fd_entry.path()) else {
continue;
};
let Some(target) = target.to_str() else {
continue;
};
let Some(inode) = parse_socket_inode(target) else {
continue;
};
let fd = fd_entry
.file_name()
.to_str()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(-1);
let (comm, start_time) = identity.get_or_insert_with(|| {
(read_comm(&pid_dir), read_start_time(&pid_dir))
});
map.entry(inode).or_default().push(ProcessRef {
pid,
start_time: *start_time,
comm: comm.clone(),
fd,
});
}
}
Self { map }
}
pub fn resolve(&self, inode: u32) -> &[ProcessRef] {
self.map.get(&inode).map(Vec::as_slice).unwrap_or(&[])
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (u32, &[ProcessRef])> {
self.map.iter().map(|(k, v)| (*k, v.as_slice()))
}
pub fn insert(&mut self, inode: u32, owner: ProcessRef) {
self.map.entry(inode).or_default().push(owner);
}
}
#[derive(Debug, Default)]
pub struct CgroupPathMap {
map: HashMap<u64, PathBuf>,
root: PathBuf,
}
impl CgroupPathMap {
pub fn scan() -> Self {
Self::scan_with_root("/sys/fs/cgroup")
}
pub fn scan_with_root(root: impl AsRef<Path>) -> Self {
use std::os::unix::fs::MetadataExt;
let root = root.as_ref().to_path_buf();
let mut map = HashMap::new();
let mut stack = vec![root.clone()];
while let Some(dir) = stack.pop() {
if let Ok(meta) = fs::metadata(&dir) {
map.insert(meta.ino(), dir.clone());
}
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|t| t.is_dir()) {
stack.push(entry.path());
}
}
}
Self { map, root }
}
pub fn resolve(&self, cgroup_id: u64) -> Option<&Path> {
self.map.get(&cgroup_id).map(PathBuf::as_path)
}
pub fn resolve_relative(&self, cgroup_id: u64) -> Option<&Path> {
self.resolve(cgroup_id)
.and_then(|p| p.strip_prefix(&self.root).ok())
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
fn parse_socket_inode(target: &str) -> Option<u32> {
let rest = target.strip_prefix("socket:[")?;
let num = rest.strip_suffix(']')?;
num.parse().ok()
}
fn read_comm(pid_dir: &Path) -> String {
fs::read_to_string(pid_dir.join("comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "?".to_string())
}
fn read_start_time(pid_dir: &Path) -> u64 {
fs::read_to_string(pid_dir.join("stat"))
.ok()
.and_then(|s| parse_stat_start_time(&s))
.unwrap_or(0)
}
fn parse_stat_start_time(stat: &str) -> Option<u64> {
let after_comm = &stat[stat.rfind(')')? + 1..];
after_comm.split_ascii_whitespace().nth(19)?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::MetadataExt;
#[test]
fn parses_socket_inode() {
assert_eq!(parse_socket_inode("socket:[12345]"), Some(12345));
assert_eq!(parse_socket_inode("pipe:[999]"), None);
assert_eq!(parse_socket_inode("/dev/null"), None);
assert_eq!(parse_socket_inode("socket:[]"), None);
}
#[test]
fn stat_start_time_survives_hostile_comm() {
let stat = "1234 (a (b) \n c)) R 1 1234 1234 0 -1 4194560 \
100 0 0 0 10 20 0 0 20 0 1 0 987654321 1000000 42 \
18446744073709551615";
assert_eq!(parse_stat_start_time(stat), Some(987654321));
}
#[test]
fn stat_start_time_rejects_garbage() {
assert_eq!(parse_stat_start_time(""), None);
assert_eq!(parse_stat_start_time("no parens here"), None);
assert_eq!(parse_stat_start_time("1 (x) R 2 3"), None); }
fn fake_proc(root: &Path, pid: i32, comm: &str, start_time: u64, fds: &[(i32, u32)]) {
let pid_dir = root.join(pid.to_string());
fs::create_dir_all(pid_dir.join("fd")).unwrap();
fs::write(pid_dir.join("comm"), format!("{comm}\n")).unwrap();
fs::write(
pid_dir.join("stat"),
format!(
"{pid} ({comm}) S 1 {pid} {pid} 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 {start_time} 0 0 0"
),
)
.unwrap();
for (fd, inode) in fds {
std::os::unix::fs::symlink(
format!("socket:[{inode}]"),
pid_dir.join("fd").join(fd.to_string()),
)
.unwrap();
}
}
#[test]
fn scan_with_root_attributes_synthetic_tree() {
let root = std::env::temp_dir().join(format!(
"nlink-procmap-test-{}-{}",
std::process::id(),
line!()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(root.join("self")).unwrap();
fake_proc(&root, 100, "server", 5000, &[(3, 777), (4, 888)]);
fake_proc(&root, 200, "client", 6000, &[(5, 777)]);
let map = SocketOwnerMap::scan_with_root(&root);
assert_eq!(map.len(), 2);
let owners = map.resolve(777);
assert_eq!(owners.len(), 2);
let server = owners.iter().find(|p| p.pid == 100).unwrap();
assert_eq!(server.comm, "server");
assert_eq!(server.start_time, 5000);
assert_eq!(server.fd, 3);
assert!(owners.iter().any(|p| p.pid == 200 && p.start_time == 6000));
assert_eq!(map.resolve(888).len(), 1);
assert!(map.resolve(999).is_empty());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn scan_resolves_own_socket_unprivileged() {
let sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
use std::os::fd::AsRawFd;
let target = fs::read_link(format!("/proc/self/fd/{}", sock.as_raw_fd())).unwrap();
let inode = parse_socket_inode(target.to_str().unwrap()).unwrap();
let map = SocketOwnerMap::scan();
let me = std::process::id() as i32;
let owners = map.resolve(inode);
let mine = owners.iter().find(|p| p.pid == me).expect("own socket attributed");
assert!(mine.start_time > 0, "start_time populated from /proc/self/stat");
assert_ne!(mine.comm, "?");
}
#[test]
fn cgroup_map_inverts_inodes() {
let root = std::env::temp_dir().join(format!(
"nlink-cgmap-test-{}-{}",
std::process::id(),
line!()
));
let _ = fs::remove_dir_all(&root);
let leaf = root.join("system.slice").join("test.service");
fs::create_dir_all(&leaf).unwrap();
let map = CgroupPathMap::scan_with_root(&root);
let leaf_ino = fs::metadata(&leaf).unwrap().ino();
assert_eq!(map.resolve(leaf_ino), Some(leaf.as_path()));
assert_eq!(
map.resolve_relative(leaf_ino),
Some(Path::new("system.slice/test.service"))
);
assert_eq!(map.resolve(u64::MAX), None);
assert!(map.len() >= 3);
fs::remove_dir_all(&root).unwrap();
}
}