use std::path::Path;
use crate::Error;
#[derive(Clone, Copy)]
pub struct RmOptions {
pub recursive: bool,
pub force: bool,
pub max_retries: u32,
pub retry_delay_ms: u64,
}
impl Default for RmOptions {
fn default() -> Self {
Self {
recursive: false,
force: false,
max_retries: 0,
retry_delay_ms: 100,
}
}
}
fn is_not_found(e: &std::io::Error) -> bool {
e.kind() == std::io::ErrorKind::NotFound
}
#[cfg(unix)]
fn retryable(e: &std::io::Error) -> bool {
matches!(
e.raw_os_error(),
Some(c)
if c == libc::EBUSY
|| c == libc::EMFILE
|| c == libc::ENFILE
|| c == libc::ENOTEMPTY
|| c == libc::EPERM
)
}
#[cfg(windows)]
fn retryable(e: &std::io::Error) -> bool {
matches!(e.raw_os_error(), Some(5) | Some(32) | Some(33) | Some(145))
}
fn with_policy<F: FnMut() -> std::io::Result<()>>(
mut op: F,
opts: &RmOptions,
) -> std::io::Result<()> {
let mut attempt: u32 = 0;
loop {
match op() {
Ok(()) => return Ok(()),
Err(e) if is_not_found(&e) && opts.force => return Ok(()),
Err(e) if opts.recursive && attempt < opts.max_retries && retryable(&e) => {
attempt += 1;
std::thread::sleep(std::time::Duration::from_millis(
opts.retry_delay_ms.saturating_mul(u64::from(attempt)),
));
}
Err(e) => return Err(e),
}
}
}
fn io(context: &'static str, source: std::io::Error) -> Error {
Error::Io { context, source }
}
enum EntryKind {
Missing,
Dir,
Other,
}
#[cfg_attr(test, mockall::automock)]
trait RmFs {
fn kind(&self, path: &Path) -> std::io::Result<EntryKind>;
fn remove_file(&self, path: &Path) -> std::io::Result<()>;
fn remove_tree(&self, path: &Path) -> std::io::Result<()>;
fn cwd(&self) -> std::io::Result<std::path::PathBuf>;
fn canonicalize(&self, path: &Path) -> std::io::Result<std::path::PathBuf>;
}
struct OsRmFs;
impl RmFs for OsRmFs {
fn kind(&self, path: &Path) -> std::io::Result<EntryKind> {
match std::fs::symlink_metadata(path) {
Ok(md) if md.is_dir() => Ok(EntryKind::Dir),
Ok(_) => Ok(EntryKind::Other),
Err(e) if is_not_found(&e) => Ok(EntryKind::Missing),
Err(e) => Err(e),
}
}
fn remove_file(&self, path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)
}
fn remove_tree(&self, path: &Path) -> std::io::Result<()> {
std::fs::remove_dir_all(path)
}
#[allow(clippy::disallowed_methods)]
fn cwd(&self) -> std::io::Result<std::path::PathBuf> {
std::env::current_dir()
}
fn canonicalize(&self, path: &Path) -> std::io::Result<std::path::PathBuf> {
std::fs::canonicalize(path)
}
}
fn is_cwd_ancestor_or_root(target: &Path, cwd: &Path) -> bool {
if target.parent().is_none() {
return true;
}
cwd == target || cwd.starts_with(target)
}
fn guard_cwd_and_root<F: RmFs>(fs: &F, path: &Path, opts: &RmOptions) -> Result<(), Error> {
if opts.force {
return Ok(());
}
let target = fs.canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let cwd = fs
.cwd()
.and_then(|c| fs.canonicalize(&c))
.unwrap_or_default();
if is_cwd_ancestor_or_root(&target, &cwd) {
return Err(io(
"refusing to remove the current directory, an ancestor, or the root — pass force to override",
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
));
}
Ok(())
}
pub fn rm(path: &Path, opts: &RmOptions) -> Result<(), Error> {
rm_with(&OsRmFs, path, opts)
}
fn rm_with<F: RmFs>(fs: &F, path: &Path, opts: &RmOptions) -> Result<(), Error> {
guard_cwd_and_root(fs, path, opts)?;
match fs.kind(path) {
Ok(EntryKind::Missing) if opts.force => Ok(()),
Ok(EntryKind::Missing) => Err(Error::NotFound(path.to_path_buf())),
Ok(EntryKind::Other) => with_policy(|| fs.remove_file(path), opts).map_err(|e| io("unlink", e)),
Ok(EntryKind::Dir) if !opts.recursive => Err(io(
"path is a directory (pass recursive)",
std::io::Error::from_raw_os_error(eisdir()),
)),
Ok(EntryKind::Dir) => {
with_policy(|| fs.remove_tree(path), opts).map_err(|e| io("remove tree", e))
}
Err(e) => Err(io("lstat", e)),
}
}
#[cfg(unix)]
fn eisdir() -> i32 {
libc::EISDIR
}
#[cfg(windows)]
fn eisdir() -> i32 {
267
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
fn seed_tree(root: &Path, dirs: usize, per: usize) {
std::fs::create_dir_all(root).unwrap();
for d in 0..dirs {
let sub = root.join(format!("pkg-{d}"));
std::fs::create_dir_all(sub.join("nested")).unwrap();
for f in 0..per {
std::fs::write(sub.join(format!("f{f}.js")), b"module.exports=1\n").unwrap();
std::fs::write(sub.join("nested").join(format!("g{f}.js")), b"x\n").unwrap();
}
}
}
#[test]
fn matches_node_rm_semantics() {
let root = std::env::temp_dir().join(format!("decmpfs-rm-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
seed_tree(&root, 4, 3);
assert!(rm(&root, &RmOptions::default()).is_err());
let keep = std::env::temp_dir().join(format!("decmpfs-rm-keep-{}", std::process::id()));
std::fs::write(&keep, b"keep").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&keep, root.join("link")).unwrap();
let rf = RmOptions {
recursive: true,
force: true,
..RmOptions::default()
};
rm(&root, &rf).unwrap();
assert!(!root.exists(), "tree cleared");
assert!(keep.exists(), "symlink target must survive");
rm(&root, &rf).unwrap();
assert!(matches!(
rm(&root, &RmOptions::default()),
Err(Error::NotFound(_))
));
let f = std::env::temp_dir().join(format!("decmpfs-rm-one-{}", std::process::id()));
std::fs::write(&f, b"x").unwrap();
rm(&f, &RmOptions::default()).unwrap();
assert!(!f.exists());
let _ = std::fs::remove_file(&keep);
}
#[test]
fn safe_guard_blocks_cwd_ancestors_and_root() {
use std::path::Path;
let cwd = Path::new("/a/b/c");
assert!(is_cwd_ancestor_or_root(Path::new("/a/b/c"), cwd), "cwd");
assert!(is_cwd_ancestor_or_root(Path::new("/a/b"), cwd), "ancestor");
assert!(is_cwd_ancestor_or_root(Path::new("/a"), cwd), "ancestor");
assert!(is_cwd_ancestor_or_root(Path::new("/"), cwd), "root");
assert!(
!is_cwd_ancestor_or_root(Path::new("/a/b/c/build"), cwd),
"descendant allowed"
);
assert!(
!is_cwd_ancestor_or_root(Path::new("/a/b/other"), cwd),
"sibling allowed"
);
}
#[cfg(unix)]
#[test]
fn with_policy_covers_retry_force_and_non_retryable() {
use std::io::Error as IoErr;
let recursive = RmOptions {
recursive: true,
force: false,
max_retries: 3,
retry_delay_ms: 0,
};
let mut tries = 0;
let ok = with_policy(
|| {
tries += 1;
if tries < 3 {
Err(IoErr::from_raw_os_error(libc::EBUSY))
} else {
Ok(())
}
},
&recursive,
);
assert!(ok.is_ok());
assert_eq!(tries, 3, "retried twice then succeeded");
let mut n = 0;
let non_recursive = RmOptions {
recursive: false,
..recursive
};
let err = with_policy(
|| {
n += 1;
Err::<(), _>(IoErr::from_raw_os_error(libc::EBUSY))
},
&non_recursive,
);
assert!(err.is_err());
assert_eq!(n, 1, "no retry when not recursive");
let forced = with_policy(
|| Err(IoErr::from(std::io::ErrorKind::NotFound)),
&RmOptions {
force: true,
..RmOptions::default()
},
);
assert!(forced.is_ok());
let mut k = 0;
let hard = with_policy(
|| {
k += 1;
Err::<(), _>(IoErr::from_raw_os_error(libc::EACCES))
},
&recursive,
);
assert!(hard.is_err());
assert_eq!(k, 1, "non-retryable is not retried");
assert!(retryable(&IoErr::from_raw_os_error(libc::ENOTEMPTY)));
assert!(!retryable(&IoErr::from_raw_os_error(libc::EACCES)));
}
#[test]
#[allow(clippy::disallowed_methods)]
fn rm_refuses_cwd_without_force_but_force_overrides_the_guard() {
let cwd = std::env::current_dir().unwrap();
assert!(
rm(&cwd, &RmOptions::default()).is_err(),
"guard must refuse removing the cwd"
);
let f = std::env::temp_dir().join(format!("decmpfs-guard-{}", std::process::id()));
std::fs::write(&f, b"x").unwrap();
let forced = RmOptions {
force: true,
..RmOptions::default()
};
rm(&f, &forced).unwrap();
assert!(!f.exists());
}
fn mock_target() -> std::path::PathBuf {
std::path::PathBuf::from("/mock-target/x")
}
fn mock_guard_passes(m: &mut MockRmFs) {
m.expect_canonicalize().returning(|p| Ok(p.to_path_buf()));
m.expect_cwd()
.returning(|| Ok(std::path::PathBuf::from("/mock-cwd")));
}
fn eacces() -> std::io::Error {
std::io::Error::from(std::io::ErrorKind::PermissionDenied)
}
#[test]
fn rm_with_surfaces_a_non_not_found_kind_error() {
let mut m = MockRmFs::new();
mock_guard_passes(&mut m);
m.expect_kind().returning(|_| Err(eacces()));
assert!(matches!(
rm_with(&m, &mock_target(), &RmOptions::default()),
Err(Error::Io {
context: "lstat",
..
})
));
}
#[test]
fn rm_with_missing_is_force_ok_else_not_found() {
let mut ok = MockRmFs::new();
ok.expect_kind().returning(|_| Ok(EntryKind::Missing));
let forced = RmOptions {
force: true,
..RmOptions::default()
};
assert!(rm_with(&ok, &mock_target(), &forced).is_ok());
let mut miss = MockRmFs::new();
mock_guard_passes(&mut miss);
miss.expect_kind().returning(|_| Ok(EntryKind::Missing));
assert!(matches!(
rm_with(&miss, &mock_target(), &RmOptions::default()),
Err(Error::NotFound(_))
));
}
#[test]
fn rm_with_dir_needs_recursive_then_removes_tree() {
let mut eisdir = MockRmFs::new();
mock_guard_passes(&mut eisdir);
eisdir.expect_kind().returning(|_| Ok(EntryKind::Dir));
assert!(matches!(
rm_with(&eisdir, &mock_target(), &RmOptions::default()),
Err(Error::Io { context, .. }) if context.contains("directory")
));
let mut tree_err = MockRmFs::new();
mock_guard_passes(&mut tree_err);
tree_err.expect_kind().returning(|_| Ok(EntryKind::Dir));
tree_err.expect_remove_tree().returning(|_| Err(eacces()));
let rf = RmOptions {
recursive: true,
..RmOptions::default()
};
assert!(matches!(
rm_with(&tree_err, &mock_target(), &rf),
Err(Error::Io {
context: "remove tree",
..
})
));
let mut tree_ok = MockRmFs::new();
mock_guard_passes(&mut tree_ok);
tree_ok.expect_kind().returning(|_| Ok(EntryKind::Dir));
tree_ok.expect_remove_tree().returning(|_| Ok(()));
assert!(rm_with(&tree_ok, &mock_target(), &rf).is_ok());
}
#[test]
fn rm_with_file_unlink_ok_and_error() {
let mut ok = MockRmFs::new();
mock_guard_passes(&mut ok);
ok.expect_kind().returning(|_| Ok(EntryKind::Other));
ok.expect_remove_file().returning(|_| Ok(()));
assert!(rm_with(&ok, &mock_target(), &RmOptions::default()).is_ok());
let mut err = MockRmFs::new();
mock_guard_passes(&mut err);
err.expect_kind().returning(|_| Ok(EntryKind::Other));
err.expect_remove_file().returning(|_| Err(eacces()));
assert!(matches!(
rm_with(&err, &mock_target(), &RmOptions::default()),
Err(Error::Io {
context: "unlink",
..
})
));
}
#[test]
fn guard_falls_back_when_canonicalize_and_cwd_fail() {
let mut m = MockRmFs::new();
m.expect_canonicalize().returning(|_| Err(eacces()));
m.expect_cwd().returning(|| Err(eacces()));
m.expect_kind().returning(|_| Ok(EntryKind::Missing));
assert!(matches!(
rm_with(&m, &mock_target(), &RmOptions::default()),
Err(Error::NotFound(_))
));
}
#[test]
fn guard_blocks_when_target_is_an_ancestor_of_cwd() {
let mut m = MockRmFs::new();
m.expect_canonicalize().returning(|p| Ok(p.to_path_buf()));
m.expect_cwd()
.returning(|| Ok(std::path::PathBuf::from("/mock-cwd")));
assert!(matches!(
rm_with(&m, std::path::Path::new("/"), &RmOptions::default()),
Err(Error::Io { context, .. }) if context.contains("refusing")
));
}
#[test]
fn os_rmfs_kind_surfaces_a_non_not_found_error() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f");
std::fs::write(&file, b"x").unwrap();
let bogus = file.join("child");
assert!(rm(&bogus, &RmOptions::default()).is_err());
}
#[test]
fn os_rmfs_removes_a_real_tree_and_file() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir_all(sub.join("deep")).unwrap();
std::fs::write(sub.join("a.txt"), b"a").unwrap();
let rf = RmOptions {
recursive: true,
..RmOptions::default()
};
rm(&sub, &rf).unwrap();
assert!(!sub.exists());
let f = dir.path().join("solo");
std::fs::write(&f, b"x").unwrap();
rm(&f, &RmOptions::default()).unwrap();
assert!(!f.exists());
}
#[test]
#[ignore]
fn rmrf_probe() {
let base = std::env::temp_dir().join(format!("decmpfs-rmrf-{}", std::process::id()));
let a = base.join("parallel");
let b = base.join("std");
for d in [&a, &b] {
seed_tree(d, 300, 20);
}
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
let rf = RmOptions {
recursive: true,
force: true,
..RmOptions::default()
};
let t0 = std::time::Instant::now();
rm(&a, &rf).unwrap();
let par = t0.elapsed().as_secs_f64() * 1e3;
let t1 = std::time::Instant::now();
std::fs::remove_dir_all(&b).unwrap();
let base_ms = t1.elapsed().as_secs_f64() * 1e3;
eprintln!(
"rmrf ~12k files — decmpfs::rm ({cores} cores avail): {par:.1} ms | std::fs::remove_dir_all: {base_ms:.1} ms"
);
let _ = std::fs::remove_dir_all(&base);
}
}