Conversation
Merging this PR will improve performance by 24.39%
Performance Changes
Tip Curious why performance improved? Comment Comparing Footnotes
|
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate implementation and test-portability issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Updates Unix rm -rf to safely remove extremely deep directory trees without stack overflow.
Changes:
- Replaces recursive traversal with an explicit stack.
- Bounds open directory descriptors and validates reopened parents.
- Adds a 32K-level hierarchy regression test.
File summaries
| File | Changes | Final findings |
|---|---|---|
src/uu/rm/src/platform/unix.rs |
Implements iterative safe traversal. | Moderate (2 votes): Avoid quadratic PathBuf cloning during unwind. Moderate (1 vote): Fix the descriptor-cap off-by-one. |
tests/by-util/test_rm.rs |
Adds deep-hierarchy coverage. | Moderate (3 votes): Exclude Redox, which uses an unsupported fallback. Nit (1 vote): Correct the descriptor-release comment. |
Review details
Suppressed comments (2)
src/uu/rm/src/platform/unix.rs:580
OPEN_DIR_FDSis 16, but the+ 1means the oldest frame is not closed until 17 frames are stacked. After that assignment, frames 1..16 still hold descriptors andcur_fdholds another, so 17 directory FDs remain open; usechecked_sub(OPEN_DIR_FDS)(or adjust the constant and documentation) to enforce the stated cap.
if let Some(closable) = stack.len().checked_sub(OPEN_DIR_FDS + 1) {
stack[closable].dir_fd = None;
tests/by-util/test_rm.rs:1302
- This comment contradicts the next line:
drop(fd)releases the deepest descriptor, so no directory is kept open whilermruns. Describe the release instead so the test setup rationale is accurate.
// Keeping the deepest directory open makes its removal much slower.
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
e13a1ba to
35d8cc9
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved traversal-safety, platform-compatibility, and regression-test issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
tests/by-util/test_rm.rs:1317
depth > 1024is not tied to the pre-fix failure depth. Because this test also accepts filesystems that stop atENAMETOOLONG, it can pass at a depth where the old recursive implementation still fits on the stack, leaving the regression untested. Require a depth known to exhaust the old implementation, or run the traversal with a deliberately bounded stack while retaining a platform-specific skip when that depth cannot be created.
assert!(
depth > 1024,
"hierarchy is too shallow to be a regression test"
tests/by-util/test_rm.rs:1284
- This test is enabled on AIX and Hurd, but
uucore::safe_traversalis explicitly unavailable on those targets (src/uucore/src/lib/features.rs:88-93). Mirror that platform exclusion here; otherwise the test exercises the fallback implementation (or fails to compile) instead of the traversal fixed by this PR.
#[cfg(all(unix, not(target_os = "redox")))]
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Lite
| #[allow(clippy::unnecessary_cast)] | ||
| let entry_ino = entry_stat.st_ino as u64; |
| /// with the directory being walked, only the deepest [`OPEN_DIR_FDS`] keep | ||
| /// their descriptor; the others get it back through ".." on the way up, so | ||
| /// depth costs memory, not file descriptors. | ||
| #[cfg(not(target_os = "redox"))] |
35d8cc9 to
3c6691a
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate review issues remain regarding cleanup after ENAMETOOLONG and unsupported test targets.
Review details
Suppressed comments (2)
tests/by-util/test_rm.rs:1326
- If
mkdiratsucceeds but the followingopenatreturnsENAMETOOLONG, the leaf remains created whiledepthis not incremented. This cleanup then runs the same traversal and can hit the sameENAMETOOLONGopening that leaf, so the intended skip path can fail and leave the partial tree behind. Unwind the partial tree with fd-relative operations (or otherwise make cleanup independent ofrm) before returning.
if depth < DEPTH {
println!("this filesystem stops nesting at {depth} levels; skipping");
// Still tear the tree down here: the harness cleanup recurses too.
ts.ucmd().arg("-rf").arg("deep").succeeds();
tests/by-util/test_rm.rs:1284
- This test is enabled on AIX and Hurd even though
uucore::safe_traversalis unavailable on those targets (src/uucore/src/lib/features.rs:88-93). Match the safe-traversal target exclusions here so the newnixsetup is not built for unsupported platforms.
#[cfg(all(unix, not(target_os = "redox")))]
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
3c6691a to
20a9462
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Four unresolved moderate findings remain.
Review details
Suppressed comments (4)
src/uu/rm/src/platform/unix.rs:502
- This allocates and copies the entire accumulated path for every directory level. A 32K-deep
rm -rftherefore does quadratic path-copying and allocator work even on the success path; keep the path as a push/pop buffer or materialize it only when a prompt or diagnostic needs it.
let entry_path = path.join(&entry_name);
src/uu/rm/src/platform/unix.rs:405
- These new cfg guards still include AIX and Hurd, but
uucore::safe_traversalis explicitly unavailable on those targets (src/uucore/src/lib/features.rs:88-93andsrc/uucore/src/lib/lib.rs:106-111). Align the rm module/dependency and this traversal code with that shared exclusion (or provide a fallback), otherwise the Unix build cannot compile on those platforms.
#[cfg(not(target_os = "redox"))]
tests/by-util/test_rm.rs:1284
- This test's cfg is broader than the implementation it exercises:
uucore::safe_traversalis not built on AIX or Hurd (seesrc/uucore/src/lib/features.rs:88-93), so the new fd-based test should exclude those targets as well; otherwise those Unix targets can fail to compile or run this test.
#[cfg(all(unix, not(target_os = "redox")))]
tests/by-util/test_rm.rs:1284
- This test is also compiled under
wasi_runner, but the WASI binary uses the non-Unix fallback rather than thisopenat-based traversal, so the test either exercises a different implementation or fails on the 32K-level tree. Mark it ignored under WASI so this regression test only runs where it can validate the changed code.
#[cfg(all(unix, not(target_os = "redox")))]
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
20a9462 to
5c4d4e7
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Fix the deep-tree interactive emptiness check and guard the regression test for wasi_runner.
Review details
Suppressed comments (2)
src/uu/rm/src/platform/unix.rs:576
path_of(&path_buf)can exceedPATH_MAXin the deep-tree case, butis_dir_emptycallsfs::read_diron that pathname and treats any error as “non-empty” (src/uu/rm/src/rm.rs:621-627). As a result,rm -riprompts to descend into empty directories at sufficient depth and can leave the tree when those prompts are declined; perform the emptiness check relative to the already-open directory fd before prompting.
&& !is_dir_empty(path_of(&path_buf))
tests/by-util/test_rm.rs:1284
- This Unix-gated test also runs in the repository's
wasi_runnermode, where only the rm binary is WASI and cannot use the Unix fd-relative traversal needed for this 32K-deep tree. It will therefore fail instead of testing the intended implementation; add a#[cfg_attr(wasi_runner, ignore = ...)]guard (as other Unix syscall tests do).
#[cfg(all(unix, not(target_os = "redox")))]
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
GNU testsuite comparison: |
5c4d4e7 to
a1e7a45
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Four unresolved moderate issues remain in traversal safety, descriptor limits, and test portability/cleanup.
Review details
Suppressed comments (4)
src/uu/rm/src/platform/unix.rs:601
- This remains a stat-then-open race: if an attacker replaces the entry with a different real directory (not a symlink),
O_NOFOLLOWstill succeeds and the walker will recurse into and delete that directory. Compare the opened fd's device/inode withentry_statbefore pushing the frame, as the root is checked above at lines 349-353.
let child_dir_fd = match cur_fd.open_subdir(&entry_name, SymlinkBehavior::NoFollow) {
Ok(fd) => fd,
src/uu/rm/src/platform/unix.rs:645
- At
stack.len() == OPEN_DIR_FDS, the current directory plus all 16 suspended frames are still open becausechecked_sub(OPEN_DIR_FDS)returnsNone; the next descent therefore reaches 17 persistentDirFds (beforeread_dir's temporary duplicate). Close the oldest suspended frame as soon as the stack reaches the configured bound, or adjust the bound to account for the current directory.
if let Some(closable) = stack.len().checked_sub(OPEN_DIR_FDS) {
stack[closable].dir_fd = None;
tests/by-util/test_rm.rs:1284
- Align this test guard with the supported traversal targets and skip it under the WASI runner.
cfg(unix) && !redoxincludes AIX/Hurd, wheresafe_traversalis unavailable, and WASI cannot exercise this host-onlyopenat/mkdirat32K-directory stress test; otherwise this test is not portable across the repository's Unix test matrix.
#[cfg(all(unix, not(target_os = "redox")))]
tests/by-util/test_rm.rs:1316
- When
openatreturnsENAMETOOLONG, the precedingmkdirathas already created the finala, but this branch leaves it behind. The cleanuprm -rf deephits the same failure before it can unlink that directory, so the intended skip path fails and can leave the fixture in place; remove the just-created child through the still-openfdbefore breaking.
Err(Errno::ENAMETOOLONG) => break,
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
safe_remove_dir_recursive_impl recursed once per directory level, so `rm -rf` on a hierarchy tens of thousands of levels deep overflowed the stack and died with SIGSEGV, leaving the tree in place. Walk the tree with an explicit stack instead. Only the deepest 16 levels keep their directory descriptor open; the others get theirs back through ".." on the way up, checked against the recorded device and inode so a directory swapped in mid-walk cannot redirect the removal. Depth is then bounded by memory rather than by the stack or the descriptor limit. Keep the current path in one byte buffer, stepping into an entry and back out by appending and truncating rather than building a fresh PathBuf per entry, which a long path makes expensive. That is 7% fewer instructions than the recursive version on the rm_recursive_tree benchmark. Ask whether a directory is empty through its parent's descriptor too. The path-based check fails past PATH_MAX and a failure counts as non-empty, so `rm -ri` prompted before descending into an empty directory that deep, and left it behind when the prompt was declined. https://bugs.launchpad.net/bugs/2167206 uutils#2949
a1e7a45 to
61d46df
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Retain a fallback or descriptor for non-searchable directories and add a regression test.
Review details
Suppressed comments (1)
src/uu/rm/src/platform/unix.rs:458
openat(child_fd, "..", ...)requires search/execute permission on the current child directory. Once this frame's parent descriptor is evicted, a readable but non-searchable directory at that level makesreopen_parentfail withEACCES, sorm -rfleaves an otherwise removable empty subtree; the previous recursive implementation retained the parent fd and did not have this regression. Keep a fallback/descriptor for this case and add a deep regression test.
let parent_fd = child_fd.open_subdir(OsStr::new(".."), SymlinkBehavior::NoFollow)?;
let info = parent_fd.metadata()?.file_info();
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
safe_remove_dir_recursive_impl recursed once per directory level, so
rm -rfon a hierarchy tens of thousands of levels deep overflowed the stack and died with SIGSEGV, leaving the tree in place.Walk the tree with an explicit stack instead. Only the deepest 16 levels keep their directory descriptor open; the others get theirs back through ".." on the way up, checked against the recorded device and inode so a directory swapped in mid-walk cannot redirect the removal. Depth is then bounded by memory rather than by the stack or the descriptor limit.
https://bugs.launchpad.net/bugs/2167206
#2949