Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 63 additions & 13 deletions src/uu/rm/src/rm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -876,21 +876,71 @@ fn prompt_file_permission_readonly(path: &Path, options: &Options, metadata: &Me
}
}

/// Checks if the path is referring to current or parent directory , if it is referring to current or any parent directory in the file tree e.g '/../..' , '../..'
/// Checks if the path is referring to current or parent directory.
///
/// Strips trailing separators, then checks if the last component is "." or "..".
///
/// Examples:
/// - ".", "./" -> true
/// - "..", "../" -> true
/// - "foo/.", "foo/.." -> true
/// - "foo/bar" -> false
fn path_is_current_or_parent_directory(path: &Path) -> bool {
let path_str = os_str_as_bytes(path.as_os_str());
let dir_separator = MAIN_SEPARATOR as u8;
if let Ok(path_bytes) = path_str {
return path_bytes == ([b'.'])
|| path_bytes == ([b'.', dir_separator])
|| path_bytes == ([b'.', b'.'])
|| path_bytes == ([b'.', b'.', dir_separator])
|| path_bytes.ends_with(&[dir_separator, b'.'])
|| path_bytes.ends_with(&[dir_separator, b'.', b'.'])
|| path_bytes.ends_with(&[dir_separator, b'.', dir_separator])
|| path_bytes.ends_with(&[dir_separator, b'.', b'.', dir_separator]);
let Ok(bytes) = os_str_as_bytes(path.as_os_str()) else {
return false;
};

// Strip all trailing separators
let trimmed = match bytes.iter().rposition(|&b| !is_separator(b)) {
Some(i) => &bytes[..=i],
None => return false, // all separators or empty
};

// Extract the last component (after the last separator)
let last = match trimmed.iter().rposition(|&b| is_separator(b)) {
Some(i) => &trimmed[i + 1..],
None => trimmed,
};

last == b"." || last == b".."
}

fn is_separator(b: u8) -> bool {
b == MAIN_SEPARATOR as u8 || cfg!(windows) && b == b'/'
}

#[cfg(test)]
mod path_tests {
use super::*;
use std::path::Path;

#[test]
fn test_path_is_current_or_parent_directory() {
// Current directory
assert!(path_is_current_or_parent_directory(Path::new(".")));
assert!(path_is_current_or_parent_directory(Path::new("./")));
assert!(path_is_current_or_parent_directory(Path::new(".//")));

// Parent directory
assert!(path_is_current_or_parent_directory(Path::new("..")));
assert!(path_is_current_or_parent_directory(Path::new("../")));
assert!(path_is_current_or_parent_directory(Path::new("..//")));

// Nested paths ending in dot/dotdot
assert!(path_is_current_or_parent_directory(Path::new("d/.")));
assert!(path_is_current_or_parent_directory(Path::new("d/./")));
assert!(path_is_current_or_parent_directory(Path::new("d/..")));
assert!(path_is_current_or_parent_directory(Path::new("d/../")));
assert!(path_is_current_or_parent_directory(Path::new("a/b/.")));
assert!(path_is_current_or_parent_directory(Path::new("a/b/..")));

// Not current/parent directory
assert!(!path_is_current_or_parent_directory(Path::new("file")));
assert!(!path_is_current_or_parent_directory(Path::new(".hidden")));
assert!(!path_is_current_or_parent_directory(Path::new("..hidden")));
assert!(!path_is_current_or_parent_directory(Path::new("dir/file")));
assert!(!path_is_current_or_parent_directory(Path::new("...")));
}
false
}

// For directories finding if they are writable or not is a hassle. In Unix we can use the built-in rust crate to check mode bits. But other os don't have something similar afaik
Expand Down
206 changes: 205 additions & 1 deletion tests/by-util/test_rm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore rootlink
// spell-checker:ignore rootlink dotdot rootfile deleteme keepme topfile
#![allow(clippy::stable_sort_primitive)]

use std::process::Stdio;
Expand Down Expand Up @@ -1377,3 +1377,207 @@ fn test_preserve_root_literal_root() {
.stderr_contains("it is dangerous to operate recursively on '/'")
.stderr_contains("use --no-preserve-root to override this failsafe");
}

// Regression tests for Issue #9749: rm -rf ./ and variants silently delete
// current directory contents

/// Test edge cases for dot/dotdot protection with multiple slashes
#[test]
#[cfg(not(windows))]
fn test_dot_protection_multiple_slashes() {
// Test various patterns with multiple slashes - each in a fresh scenario
let test_cases = ["./", ".//", ".///", ".//./", "../", "..//", "..//../"];

for case in &test_cases {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;

at.mkdir("d");
at.touch("file1");
at.touch("d/file2");

let result = ts.ucmd().arg("-rf").arg(case).fails();

// Verify error message - either dot protection or root protection
// depending on temp directory structure
let stderr = result.stderr_str();
assert!(
stderr.contains("refusing to remove") || stderr.contains("dangerous to operate"),
"Expected error message for rm -rf {case}, got: {stderr}"
);

// CRITICAL: Verify no files were silently deleted (the #9749 bug)
assert!(
at.dir_exists("d"),
"Directory 'd' should exist after rm -rf {case}"
);
assert!(
at.file_exists("file1"),
"file1 should exist after rm -rf {case}"
);
assert!(
at.file_exists("d/file2"),
"d/file2 should exist after rm -rf {case}"
);
}
}

/// Test dot/dotdot protection in deeply nested paths
#[test]
#[cfg(not(windows))]
fn test_dot_protection_nested_paths() {
let test_cases = [
"a/b/c/.",
"a/b/c/./",
"a/b/c/..",
"a/b/c/../",
"a/b/.",
"a/b/../",
];

for case in &test_cases {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;

at.mkdir_all("a/b/c");
at.touch("a/b/c/file");
at.touch("rootfile");

let result = ts.ucmd().arg("-rf").arg(case).fails();
result.stderr_contains("refusing to remove");

// Verify structure is intact
assert!(
at.dir_exists("a/b/c"),
"a/b/c should exist after rm -rf {case}"
);
assert!(
at.file_exists("a/b/c/file"),
"a/b/c/file should exist after rm -rf {case}"
);
assert!(
at.file_exists("rootfile"),
"rootfile should exist after rm -rf {case}"
);
}
}

/// Test that normal files/directories with dots in names are NOT protected
#[test]
fn test_normal_files_with_dots_not_protected() {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;

at.touch(".hidden");
at.touch("..double_hidden");
at.touch("file.txt");
at.mkdir("dir.with.dots");
at.touch("dir.with.dots/file");

// These should succeed (not be blocked)
ts.ucmd().arg("-f").arg(".hidden").succeeds();
ts.ucmd().arg("-f").arg("..double_hidden").succeeds();
ts.ucmd().arg("-f").arg("file.txt").succeeds();
ts.ucmd().arg("-rf").arg("dir.with.dots").succeeds();

assert!(!at.file_exists(".hidden"));
assert!(!at.file_exists("..double_hidden"));
assert!(!at.file_exists("file.txt"));
assert!(!at.dir_exists("dir.with.dots"));
}

/// Test mixed valid and invalid arguments
///
/// When rm encounters a dot/directory protection error, it should:
/// 1. Process arguments in order
/// 2. Continue with remaining arguments after reporting the error
/// 3. Report the error
#[test]
#[cfg(not(windows))]
fn test_mixed_valid_and_dot_arguments() {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;

at.mkdir("deleteme");
at.touch("deleteme/file");
at.mkdir("d"); // Create directory for dot test
at.touch("keepme");

let result = ts
.ucmd()
.arg("-rf")
.arg("deleteme")
.arg("d/.") // This should be refused (in a subdir)
.arg("keepme")
.fails();

// Should have deleted deleteme
assert!(!at.dir_exists("deleteme"));

// Should have refused to remove d/.
result.stderr_contains("refusing to remove");

// keepme should also be deleted (rm continues after errors)
assert!(!at.file_exists("keepme"));
}

/// Test Windows-specific edge cases
#[test]
#[cfg(windows)]
fn test_dot_protection_windows_edge_cases() {
// Test Windows backslash patterns - each in a fresh scenario
let test_cases = [".\\", ".\\\\", "..\\", "..\\\\", "d\\.\\", "d\\..\\"];

for case in &test_cases {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;

at.mkdir("d");
at.touch("file1");
at.touch("d\\file2");

let result = ts.ucmd().arg("-rf").arg(case).fails();
result.stderr_contains("refusing to remove");

// Verify no files were deleted
assert!(
at.dir_exists("d"),
"Directory 'd' should exist after rm -rf {case}",
);
assert!(
at.file_exists("file1"),
"file1 should exist after rm -rf {case}",
);
}
}

/// Test symlink targets that resolve to . or ..
///
/// Note: Symlinks to . or .. are handled differently than direct paths.
/// The symlink itself is removed, but the target (current/parent dir) is not.
/// This test verifies symlinks can be removed without affecting the actual directories.
#[cfg(unix)]
#[test]
fn test_symlink_to_dot_protection() {
let ts = TestScenario::new(util_name!());
let at = &ts.fixtures;

at.mkdir("subdir");
at.touch("subdir/file");
at.touch("topfile");

// Create symlinks pointing to . and ..
at.symlink_dir(".", "subdir/dot_link");
at.symlink_dir("..", "subdir/dotdot_link");

// Remove the symlinks themselves (without trailing slash they are just links)
ts.ucmd().arg("-f").arg("subdir/dot_link").succeeds();
ts.ucmd().arg("-f").arg("subdir/dotdot_link").succeeds();

// Verify symlinks are gone but directories still exist
assert!(!at.symlink_exists("subdir/dot_link"));
assert!(!at.symlink_exists("subdir/dotdot_link"));
assert!(at.dir_exists("subdir"));
assert!(at.file_exists("subdir/file"));
assert!(at.file_exists("topfile"));
}
Loading