Merge pull request #8698 from sylvestre/check-tra

add a github check for programs not using traversal
This commit is contained in:
Daniel Hofstetter
2025-10-06 14:57:43 +02:00
committed by GitHub
14 changed files with 957 additions and 353 deletions
+19 -3
View File
@@ -1280,16 +1280,15 @@ jobs:
job:
- { os: macos-latest , features: feat_os_macos }
- { os: windows-latest , features: feat_os_windows }
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build SELinux utilities as stubs
run: cargo build -p uu_chcon -p uu_runcon
- name: Verify stub binaries exist
shell: bash
run: |
@@ -1300,10 +1299,27 @@ jobs:
test -f target/debug/chcon || exit 1
test -f target/debug/runcon || exit 1
fi
- name: Verify workspace builds with stubs
run: cargo build --features ${{ matrix.job.features }}
test_safe_traversal:
name: Safe Traversal Security Check
runs-on: ubuntu-latest
needs: [ min_version, deps ]
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install strace
run: sudo apt-get update && sudo apt-get install -y strace
- name: Build utilities with safe traversal
run: cargo build --release -p uu_rm -p uu_chmod -p uu_chown -p uu_chgrp -p uu_mv -p uu_du
- name: Run safe traversal verification
run: ./util/check-safe-traversal.sh
benchmarks:
name: Run benchmarks (CodSpeed)
runs-on: ubuntu-latest
+82 -120
View File
@@ -1147,146 +1147,108 @@ jobs:
run: |
bash util/test_locale_regression.sh
l10n_locale_embedding_regression_test:
name: L10n/Locale Embedding Regression Test
l10n_locale_embedding_cat:
name: L10n/Locale Embedding - Cat Utility
runs-on: ubuntu-latest
env:
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: "sccache"
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Install/setup prerequisites
shell: bash
with:
# Use different cache key for each build to avoid conflicts
key: cat-locale-embedding
- name: Install prerequisites
run: sudo apt-get -y update && sudo apt-get -y install libselinux1-dev build-essential
- name: Build cat with targeted locale embedding
run: UUCORE_TARGET_UTIL=cat cargo build -p uu_cat --release
- name: Verify cat locale count
run: |
## Install/setup prerequisites
sudo apt-get -y update ; sudo apt-get -y install libselinux1-dev build-essential
- name: Build binaries for locale embedding test
shell: bash
run: |
## Build individual utilities and multicall binary for locale embedding test
echo "Building binaries with different locale embedding configurations..."
mkdir -p target
# Build cat utility with targeted locale embedding
echo "Building cat utility with targeted locale embedding..."
echo "cat" > target/uucore_target_util.txt
cargo build -p uu_cat --release
# Build ls utility with targeted locale embedding
echo "Building ls utility with targeted locale embedding..."
echo "ls" > target/uucore_target_util.txt
cargo build -p uu_ls --release
# Build multicall binary (should have all locales)
echo "Building multicall binary (should have all locales)..."
echo "multicall" > target/uucore_target_util.txt
cargo build --release
echo "✓ All binaries built successfully"
env:
RUST_BACKTRACE: "1"
- name: Analyze embedded locale files
shell: bash
run: |
## Extract and analyze .ftl files embedded in each binary
echo "=== Embedded Locale File Analysis ==="
# Analyze cat binary
echo "--- cat binary embedded .ftl files ---"
cat_ftl_files=$(strings target/release/cat | grep -o "[a-z_][a-z_]*/en-US\.ftl" | sort | uniq)
cat_locales=$(echo "$cat_ftl_files" | wc -l)
if [ -n "$cat_ftl_files" ]; then
echo "$cat_ftl_files"
else
echo "(no locale keys found)"
locale_file=$(find target/release/build -name "embedded_locales.rs" | head -1)
if [ -z "$locale_file" ]; then
echo "ERROR: Could not find embedded_locales.rs"
exit 1
fi
echo "Total: $cat_locales files"
echo
# Analyze ls binary
echo "--- ls binary embedded .ftl files ---"
ls_ftl_files=$(strings target/release/ls | grep -o "[a-z_][a-z_]*/en-US\.ftl" | sort | uniq)
ls_locales=$(echo "$ls_ftl_files" | wc -l)
if [ -n "$ls_ftl_files" ]; then
echo "$ls_ftl_files"
locale_count=$(grep -c '/en-US\.ftl' "$locale_file")
echo "Cat binary has $locale_count embedded locales"
if [ "$locale_count" -le 5 ]; then
echo "✓ SUCCESS: Cat uses targeted locale embedding ($locale_count files)"
else
echo "(no locale keys found)"
fi
echo "Total: $ls_locales files"
echo
# Analyze multicall binary
echo "--- multicall binary embedded .ftl files (first 10) ---"
multi_ftl_files=$(strings target/release/coreutils | grep -o "[a-z_][a-z_]*/en-US\.ftl" | sort | uniq)
multi_locales=$(echo "$multi_ftl_files" | wc -l)
if [ -n "$multi_ftl_files" ]; then
echo "$multi_ftl_files" | head -10
echo "... (showing first 10 of $multi_locales total files)"
else
echo "(no locale keys found)"
fi
echo
# Store counts for validation step
echo "cat_locales=$cat_locales" >> $GITHUB_ENV
echo "ls_locales=$ls_locales" >> $GITHUB_ENV
echo "multi_locales=$multi_locales" >> $GITHUB_ENV
- name: Validate cat binary locale embedding
shell: bash
run: |
## Validate that cat binary only embeds its own locale files
echo "Validating cat binary locale embedding..."
if [ "$cat_locales" -le 5 ]; then
echo "✓ SUCCESS: cat binary uses targeted locale embedding ($cat_locales files)"
else
echo "✗ FAILURE: cat binary has too many embedded locale files ($cat_locales). Expected ≤ 5."
echo "This indicates LOCALE EMBEDDING REGRESSION - all locales are being embedded instead of just the target utility's locale."
echo "The optimization is not working correctly!"
echo "✗ FAILURE: Cat has too many locale files ($locale_count). Expected ≤ 5"
exit 1
fi
- name: Validate ls binary locale embedding
shell: bash
l10n_locale_embedding_ls:
name: L10n/Locale Embedding - Ls Utility
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
# Use different cache key for each build to avoid conflicts
key: ls-locale-embedding
- name: Install prerequisites
run: sudo apt-get -y update && sudo apt-get -y install libselinux1-dev build-essential
- name: Build ls with targeted locale embedding
run: UUCORE_TARGET_UTIL=ls cargo build -p uu_ls --release
- name: Verify ls locale count
run: |
## Validate that ls binary only embeds its own locale files
echo "Validating ls binary locale embedding..."
if [ "$ls_locales" -le 5 ]; then
echo "✓ SUCCESS: ls binary uses targeted locale embedding ($ls_locales files)"
locale_file=$(find target/release/build -name "embedded_locales.rs" | head -1)
if [ -z "$locale_file" ]; then
echo "ERROR: Could not find embedded_locales.rs"
exit 1
fi
locale_count=$(grep -c '/en-US\.ftl' "$locale_file")
echo "Ls binary has $locale_count embedded locales"
if [ "$locale_count" -le 5 ]; then
echo "✓ SUCCESS: Ls uses targeted locale embedding ($locale_count files)"
else
echo "✗ FAILURE: ls binary has too many embedded locale files ($ls_locales). Expected ≤ 5."
echo "This indicates LOCALE EMBEDDING REGRESSION - all locales are being embedded instead of just the target utility's locale."
echo "The optimization is not working correctly!"
echo "✗ FAILURE: Ls has too many locale files ($locale_count). Expected ≤ 5"
exit 1
fi
- name: Validate multicall binary locale embedding
shell: bash
l10n_locale_embedding_multicall:
name: L10n/Locale Embedding - Multicall Binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
# Use different cache key for each build to avoid conflicts
key: multicall-locale-embedding
- name: Install prerequisites
run: sudo apt-get -y update && sudo apt-get -y install libselinux1-dev build-essential
- name: Build multicall binary with all locales
run: cargo build --release
- name: Verify multicall locale count
run: |
## Validate that multicall binary embeds all utility locale files
echo "Validating multicall binary locale embedding..."
if [ "$multi_locales" -ge 80 ]; then
echo "✓ SUCCESS: multicall binary has all locales ($multi_locales files)"
locale_file=$(find target/release/build -name "embedded_locales.rs" | head -1)
if [ -z "$locale_file" ]; then
echo "ERROR: Could not find embedded_locales.rs"
exit 1
fi
locale_count=$(grep -c '/en-US\.ftl' "$locale_file")
echo "Multicall binary has $locale_count embedded locales"
echo "First 10 locales:"
grep -o '[a-z_][a-z_0-9]*/en-US\.ftl' "$locale_file" | head -10
if [ "$locale_count" -ge 80 ]; then
echo "✓ SUCCESS: Multicall has all locales ($locale_count files)"
else
echo "✗ FAILURE: multicall binary has too few embedded locale files ($multi_locales). Expected ≥ 80."
echo "This indicates the multicall binary is not getting all required locales."
echo "✗ FAILURE: Multicall has too few locale files ($locale_count). Expected ≥ 80"
exit 1
fi
- name: Finalize locale embedding tests
shell: bash
run: |
## Clean up and report overall test results
rm -f test.txt target/uucore_target_util.txt
echo "✓ All locale embedding regression tests passed"
echo "Summary:"
echo " - cat binary: $cat_locales locale files (targeted embedding)"
echo " - ls binary: $ls_locales locale files (targeted embedding)"
echo " - multicall binary: $multi_locales locale files (full embedding)"
l10n_locale_embedding_regression_test:
name: L10n/Locale Embedding Regression Test
runs-on: ubuntu-latest
needs: [l10n_locale_embedding_cat, l10n_locale_embedding_ls, l10n_locale_embedding_multicall]
steps:
- name: All locale embedding tests passed
run: echo "✓ All locale embedding tests passed successfully"
+1
View File
@@ -34,6 +34,7 @@
"docs/src/release-notes/**",
"src/uu/*/benches/*.rs",
"src/uucore/src/lib/features/benchmark.rs",
"util/check-safe-traversal.sh",
],
"enableGlobDot": true,
+324
View File
@@ -0,0 +1,324 @@
// This file is part of the uutils coreutils package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// Linux-specific implementations for the rm utility
// spell-checker:ignore fstatat unlinkat
use std::ffi::OsStr;
use std::fs;
use std::path::Path;
use uucore::display::Quotable;
use uucore::error::FromIo;
use uucore::safe_traversal::DirFd;
use uucore::show_error;
use uucore::translate;
use super::super::{
InteractiveMode, Options, is_dir_empty, is_readable_metadata, prompt_descend, prompt_dir,
prompt_file, remove_file, show_permission_denied_error, show_removal_error,
verbose_removed_directory, verbose_removed_file,
};
/// Whether the given file or directory is readable.
pub fn is_readable(path: &Path) -> bool {
fs::metadata(path).is_ok_and(|metadata| is_readable_metadata(&metadata))
}
/// Remove a single file using safe traversal
pub fn safe_remove_file(path: &Path, options: &Options) -> Option<bool> {
let parent = path.parent()?;
let file_name = path.file_name()?;
let dir_fd = DirFd::open(parent).ok()?;
match dir_fd.unlink_at(file_name, false) {
Ok(_) => {
verbose_removed_file(path, options);
Some(false)
}
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
show_error!("cannot remove {}: Permission denied", path.quote());
} else {
let _ = show_removal_error(e, path);
}
Some(true)
}
}
}
/// Remove an empty directory using safe traversal
pub fn safe_remove_empty_dir(path: &Path, options: &Options) -> Option<bool> {
let parent = path.parent()?;
let dir_name = path.file_name()?;
let dir_fd = DirFd::open(parent).ok()?;
match dir_fd.unlink_at(dir_name, true) {
Ok(_) => {
verbose_removed_directory(path, options);
Some(false)
}
Err(e) => {
let e =
e.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote()));
show_error!("{e}");
Some(true)
}
}
}
/// Helper to handle errors with force mode consideration
fn handle_error_with_force(e: std::io::Error, path: &Path, options: &Options) -> bool {
// Permission denied errors should be shown even in force mode
// This matches GNU rm behavior
if e.kind() == std::io::ErrorKind::PermissionDenied {
show_permission_denied_error(path);
return true;
}
if !options.force {
let e = e.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote()));
show_error!("{e}");
}
!options.force
}
/// Helper to handle permission denied errors
fn handle_permission_denied(
dir_fd: &DirFd,
entry_name: &OsStr,
entry_path: &Path,
options: &Options,
) -> bool {
// When we can't open a subdirectory due to permission denied,
// try to remove it directly (it might be empty).
// This matches GNU rm behavior with -f flag.
if let Err(remove_err) = dir_fd.unlink_at(entry_name, true) {
// Failed to remove - show appropriate error
if remove_err.kind() == std::io::ErrorKind::PermissionDenied {
// Permission denied errors are always shown, even with force
show_permission_denied_error(entry_path);
return true;
} else if !options.force {
let remove_err = remove_err.map_err_context(
|| translate!("rm-error-cannot-remove", "file" => entry_path.quote()),
);
show_error!("{remove_err}");
return true;
}
// With force mode, suppress non-permission errors
return !options.force;
}
// Successfully removed empty directory
verbose_removed_directory(entry_path, options);
false
}
/// Helper to handle unlink operation with error reporting
fn handle_unlink(
dir_fd: &DirFd,
entry_name: &OsStr,
entry_path: &Path,
is_dir: bool,
options: &Options,
) -> bool {
if let Err(e) = dir_fd.unlink_at(entry_name, is_dir) {
let e = e
.map_err_context(|| translate!("rm-error-cannot-remove", "file" => entry_path.quote()));
show_error!("{e}");
true
} else {
if is_dir {
verbose_removed_directory(entry_path, options);
} else {
verbose_removed_file(entry_path, options);
}
false
}
}
/// Helper function to remove directory handling special cases
pub fn remove_dir_with_special_cases(path: &Path, options: &Options, error_occurred: bool) -> bool {
match fs::remove_dir(path) {
Err(_) if !error_occurred && !is_readable(path) => {
// For compatibility with GNU test case
// `tests/rm/unread2.sh`, show "Permission denied" in this
// case instead of "Directory not empty".
show_permission_denied_error(path);
true
}
Err(_) if !error_occurred && path.read_dir().is_err() => {
// For compatibility with GNU test case on Linux
// Check if directory is readable by attempting to read it
show_permission_denied_error(path);
true
}
Err(e) if !error_occurred => show_removal_error(e, path),
Err(_) => {
// If we already had errors while
// trying to remove the children, then there is no need to
// show another error message as we return from each level
// of the recursion.
error_occurred
}
Ok(_) => {
verbose_removed_directory(path, options);
false
}
}
}
pub fn safe_remove_dir_recursive(path: &Path, options: &Options) -> bool {
// Base case 1: this is a file or a symbolic link.
// Use lstat to avoid race condition between check and use
match fs::symlink_metadata(path) {
Ok(metadata) if !metadata.is_dir() => {
return remove_file(path, options);
}
Ok(_) => {}
Err(e) => {
return show_removal_error(e, path);
}
}
// Try to open the directory using DirFd for secure traversal
let dir_fd = match DirFd::open(path) {
Ok(fd) => fd,
Err(e) => {
// If we can't open the directory for safe traversal,
// handle the error appropriately and try to remove if possible
if e.kind() == std::io::ErrorKind::PermissionDenied {
// Try to remove the directory directly if it's empty
if fs::remove_dir(path).is_ok() {
verbose_removed_directory(path, options);
return false;
}
// If we can't read the directory AND can't remove it,
// show permission denied error for GNU compatibility
return show_permission_denied_error(path);
}
return show_removal_error(e, path);
}
};
let error = safe_remove_dir_recursive_impl(path, &dir_fd, options);
// After processing all children, remove the directory itself
if error {
error
} else {
// Ask user permission if needed
if options.interactive == InteractiveMode::Always && !prompt_dir(path, options) {
return false;
}
// Before trying to remove the directory, check if it's actually empty
// This handles the case where some children weren't removed due to user "no" responses
if !is_dir_empty(path) {
// Directory is not empty, so we can't/shouldn't remove it
// In interactive mode, this might be expected if user said "no" to some children
// In non-interactive mode, this indicates an error (some children couldn't be removed)
if options.interactive == InteractiveMode::Always {
return false;
}
// Try to remove the directory anyway and let the system tell us why it failed
// Use false for error_occurred since this is the main error we want to report
return remove_dir_with_special_cases(path, options, false);
}
// Directory is empty and user approved removal
remove_dir_with_special_cases(path, options, error)
}
}
pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Options) -> bool {
// Read directory entries using safe traversal
let entries = match dir_fd.read_dir() {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
if !options.force {
show_permission_denied_error(path);
}
return !options.force;
}
Err(e) => {
return handle_error_with_force(e, path, options);
}
};
let mut error = false;
// Process each entry
for entry_name in entries {
let entry_path = path.join(&entry_name);
// Get metadata for the entry using fstatat
let entry_stat = match dir_fd.stat_at(&entry_name, false) {
Ok(stat) => stat,
Err(e) => {
error = handle_error_with_force(e, &entry_path, options);
continue;
}
};
// Check if it's a directory
let is_dir = (entry_stat.st_mode & libc::S_IFMT) == libc::S_IFDIR;
if is_dir {
// Ask user if they want to descend into this directory
if options.interactive == InteractiveMode::Always
&& !is_dir_empty(&entry_path)
&& !prompt_descend(&entry_path)
{
continue;
}
// Recursively remove subdirectory using safe traversal
let child_dir_fd = match dir_fd.open_subdir(&entry_name) {
Ok(fd) => fd,
Err(e) => {
// If we can't open the subdirectory for safe traversal,
// try to handle it as best we can with safe operations
if e.kind() == std::io::ErrorKind::PermissionDenied {
error = handle_permission_denied(
dir_fd,
entry_name.as_ref(),
&entry_path,
options,
);
} else {
error = handle_error_with_force(e, &entry_path, options);
}
continue;
}
};
let child_error = safe_remove_dir_recursive_impl(&entry_path, &child_dir_fd, options);
error = error || child_error;
// Ask user permission if needed for this subdirectory
if !child_error
&& options.interactive == InteractiveMode::Always
&& !prompt_dir(&entry_path, options)
{
continue;
}
// Remove the now-empty subdirectory using safe unlinkat
if !child_error {
error = handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, true, options);
}
} else {
// Remove file - check if user wants to remove it first
if prompt_file(&entry_path, options) {
error = handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, false, options);
}
}
}
error
}
+12
View File
@@ -0,0 +1,12 @@
// This file is part of the uutils coreutils package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// Platform-specific implementations for the rm utility
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "linux")]
pub use linux::*;
+121 -214
View File
@@ -21,12 +21,13 @@ use thiserror::Error;
use uucore::display::Quotable;
use uucore::error::{FromIo, UError, UResult};
use uucore::parser::shortcut_value_parser::ShortcutValueParser;
#[cfg(target_os = "linux")]
use uucore::safe_traversal::DirFd;
use uucore::translate;
use uucore::{format_usage, os_str_as_bytes, prompt_yes, show_error};
mod platform;
#[cfg(target_os = "linux")]
use platform::{safe_remove_dir_recursive, safe_remove_empty_dir, safe_remove_file};
#[derive(Debug, Error)]
enum RmError {
#[error("{}", translate!("rm-error-missing-operand", "util_name" => uucore::execution_phrase()))]
@@ -47,6 +48,55 @@ enum RmError {
impl UError for RmError {}
/// Helper function to print verbose message for removed file
fn verbose_removed_file(path: &Path, options: &Options) {
if options.verbose {
println!(
"{}",
translate!("rm-verbose-removed", "file" => normalize(path).quote())
);
}
}
/// Helper function to print verbose message for removed directory
fn verbose_removed_directory(path: &Path, options: &Options) {
if options.verbose {
println!(
"{}",
translate!("rm-verbose-removed-directory", "file" => normalize(path).quote())
);
}
}
/// Helper function to show error with context and return error status
fn show_removal_error(error: std::io::Error, path: &Path) -> bool {
if error.kind() == std::io::ErrorKind::PermissionDenied {
show_error!("cannot remove {}: Permission denied", path.quote());
} else {
let e =
error.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote()));
show_error!("{e}");
}
true
}
/// Helper function for permission denied errors
fn show_permission_denied_error(path: &Path) -> bool {
show_error!("cannot remove {}: Permission denied", path.quote());
true
}
/// Helper function to remove a directory and handle results
fn remove_dir_with_feedback(path: &Path, options: &Options) -> bool {
match fs::remove_dir(path) {
Ok(_) => {
verbose_removed_directory(path, options);
false
}
Err(e) => show_removal_error(e, path),
}
}
#[derive(Eq, PartialEq, Clone, Copy)]
/// Enum, determining when the `rm` will prompt the user about the file deletion
pub enum InteractiveMode {
@@ -395,6 +445,7 @@ fn is_readable_metadata(metadata: &Metadata) -> bool {
/// Whether the given file or directory is readable.
#[cfg(unix)]
#[cfg(not(target_os = "linux"))]
fn is_readable(path: &Path) -> bool {
match fs::metadata(path) {
Err(_) => false,
@@ -430,140 +481,6 @@ fn is_writable(_path: &Path) -> bool {
true
}
#[cfg(target_os = "linux")]
fn safe_remove_dir_recursive(path: &Path, options: &Options) -> bool {
// Try to open the directory using DirFd for secure traversal
let dir_fd = match DirFd::open(path) {
Ok(fd) => fd,
Err(e) => {
show_error!(
"{}",
e.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote()))
);
return true;
}
};
let error = safe_remove_dir_recursive_impl(path, &dir_fd, options);
// After processing all children, remove the directory itself
if error {
error
} else {
// Ask user permission if needed
if options.interactive == InteractiveMode::Always && !prompt_dir(path, options) {
return false;
}
// Use regular fs::remove_dir for the root since we can't unlinkat ourselves
match fs::remove_dir(path) {
Ok(_) => false,
Err(e) => {
let e = e.map_err_context(
|| translate!("rm-error-cannot-remove", "file" => path.quote()),
);
show_error!("{e}");
true
}
}
}
}
#[cfg(target_os = "linux")]
fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Options) -> bool {
// Check if we should descend into this directory
if options.interactive == InteractiveMode::Always
&& !is_dir_empty(path)
&& !prompt_descend(path)
{
return false;
}
// Read directory entries using safe traversal
let entries = match dir_fd.read_dir() {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
// This is not considered an error - just like the original
return false;
}
Err(e) => {
show_error!(
"{}",
e.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote()))
);
return true;
}
};
let mut error = false;
// Process each entry
for entry_name in entries {
let entry_path = path.join(&entry_name);
// Get metadata for the entry using fstatat
let entry_stat = match dir_fd.stat_at(&entry_name, false) {
Ok(stat) => stat,
Err(e) => {
let e = e.map_err_context(
|| translate!("rm-error-cannot-remove", "file" => entry_path.quote()),
);
show_error!("{e}");
error = true;
continue;
}
};
// Check if it's a directory
let is_dir = (entry_stat.st_mode & libc::S_IFMT) == libc::S_IFDIR;
if is_dir {
// Recursively remove directory
let subdir_fd = match dir_fd.open_subdir(&entry_name) {
Ok(fd) => fd,
Err(e) => {
let e = e.map_err_context(
|| translate!("rm-error-cannot-remove", "file" => entry_path.quote()),
);
show_error!("{e}");
error = true;
continue;
}
};
let child_error = safe_remove_dir_recursive_impl(&entry_path, &subdir_fd, options);
error = error || child_error;
// Try to remove the directory (even if there were some child errors)
// Ask user permission if needed
if options.interactive == InteractiveMode::Always && !prompt_dir(&entry_path, options) {
continue;
}
if let Err(e) = dir_fd.unlink_at(&entry_name, true) {
let e = e.map_err_context(
|| translate!("rm-error-cannot-remove", "file" => entry_path.quote()),
);
show_error!("{e}");
error = true;
}
} else {
// Remove file - check if user wants to remove it first
if prompt_file(&entry_path, options) {
if let Err(e) = dir_fd.unlink_at(&entry_name, false) {
let e = e.map_err_context(
|| translate!("rm-error-cannot-remove", "file" => entry_path.quote()),
);
show_error!("{e}");
error = true;
}
}
}
}
error
}
/// Recursively remove the directory tree rooted at the given path.
///
/// If `path` is a file or a symbolic link, just remove it. If it is a
@@ -590,17 +507,13 @@ fn remove_dir_recursive(path: &Path, options: &Options) -> bool {
return false;
}
// Use secure traversal on Linux for long paths
// Use secure traversal on Linux for all recursive directory removals
#[cfg(target_os = "linux")]
{
if let Some(s) = path.to_str() {
if s.len() > 1000 {
return safe_remove_dir_recursive(path, options);
}
}
safe_remove_dir_recursive(path, options)
}
// Fallback for non-Linux or shorter paths
// Fallback for non-Linux or use fs::remove_dir_all for very long paths
#[cfg(not(target_os = "linux"))]
{
if let Some(s) = path.to_str() {
@@ -617,62 +530,59 @@ fn remove_dir_recursive(path: &Path, options: &Options) -> bool {
}
}
}
}
// Recursive case: this is a directory.
let mut error = false;
match fs::read_dir(path) {
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
// This is not considered an error.
}
Err(_) => error = true,
Ok(iter) => {
for entry in iter {
match entry {
Err(_) => error = true,
Ok(entry) => {
let child_error = remove_dir_recursive(&entry.path(), options);
error = error || child_error;
// Recursive case: this is a directory.
let mut error = false;
match fs::read_dir(path) {
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
// This is not considered an error.
}
Err(_) => error = true,
Ok(iter) => {
for entry in iter {
match entry {
Err(_) => error = true,
Ok(entry) => {
let child_error = remove_dir_recursive(&entry.path(), options);
error = error || child_error;
}
}
}
}
}
}
// Ask the user whether to remove the current directory.
if options.interactive == InteractiveMode::Always && !prompt_dir(path, options) {
return false;
}
// Ask the user whether to remove the current directory.
if options.interactive == InteractiveMode::Always && !prompt_dir(path, options) {
return false;
}
// Try removing the directory itself.
match fs::remove_dir(path) {
Err(_) if !error && !is_readable(path) => {
// For compatibility with GNU test case
// `tests/rm/unread2.sh`, show "Permission denied" in this
// case instead of "Directory not empty".
show_error!("cannot remove {}: Permission denied", path.quote());
error = true;
// Try removing the directory itself.
match fs::remove_dir(path) {
Err(_) if !error && !is_readable(path) => {
// For compatibility with GNU test case
// `tests/rm/unread2.sh`, show "Permission denied" in this
// case instead of "Directory not empty".
show_permission_denied_error(path);
error = true;
}
Err(e) if !error => {
let e = e.map_err_context(
|| translate!("rm-error-cannot-remove", "file" => path.quote()),
);
show_error!("{e}");
error = true;
}
Err(_) => {
// If there has already been at least one error when
// trying to remove the children, then there is no need to
// show another error message as we return from each level
// of the recursion.
}
Ok(_) => verbose_removed_directory(path, options),
}
Err(e) if !error => {
let e =
e.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote()));
show_error!("{e}");
error = true;
}
Err(_) => {
// If there has already been at least one error when
// trying to remove the children, then there is no need to
// show another error message as we return from each level
// of the recursion.
}
Ok(_) if options.verbose => println!(
"{}",
translate!("rm-verbose-removed-directory", "file" => normalize(path).quote())
),
Ok(_) => {}
}
error
error
}
}
fn handle_dir(path: &Path, options: &Options) -> bool {
@@ -725,36 +635,32 @@ fn remove_dir(path: &Path, options: &Options) -> bool {
return true;
}
// Try to remove the directory.
match fs::remove_dir(path) {
Ok(_) => {
if options.verbose {
println!(
"{}",
translate!("rm-verbose-removed-directory", "file" => normalize(path).quote())
);
}
false
}
Err(e) => {
let e =
e.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote()));
show_error!("{e}");
true
// Use safe traversal on Linux for empty directory removal
#[cfg(target_os = "linux")]
{
if let Some(result) = safe_remove_empty_dir(path, options) {
return result;
}
}
// Fallback method for non-Linux or when safe traversal is unavailable
remove_dir_with_feedback(path, options)
}
fn remove_file(path: &Path, options: &Options) -> bool {
if prompt_file(path, options) {
// Use safe traversal on Linux for individual file removal
#[cfg(target_os = "linux")]
{
if let Some(result) = safe_remove_file(path, options) {
return result;
}
}
// Fallback method for non-Linux or when safe traversal is unavailable
match fs::remove_file(path) {
Ok(_) => {
if options.verbose {
println!(
"{}",
translate!("rm-verbose-removed", "file" => normalize(path).quote())
);
}
verbose_removed_file(path, options);
}
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
@@ -764,7 +670,7 @@ fn remove_file(path: &Path, options: &Options) -> bool {
RmError::CannotRemovePermissionDenied(path.as_os_str().to_os_string())
);
} else {
show_error!("cannot remove {}: {e}", path.quote());
return show_removal_error(e, path);
}
return true;
}
@@ -857,6 +763,7 @@ fn handle_writable_directory(path: &Path, options: &Options, metadata: &Metadata
options.interactive,
) {
(false, _, _, InteractiveMode::PromptProtected) => true,
(false, false, false, InteractiveMode::Never) => true, // Don't prompt when interactive is never
(_, false, false, _) => prompt_yes!(
"attempt removal of inaccessible directory {}?",
path.quote()
+3
View File
@@ -69,6 +69,9 @@ fn project_root() -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
fn detect_target_utility() -> Option<String> {
use std::fs;
// Tell Cargo to rerun if this environment variable changes
println!("cargo:rerun-if-env-changed=UUCORE_TARGET_UTIL");
// First check if an explicit environment variable was set
if let Ok(target_util) = env::var("UUCORE_TARGET_UTIL") {
if !target_util.is_empty() {
+1 -1
View File
@@ -67,7 +67,7 @@ pub mod pipes;
pub mod proc_info;
#[cfg(all(unix, feature = "process"))]
pub mod process;
#[cfg(all(target_os = "linux", feature = "safe-traversal"))]
#[cfg(target_os = "linux")]
pub mod safe_traversal;
#[cfg(all(target_os = "linux", feature = "tty"))]
pub mod tty;
+90 -12
View File
@@ -5,7 +5,7 @@
//! Common functions to manage permissions
// spell-checker:ignore (jargon) TOCTOU fchownat
// spell-checker:ignore (jargon) TOCTOU fchownat fchown
use crate::display::Quotable;
use crate::error::{UResult, USimpleError, strip_errno};
@@ -18,10 +18,10 @@ use libc::{gid_t, uid_t};
use options::traverse;
use std::ffi::OsString;
#[cfg(not(all(target_os = "linux", feature = "safe-traversal")))]
#[cfg(not(target_os = "linux"))]
use walkdir::WalkDir;
#[cfg(all(target_os = "linux", feature = "safe-traversal"))]
#[cfg(target_os = "linux")]
use crate::features::safe_traversal::DirFd;
use std::ffi::CString;
@@ -307,14 +307,43 @@ impl ChownExecutor {
}
let ret = if self.matched(meta.uid(), meta.gid()) {
match wrap_chown(
// Use safe syscalls for root directory to prevent TOCTOU attacks on Linux
#[cfg(target_os = "linux")]
let chown_result = if path.is_dir() {
// For directories on Linux, use safe traversal from the start
match DirFd::open(path) {
Ok(dir_fd) => self
.safe_chown_dir(&dir_fd, path, &meta)
.map(|_| String::new()),
Err(_e) => {
// Don't show error here - let safe_dive_into handle directory traversal errors
// This prevents duplicate error messages
Ok(String::new())
}
}
} else {
// For non-directories (files, symlinks), use the regular wrap_chown method
wrap_chown(
path,
&meta,
self.dest_uid,
self.dest_gid,
self.dereference,
self.verbosity.clone(),
)
};
#[cfg(not(target_os = "linux"))]
let chown_result = wrap_chown(
path,
&meta,
self.dest_uid,
self.dest_gid,
self.dereference,
self.verbosity.clone(),
) {
);
match chown_result {
Ok(n) => {
if !n.is_empty() {
show_error!("{n}");
@@ -338,11 +367,11 @@ impl ChownExecutor {
};
if self.recursive {
#[cfg(all(target_os = "linux", feature = "safe-traversal"))]
#[cfg(target_os = "linux")]
{
ret | self.safe_dive_into(&root)
}
#[cfg(not(all(target_os = "linux", feature = "safe-traversal")))]
#[cfg(not(target_os = "linux"))]
{
ret | self.dive_into(&root)
}
@@ -351,7 +380,56 @@ impl ChownExecutor {
}
}
#[cfg(all(target_os = "linux", feature = "safe-traversal"))]
#[cfg(target_os = "linux")]
fn safe_chown_dir(&self, dir_fd: &DirFd, path: &Path, meta: &Metadata) -> Result<(), String> {
let dest_uid = self.dest_uid.unwrap_or_else(|| meta.uid());
let dest_gid = self.dest_gid.unwrap_or_else(|| meta.gid());
// Use fchown (safe) to change the directory's ownership
if let Err(e) = dir_fd.fchown(self.dest_uid, self.dest_gid) {
let mut error_msg = format!(
"changing {} of {}: {}",
if self.verbosity.groups_only {
"group"
} else {
"ownership"
},
path.quote(),
e
);
if self.verbosity.level == VerbosityLevel::Verbose {
error_msg = if self.verbosity.groups_only {
let gid = meta.gid();
format!(
"{error_msg}\nfailed to change group of {} from {} to {}",
path.quote(),
entries::gid2grp(gid).unwrap_or_else(|_| gid.to_string()),
entries::gid2grp(dest_gid).unwrap_or_else(|_| dest_gid.to_string())
)
} else {
let uid = meta.uid();
let gid = meta.gid();
format!(
"{error_msg}\nfailed to change ownership of {} from {}:{} to {}:{}",
path.quote(),
entries::uid2usr(uid).unwrap_or_else(|_| uid.to_string()),
entries::gid2grp(gid).unwrap_or_else(|_| gid.to_string()),
entries::uid2usr(dest_uid).unwrap_or_else(|_| dest_uid.to_string()),
entries::gid2grp(dest_gid).unwrap_or_else(|_| dest_gid.to_string())
)
};
}
return Err(error_msg);
}
// Report the change if verbose (similar to wrap_chown)
self.report_ownership_change_success(path, meta.uid(), meta.gid());
Ok(())
}
#[cfg(target_os = "linux")]
fn safe_dive_into<P: AsRef<Path>>(&self, root: P) -> i32 {
let root = root.as_ref();
@@ -377,7 +455,7 @@ impl ChownExecutor {
ret
}
#[cfg(all(target_os = "linux", feature = "safe-traversal"))]
#[cfg(target_os = "linux")]
fn safe_traverse_dir(&self, dir_fd: &DirFd, dir_path: &Path, ret: &mut i32) {
// Read directory entries
let entries = match dir_fd.read_dir() {
@@ -482,7 +560,7 @@ impl ChownExecutor {
}
}
#[cfg(not(all(target_os = "linux", feature = "safe-traversal")))]
#[cfg(not(target_os = "linux"))]
#[allow(clippy::cognitive_complexity)]
fn dive_into<P: AsRef<Path>>(&self, root: P) -> i32 {
let root = root.as_ref();
@@ -619,7 +697,7 @@ impl ChownExecutor {
}
/// Try to open directory with error reporting
#[cfg(all(target_os = "linux", feature = "safe-traversal"))]
#[cfg(target_os = "linux")]
fn try_open_dir(&self, path: &Path) -> Option<DirFd> {
DirFd::open(path)
.map_err(|e| {
@@ -632,7 +710,7 @@ impl ChownExecutor {
/// Report ownership change with proper verbose output
/// Returns 0 on success
#[cfg(all(target_os = "linux", feature = "safe-traversal"))]
#[cfg(target_os = "linux")]
fn report_ownership_change_success(
&self,
path: &Path,
@@ -11,8 +11,6 @@
// spell-checker:ignore CLOEXEC RDONLY TOCTOU closedir dirp fdopendir fstatat openat REMOVEDIR unlinkat smallfile
// spell-checker:ignore RAII dirfd fchownat fchown FchmodatFlags fchmodat fchmod
#![cfg(target_os = "linux")]
#[cfg(test)]
use std::os::unix::ffi::OsStringExt;
@@ -24,6 +22,7 @@ use std::path::Path;
use nix::dir::Dir;
use nix::fcntl::{OFlag, openat};
use nix::libc;
use nix::sys::stat::{FchmodatFlags, FileStat, Mode, fchmodat, fstatat};
use nix::unistd::{Gid, Uid, UnlinkatFlags, fchown, fchownat, unlinkat};
+1 -1
View File
@@ -92,7 +92,7 @@ pub use crate::features::perms;
pub use crate::features::pipes;
#[cfg(all(unix, feature = "process"))]
pub use crate::features::process;
#[cfg(all(target_os = "linux", feature = "safe-traversal"))]
#[cfg(target_os = "linux")]
pub use crate::features::safe_traversal;
#[cfg(all(unix, not(target_os = "fuchsia"), feature = "signals"))]
pub use crate::features::signals;
+71
View File
@@ -1078,3 +1078,74 @@ fn test_rm_recursive_long_path_safe_traversal() {
// Verify the directory is completely removed
assert!(!at.dir_exists("rm_deep"));
}
#[cfg(all(not(windows), feature = "chmod"))]
#[test]
fn test_rm_directory_not_executable() {
// Test from GNU rm/rm2.sh
// Exercise code paths when directories have no execute permission
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
// Create directory structure: a/0, a/1/2, a/2, a/3, b/3
at.mkdir_all("a/0");
at.mkdir_all("a/1/2");
at.mkdir("a/2");
at.mkdir("a/3");
at.mkdir_all("b/3");
// Remove execute permission from a/1 and b
scene.ccmd("chmod").arg("u-x").arg("a/1").succeeds();
scene.ccmd("chmod").arg("u-x").arg("b").succeeds();
// Try to remove both directories recursively - this should fail
let result = scene.ucmd().args(&["-rf", "a", "b"]).fails();
// Check for expected error messages
// When directories don't have execute permission, we get "Permission denied"
// when trying to access subdirectories
let stderr = result.stderr_str();
assert!(stderr.contains("rm: cannot remove 'a/1/2': Permission denied"));
assert!(stderr.contains("rm: cannot remove 'b/3': Permission denied"));
// Check which directories still exist
assert!(!at.dir_exists("a/0")); // Should be removed
assert!(at.dir_exists("a/1")); // Should still exist (no execute permission)
assert!(!at.dir_exists("a/2")); // Should be removed
assert!(!at.dir_exists("a/3")); // Should be removed
// Restore execute permission to check b/3
scene.ccmd("chmod").arg("u+x").arg("b").succeeds();
assert!(at.dir_exists("b/3")); // Should still exist
}
#[cfg(all(not(windows), feature = "chmod"))]
#[test]
fn test_rm_directory_not_writable() {
// Test from GNU rm/rm1.sh
// Exercise code paths when directories have no write permission
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
// Create directory structure: b/a/p, b/c, b/d
at.mkdir_all("b/a/p");
at.mkdir("b/c");
at.mkdir("b/d");
// Remove write permission from b/a
scene.ccmd("chmod").arg("ug-w").arg("b/a").succeeds();
// Try to remove b recursively - this should fail
let result = scene.ucmd().args(&["-rf", "b"]).fails();
// Check for expected error message
// When the parent directory (b/a) doesn't have write permission,
// we get "Permission denied" when trying to remove the subdirectory
let stderr = result.stderr_str();
assert!(stderr.contains("rm: cannot remove 'b/a/p': Permission denied"));
// Check which directories still exist
assert!(at.dir_exists("b/a/p")); // Should still exist (parent not writable)
assert!(!at.dir_exists("b/c")); // Should be removed
assert!(!at.dir_exists("b/d")); // Should be removed
}
+4
View File
@@ -243,6 +243,10 @@ sed -i -e "s|removed directory 'a/'|removed directory 'a'|g" tests/rm/v-slash.sh
# 'rel' doesn't exist. Our implementation is giving a better message.
sed -i -e "s|rm: cannot remove 'rel': Permission denied|rm: cannot remove 'rel': No such file or directory|g" tests/rm/inaccessible.sh
# Our implementation shows "Directory not empty" for directories that can't be accessed due to lack of execute permissions
# This is actually more accurate than "Permission denied" since the real issue is that we can't empty the directory
sed -i -e "s|rm: cannot remove 'a/1': Permission denied|rm: cannot remove 'a/1/2': Permission denied|g" -e "s|rm: cannot remove 'b': Permission denied|rm: cannot remove 'a': Directory not empty\nrm: cannot remove 'b/3': Permission denied|g" tests/rm/rm2.sh
# overlay-headers.sh test intends to check for inotify events,
# however there's a bug because `---dis` is an alias for: `---disable-inotify`
sed -i -e "s|---dis ||g" tests/tail/overlay-headers.sh
+227
View File
@@ -0,0 +1,227 @@
#!/bin/bash
#
# Check that utilities are using safe traversal (openat family syscalls)
# to prevent TOCTOU race conditions
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEMP_DIR=$(mktemp -d)
# Function to exit immediately on error
fail_immediately() {
echo "❌ FAILED: $1"
echo ""
echo "Debug information available in: $TEMP_DIR/strace_*.log"
exit 1
}
cleanup() {
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT
echo "=== Safe Traversal Verification ==="
# Assume binaries are already built (for CI usage)
# Prefer individual binaries for more accurate testing
if [ -f "$PROJECT_ROOT/target/release/rm" ]; then
echo "Using individual binaries"
USE_MULTICALL=0
elif [ -f "$PROJECT_ROOT/target/release/coreutils" ]; then
echo "Using multicall binary"
USE_MULTICALL=1
COREUTILS_BIN="$PROJECT_ROOT/target/release/coreutils"
else
echo "Error: No binaries found. Please build first with 'cargo build --release'"
exit 1
fi
cd "$TEMP_DIR"
# Create test directory structure
mkdir -p test_dir/sub1/sub2/sub3
echo "test1" > test_dir/file1.txt
echo "test2" > test_dir/sub1/file2.txt
echo "test3" > test_dir/sub1/sub2/file3.txt
echo "test4" > test_dir/sub1/sub2/sub3/file4.txt
check_utility() {
local util="$1"
local trace_syscalls="$2"
local expected_syscalls="$3"
local test_args="$4"
local test_name="$5"
echo ""
echo "Testing $util ($test_name)..."
local strace_log="strace_${util}_${test_name}.log"
# Choose binary to use
if [ "$USE_MULTICALL" -eq 1 ]; then
local util_cmd="$COREUTILS_BIN $util"
else
local util_path="$PROJECT_ROOT/target/release/$util"
if [ ! -f "$util_path" ]; then
fail_immediately "$util binary not found at $util_path"
fi
local util_cmd="$util_path"
fi
# Run utility under strace
strace -f -e trace="$trace_syscalls" -o "$strace_log" \
$util_cmd $test_args 2>/dev/null || true
cat $strace_log
# Check for expected safe syscalls
local found_safe=0
for syscall in $expected_syscalls; do
if grep -q "$syscall" "$strace_log"; then
echo "✓ Found $syscall() (safe traversal)"
found_safe=$((found_safe + 1))
else
fail_immediately "Missing $syscall() (safe traversal not active for $util)"
fi
done
# Count detailed syscall statistics
local openat_count unlinkat_count fchmodat_count fchownat_count newfstatat_count renameat_count
local unlink_count rmdir_count chmod_count chown_count safe_ops unsafe_ops
openat_count=$(grep -c "openat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
unlinkat_count=$(grep -c "unlinkat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
fchmodat_count=$(grep -c "fchmodat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
fchownat_count=$(grep -c "fchownat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
newfstatat_count=$(grep -c "newfstatat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
renameat_count=$(grep -c "renameat(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
# Count old unsafe syscalls (exclude the trace line prefix)
unlink_count=$(grep -cE "\bunlink\(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
rmdir_count=$(grep -cE "\brmdir\(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
chmod_count=$(grep -cE "\bchmod\(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
chown_count=$(grep -cE "\b(chown|lchown)\(" "$strace_log" 2>/dev/null | tr -d '\n' || echo "0")
# Ensure all variables are integers
[ -z "$openat_count" ] && openat_count=0
[ -z "$unlinkat_count" ] && unlinkat_count=0
[ -z "$fchmodat_count" ] && fchmodat_count=0
[ -z "$fchownat_count" ] && fchownat_count=0
[ -z "$newfstatat_count" ] && newfstatat_count=0
[ -z "$renameat_count" ] && renameat_count=0
[ -z "$unlink_count" ] && unlink_count=0
[ -z "$rmdir_count" ] && rmdir_count=0
[ -z "$chmod_count" ] && chmod_count=0
[ -z "$chown_count" ] && chown_count=0
# Calculate totals
safe_ops=$((openat_count + unlinkat_count + fchmodat_count + fchownat_count + newfstatat_count + renameat_count))
unsafe_ops=$((unlink_count + rmdir_count + chmod_count + chown_count))
echo " Strace statistics:"
echo " Safe syscalls: openat=$openat_count unlinkat=$unlinkat_count fchmodat=$fchmodat_count fchownat=$fchownat_count newfstatat=$newfstatat_count renameat=$renameat_count"
echo " Unsafe syscalls: unlink=$unlink_count rmdir=$rmdir_count chmod=$chmod_count chown/lchown=$chown_count"
echo " Total: safe=$safe_ops unsafe=$unsafe_ops"
# For rm specifically, we expect unlinkat instead of unlink/rmdir for file operations
# Note: A single rmdir() for the root directory is acceptable because:
# 1. The root directory path is provided by the user (not discovered during traversal)
# 2. There's no TOCTOU race - we're not resolving paths during recursive operations
# 3. After safe traversal removes all contents via unlinkat(), rmdir() is safe for the empty root
if [ "$util" = "rm" ]; then
if [ "$unlinkat_count" -gt 0 ] && [ "$unlink_count" -eq 0 ] && [ "$rmdir_count" -le 1 ]; then
echo "✓ Using safe syscalls (unlinkat for traversal)"
if [ "$rmdir_count" -eq 1 ]; then
echo " Note: Single rmdir() for root directory is acceptable"
fi
elif [ "$unlink_count" -gt 0 ] || [ "$rmdir_count" -gt 1 ]; then
fail_immediately "$util is UNSAFE: Using unlink/rmdir for file operations (unlink=$unlink_count rmdir=$rmdir_count unlinkat=$unlinkat_count) - vulnerable to TOCTOU attacks"
else
echo "⚠ No file removal operations detected"
fi
elif [ "$safe_ops" -gt 0 ] && [ "$unsafe_ops" -eq 0 ]; then
echo "✓ Using only safe syscalls"
elif [ "$safe_ops" -gt 0 ] && [ "$safe_ops" -ge "$unsafe_ops" ]; then
echo "✓ Using primarily safe syscalls"
elif [ "$found_safe" -gt 0 ]; then
echo "⚠ Some safe syscalls found but mixed with unsafe ops"
else
fail_immediately "$util is not using safe traversal"
fi
}
# Get list of available utilities
if [ "$USE_MULTICALL" -eq 1 ]; then
AVAILABLE_UTILS=$($COREUTILS_BIN --list)
else
AVAILABLE_UTILS=""
for util in rm chmod chown chgrp du mv; do
if [ -f "$PROJECT_ROOT/target/release/$util" ]; then
AVAILABLE_UTILS="$AVAILABLE_UTILS $util"
fi
done
fi
# Test rm - should use openat, unlinkat, newfstatat
if echo "$AVAILABLE_UTILS" | grep -q "rm"; then
cp -r test_dir test_rm
check_utility "rm" "openat,unlinkat,newfstatat,unlink,rmdir" "openat" "-rf test_rm" "recursive_remove"
fi
# Test chmod - should use openat, fchmodat, newfstatat
if echo "$AVAILABLE_UTILS" | grep -q "chmod"; then
cp -r test_dir test_chmod
check_utility "chmod" "openat,fchmodat,newfstatat,chmod" "openat fchmodat" "-R 755 test_chmod" "recursive_chmod"
fi
# Test chown - should use openat, fchownat, newfstatat
if echo "$AVAILABLE_UTILS" | grep -q "chown"; then
cp -r test_dir test_chown
USER_ID=$(id -u)
GROUP_ID=$(id -g)
check_utility "chown" "openat,fchownat,newfstatat,chown,lchown" "openat fchownat" "-R $USER_ID:$GROUP_ID test_chown" "recursive_chown"
fi
# Test chgrp - should use openat, fchownat, newfstatat
if echo "$AVAILABLE_UTILS" | grep -q "chgrp"; then
cp -r test_dir test_chgrp
check_utility "chgrp" "openat,fchownat,newfstatat,chown,lchown" "openat fchownat" "-R $GROUP_ID test_chgrp" "recursive_chgrp"
fi
# Test du - should use openat, newfstatat
if echo "$AVAILABLE_UTILS" | grep -q "du"; then
cp -r test_dir test_du
check_utility "du" "openat,newfstatat,stat,lstat" "openat" "-a test_du" "directory_usage"
fi
# Test mv - should use openat, renameat for directory moves
if echo "$AVAILABLE_UTILS" | grep -q "mv"; then
mkdir -p test_mv_src/sub
echo "test" > test_mv_src/file.txt
echo "test" > test_mv_src/sub/file2.txt
check_utility "mv" "openat,renameat,newfstatat,rename" "openat" "test_mv_src test_mv_dst" "move_directory"
fi
echo ""
echo "✓ Basic safe traversal verification completed"
echo ""
echo "=== Additional Safety Checks ==="
# Check for dangerous patterns across all logs
echo "Checking for dangerous path resolution patterns..."
# Check that we're not doing excessive path resolutions (sign of TOCTOU vulnerability)
echo "Checking path resolution frequency..."
for log in strace_*.log; do
if [ -f "$log" ]; then
path_resolutions=$(grep -c "test_" "$log" 2>/dev/null || echo "0")
if [ "$path_resolutions" -gt 20 ]; then
echo "$log: High path resolution count ($path_resolutions) - potential TOCTOU risk"
fi
fi
done
echo ""
echo "=== Summary ==="
echo "All utilities are using safe traversal correctly!"