use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct ProcessRef {
pub(crate) pid: u32,
pub(crate) exe: Option<PathBuf>,
pub(crate) cwd: Option<PathBuf>,
pub(crate) fds: Vec<u32>,
}
impl ProcessRef {
pub(crate) fn blocks_deletion(&self, target: &Path) -> bool {
if !self.fds.is_empty() {
return true;
}
if self
.exe
.as_ref()
.is_some_and(|exe| exe == target || exe.starts_with(target))
{
return true;
}
self.cwd
.as_ref()
.is_some_and(|cwd| cwd == target || cwd.starts_with(target))
}
pub(crate) fn describe(&self) -> String {
let mut parts = Vec::new();
if let Some(exe) = &self.exe {
parts.push(format!("exe={}", exe.display()));
}
if let Some(cwd) = &self.cwd {
parts.push(format!("cwd={}", cwd.display()));
}
if !self.fds.is_empty() {
let fds: Vec<String> = self.fds.iter().map(u32::to_string).collect();
parts.push(format!("fds=[{}]", fds.join(",")));
}
if parts.is_empty() {
format!("pid {} (no refs readable)", self.pid)
} else {
format!("pid {} ({})", self.pid, parts.join(", "))
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ReferenceCapture {
pub(crate) refs: Vec<ProcessRef>,
pub(crate) scan_failed: bool,
}
impl ReferenceCapture {
pub(crate) fn blocking_reason(&self, target: &Path) -> Option<String> {
if self.scan_failed {
return Some("/proc unreadable — usage cannot be verified".to_string());
}
if self.refs.is_empty() {
return None;
}
let mut blocking: Vec<ProcessRef> = merged_by_pid(&self.refs)
.into_iter()
.filter(|r| r.blocks_deletion(target))
.collect();
if blocking.is_empty() {
return None;
}
const SHOWN: usize = 4;
let total = blocking.len();
let truncated = total > SHOWN;
if truncated {
blocking.truncate(SHOWN);
}
let mut text = blocking
.iter()
.map(|r| r.describe())
.collect::<Vec<_>>()
.join("; ");
if truncated {
text.push_str(&format!("; … and {} more process(es)", total - SHOWN));
}
Some(text)
}
pub(crate) fn describe(&self) -> String {
if self.scan_failed {
return "scan failed (/proc unreadable)".to_string();
}
if self.refs.is_empty() {
return "none (no /proc exe, cwd or fd reference)".to_string();
}
merged_by_pid(&self.refs)
.into_iter()
.map(|reference| reference.describe())
.collect::<Vec<_>>()
.join("; ")
}
}
#[cfg(test)]
pub(crate) fn fds_for(path: &Path, target: &Path) -> Vec<u32> {
let Ok(entries) = std::fs::read_dir(path.join("fd")) else {
return Vec::new();
};
let mut fds = Vec::new();
for entry in entries.flatten() {
let Some(fd) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let Ok(link) = std::fs::read_link(entry.path()) else {
continue;
};
let text = link.to_string_lossy();
let plain = text.strip_suffix(" (deleted)").unwrap_or(&text);
if Path::new(plain) == target {
fds.push(fd);
}
}
fds.sort_unstable();
fds
}
pub(crate) fn merged_by_pid(refs: &[ProcessRef]) -> Vec<ProcessRef> {
type MergedRow = (Option<PathBuf>, Option<PathBuf>, Vec<u32>);
let mut merged: BTreeMap<u32, MergedRow> = BTreeMap::new();
for reference in refs {
let entry = merged.entry(reference.pid).or_default();
if entry.0.is_none() {
entry.0 = reference.exe.clone();
}
if entry.1.is_none() {
entry.1 = reference.cwd.clone();
}
for fd in &reference.fds {
if !entry.2.contains(fd) {
entry.2.push(*fd);
}
}
}
merged
.into_iter()
.map(|(pid, (exe, cwd, mut fds))| {
fds.sort_unstable();
ProcessRef {
pid,
exe,
cwd,
fds,
}
})
.collect()
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ProcSnapshot {
refs: BTreeMap<PathBuf, Vec<ProcessRef>>,
pub(crate) scan_failed: bool,
}
impl ProcSnapshot {
pub(crate) fn read(proc_root: &Path) -> Self {
let Ok(entries) = std::fs::read_dir(proc_root) else {
return Self {
refs: BTreeMap::new(),
scan_failed: true,
};
};
let mut index: BTreeMap<PathBuf, Vec<ProcessRef>> = BTreeMap::new();
for entry in entries.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let dir = entry.path();
let exe = std::fs::read_link(dir.join("exe")).ok();
let cwd = std::fs::read_link(dir.join("cwd")).ok();
if let Some(exe_path) = &exe {
index
.entry(strip_deleted(exe_path))
.or_default()
.push(ProcessRef {
pid,
exe: Some(exe_path.clone()),
cwd: cwd.clone(),
fds: Vec::new(),
});
}
if let Some(cwd) = &cwd {
index.entry(strip_deleted(cwd)).or_default().push(ProcessRef {
pid,
exe: exe.clone(),
cwd: Some(cwd.clone()),
fds: Vec::new(),
});
}
let Ok(fd_entries) = std::fs::read_dir(dir.join("fd")) else {
continue;
};
for fd_entry in fd_entries.flatten() {
let Some(fd) = fd_entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let Ok(link) = std::fs::read_link(fd_entry.path()) else {
continue;
};
index
.entry(strip_deleted(&link))
.or_default()
.push(ProcessRef {
pid,
exe: exe.clone(),
cwd: cwd.clone(),
fds: vec![fd],
});
}
}
for refs in index.values_mut() {
refs.sort();
}
Self {
refs: index,
scan_failed: false,
}
}
pub(crate) fn capture(&self, target: &Path) -> ReferenceCapture {
if self.scan_failed {
return ReferenceCapture {
refs: Vec::new(),
scan_failed: true,
};
}
let mut refs: Vec<ProcessRef> = Vec::new();
if let Some(exact) = self.refs.get(target) {
refs.extend(exact.iter().cloned());
}
let prefix = target.to_path_buf();
for (path, entries) in self.refs.range(prefix.clone()..) {
if path == &prefix {
continue; }
if !path.starts_with(&prefix) {
break;
}
refs.extend(entries.iter().cloned());
}
refs.sort();
ReferenceCapture {
refs,
scan_failed: false,
}
}
}
fn strip_deleted(path: &Path) -> PathBuf {
let text = path.to_string_lossy();
match text.strip_suffix(" (deleted)") {
Some(plain) => PathBuf::from(plain),
None => path.to_path_buf(),
}
}
#[cfg(test)]
pub(crate) fn capture_references(proc_root: &Path, target: &Path) -> ReferenceCapture {
let Ok(entries) = std::fs::read_dir(proc_root) else {
return ReferenceCapture {
refs: Vec::new(),
scan_failed: true,
};
};
let mut refs = Vec::new();
for entry in entries.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let dir = entry.path();
let exe = std::fs::read_link(dir.join("exe")).ok();
let cwd = std::fs::read_link(dir.join("cwd")).ok();
let fds = fds_for(&dir, target);
let relevant = exe
.as_ref()
.is_some_and(|exe| exe == target || exe.starts_with(target))
|| cwd
.as_ref()
.is_some_and(|cwd| cwd == target || cwd.starts_with(target))
|| !fds.is_empty();
if relevant {
refs.push(ProcessRef {
pid,
exe,
cwd,
fds,
});
}
}
refs.sort();
ReferenceCapture {
refs,
scan_failed: false,
}
}
#[cfg(test)]
pub(crate) fn blocking_pids(capture: &ReferenceCapture, target: &Path) -> Vec<u32> {
capture
.refs
.iter()
.filter(|r| r.blocks_deletion(target))
.map(|r| r.pid)
.collect()
}
#[cfg(test)]
pub(crate) fn audit_rows(capture: &ReferenceCapture) -> BTreeMap<u32, String> {
capture
.refs
.iter()
.map(|r| (r.pid, r.describe()))
.collect()
}
#[cfg(test)]
mod capture_tests {
use super::*;
#[test]
fn an_open_fd_blocks_deletion() {
let target = PathBuf::from("/home/u/Dev/repo/.git/objects/pack/tmp_pack_a");
let capture = ReferenceCapture {
refs: vec![ProcessRef {
pid: 11,
exe: Some(PathBuf::from("/usr/bin/git")),
cwd: Some(PathBuf::from("/home/u/Dev/repo")),
fds: vec![7],
}],
scan_failed: false,
};
let reason = capture.blocking_reason(&target).expect("must block");
assert!(reason.contains("pid 11"));
assert!(reason.contains("fds=[7]"));
assert_eq!(blocking_pids(&capture, &target), vec![11]);
let rows = audit_rows(&capture);
assert_eq!(rows.len(), 1);
assert!(rows[&11].contains("fds=[7]"));
}
#[test]
fn a_process_merely_running_elsewhere_does_not_block() {
let target = PathBuf::from("/home/u/Dev/repo/.git/objects/pack/tmp_pack_a");
let capture = ReferenceCapture {
refs: vec![ProcessRef {
pid: 12,
exe: Some(PathBuf::from("/usr/bin/git")),
cwd: Some(PathBuf::from("/home/u/Dev/other-repo")),
fds: Vec::new(),
}],
scan_failed: false,
};
assert_eq!(capture.blocking_reason(&target), None);
}
#[test]
fn an_unreadable_proc_scan_is_not_evidence_of_unused() {
let capture = ReferenceCapture {
refs: Vec::new(),
scan_failed: true,
};
assert!(capture
.blocking_reason(Path::new("/anything"))
.expect("unknown must block")
.contains("cannot be verified"));
}
#[test]
fn deleting_a_directory_a_process_sits_in_is_blocked() {
let target = PathBuf::from("/home/u/dracon-lfs-proof");
let capture = ReferenceCapture {
refs: vec![ProcessRef {
pid: 13,
exe: None,
cwd: Some(target.join("sub")),
fds: Vec::new(),
}],
scan_failed: false,
};
assert!(capture.blocking_reason(&target).is_some());
}
#[test]
fn a_clean_capture_describes_itself_explicitly() {
let capture = ReferenceCapture::default();
assert_eq!(
capture.describe(),
"none (no /proc exe, cwd or fd reference)"
);
assert_eq!(capture.blocking_reason(Path::new("/x")), None);
}
#[test]
fn a_deleted_suffix_does_not_hide_a_live_fd() {
let capture = ReferenceCapture {
refs: vec![ProcessRef {
pid: 14,
exe: None,
cwd: None,
fds: vec![3],
}],
scan_failed: false,
};
assert!(capture
.blocking_reason(Path::new("/home/u/x/tmp_pack_a (deleted)"))
.is_some());
}
#[test]
fn a_real_proc_scan_finds_a_live_reference_to_a_file_this_process_holds() {
let dir = tempfile::tempdir().expect("tempdir");
let held = dir.path().join("held.bin");
std::fs::write(&held, b"held").expect("write");
let file = std::fs::File::open(&held).expect("open held file");
let capture = capture_references(Path::new("/proc"), &held);
assert!(!capture.scan_failed, "a live /proc must be readable");
let own_pid = std::process::id();
assert!(
blocking_pids(&capture, &held).contains(&own_pid),
"the process holding the file must be reported: {}",
capture.describe()
);
assert!(
capture
.blocking_reason(&held)
.expect("a held file must block")
.contains(&format!("pid {own_pid}")),
"the reason must name the holding pid"
);
drop(file);
}
#[test]
fn a_released_reference_stops_blocking() {
let dir = tempfile::tempdir().expect("tempdir");
let held = dir.path().join("released.bin");
std::fs::write(&held, b"x").expect("write");
{
let _file = std::fs::File::open(&held).expect("open");
assert!(capture_references(Path::new("/proc"), &held)
.blocking_reason(&held)
.is_some());
}
let capture = capture_references(Path::new("/proc"), &held);
let own = std::process::id();
assert!(
!blocking_pids(&capture, &held).contains(&own),
"this process no longer holds the file: {}",
capture.describe()
);
}
}