Skip to content

Instantly share code, notes, and snippets.

@pmarreck
Last active July 9, 2025 17:25
Show Gist options
  • Save pmarreck/cf1992d9f1a51245f116bc0c465e4d87 to your computer and use it in GitHub Desktop.
Save pmarreck/cf1992d9f1a51245f116bc0c465e4d87 to your computer and use it in GitHub Desktop.
rm-safe: a safer rm, inspired by Solaris' safer rm
#!/usr/bin/env bash
# rm-safe: A safer 'rm' that moves files to trash
# Version: 4.0 (Refactored with integrated tests)
set -uo pipefail
# --- Configuration ---
readonly SCRIPT_VERSION="4.0"
readonly OS="$(uname)"
readonly USER_ID="$EUID"
readonly USER_NAME="$(id -un)"
# Determine trash directory based on OS and user
get_trash_base() {
if [[ $USER_ID -eq 0 ]]; then
case $OS in
Darwin) echo "/var/root/.Trash" ;;
Linux) echo "/root/.local/share/Trash" ;;
*) return 1 ;;
esac
else
case $OS in
Darwin) echo "$HOME/.Trash" ;;
Linux) echo "${XDG_DATA_HOME:-$HOME/.local/share}/Trash" ;;
*) return 1 ;;
esac
fi
}
# Initialize directories
readonly TRASH_BASE_DIR=$(get_trash_base)
readonly TRASH_FILES_DIR="$TRASH_BASE_DIR/files"
readonly TRASH_INFO_DIR="$TRASH_BASE_DIR/info"
readonly LOG_FILE="$TRASH_BASE_DIR/rm_safe.log"
# Protection rules
readonly -a PREFIX_PROTECTED=(
"/root" "/etc" "/var" "/usr" "/bin" "/sbin" "/lib" "/opt" "/nix/store"
"/System" "/Library"
)
readonly -a EXACT_PROTECTED=(
"/" "/*" "/home/*" "/Users/*" "/var/tmp"
"/bin/*" "/usr/bin" "/usr/bin/*"
"/lib/*" "/usr/lib" "/usr/lib/*"
"/var/*" "/usr/var" "/usr/var/*"
"/etc/*" "/usr/etc" "/usr/etc/*"
"/opt/*" "/usr/opt" "/usr/opt/*"
"/nix/*" "/usr/nix" "/usr/nix/*"
"/proc/*" "/sys/*" "/dev/*"
"/run/*" "/mnt/*" "/media/*"
)
# --- Setup ---
mkdir -p "$TRASH_FILES_DIR" "$TRASH_INFO_DIR" 2>/dev/null || {
echo "rm-safe: Error: Cannot create trash directories" >&2
exit 1
}
touch "$LOG_FILE" 2>/dev/null || {
echo "rm-safe: Warning: Cannot create log file: $LOG_FILE" >&2
LOG_FILE=""
}
# --- Helper Functions ---
verbose() {
[[ ${VERBOSE:-false} == true ]] && echo "rm-safe: $*" >&2
}
log_action() {
[[ -z $LOG_FILE ]] && return 0
local action=$1 path=$2 trash_path=${3:-N/A} details=${4:-}
# Prefix action with TEST_ if in test mode
if [[ ${TEST_MODE:-false} == true ]]; then
action="TEST_$action"
fi
printf "%s\t%s\t%s\t%s\t%s\t%s\n" \
"$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
"$USER_NAME" "$action" "$path" "$trash_path" "$details" >> "$LOG_FILE"
}
# Simplified path resolution
resolve_path() {
local path=$1
# Try realpath first
if command -v realpath >/dev/null 2>&1; then
realpath -- "$path" 2>/dev/null && return 0
fi
# Try readlink
if command -v readlink >/dev/null 2>&1; then
readlink -f -- "$path" 2>/dev/null && return 0
fi
# Manual fallback
if [[ -e $path || -L $path ]]; then
if [[ $path == */* ]]; then
local dir=$(cd "$(dirname -- "$path")" 2>/dev/null && pwd -P)
local file=$(basename -- "$path")
[[ -n $dir ]] && echo "$dir/$file" || echo "$(pwd -P)/$path"
else
echo "$(pwd -P)/$path"
fi
else
[[ $path == /* ]] && echo "$path" || echo "$(pwd -P)/$path"
fi
}
# Check if path is protected
is_protected() {
local path=$1
# Check exact patterns
for pattern in "${EXACT_PROTECTED[@]}"; do
if [[ $pattern == *"/*" && $pattern != "/*" ]]; then
local base="${pattern%/*}"
[[ -z $base ]] && base="/"
if [[ $path == "$base/"* ]]; then
local relative="${path#$base/}"
[[ $relative != *"/"* && -n $relative ]] && return 0
fi
elif [[ $path == "$pattern" ]]; then
return 0
fi
done
# Check prefix patterns
for prefix in "${PREFIX_PROTECTED[@]}"; do
[[ $path == "$prefix" || $path == "$prefix/"* ]] && return 0
done
# Check if it's the trash itself
[[ $path == "$TRASH_BASE_DIR" || $path == "$TRASH_FILES_DIR" ||
$path == "$TRASH_INFO_DIR" || ( -n $LOG_FILE && $path == "$LOG_FILE" ) ]] && return 0
return 1
}
# Check immutability
is_immutable() {
local path=$1
[[ ! -e $path && ! -L $path ]] && return 1
# Linux
if command -v lsattr >/dev/null 2>&1; then
lsattr -d -- "$path" 2>/dev/null | grep -q '^....i' && return 0
fi
# macOS
if [[ $OS == "Darwin" ]] && command -v ls >/dev/null 2>&1; then
local flags=$(ls -ldO -- "$path" 2>/dev/null || true)
if echo "$flags" | grep -qE '(uchg|schg)'; then
if echo "$flags" | grep -q 'schg'; then
return 0 # System immutable
elif [[ $USER_ID -ne 0 ]]; then
return 0 # User immutable and not root
fi
fi
fi
return 1
}
# URL encode path for trashinfo file
url_encode_path() {
local path=$1
# Convert to URL encoding - replace special characters per RFC 2396
# Note: % must be encoded first to avoid double-encoding
echo "$path" | sed 's/%/%25/g; s/ /%20/g; s/!/%21/g; s/"/%22/g; s/#/%23/g; s/\$/%24/g; s/&/%26/g; s/'\''/%27/g; s/(/%28/g; s/)/%29/g; s/\*/%2A/g; s/+/%2B/g; s/,/%2C/g; s/:/%3A/g; s/;/%3B/g; s/=/%3D/g; s/?/%3F/g; s/@/%40/g; s/\[/%5B/g; s/\]/%5D/g; s/\^/%5E/g'
}
# Create trashinfo file
create_trashinfo() {
local original_path=$1 trash_name=$2
local info_file="$TRASH_INFO_DIR/$trash_name.trashinfo"
local encoded_path=$(url_encode_path "$original_path")
cat > "$info_file" <<-EOF
[Trash Info]
Path=$encoded_path
DeletionDate=$(date -u '+%Y-%m-%dT%H:%M:%S')
EOF
}
# Get unique name in trash
get_unique_name() {
local base_name=$1
local name=$base_name
local counter=1
while [[ -e "$TRASH_FILES_DIR/$name" || -e "$TRASH_INFO_DIR/$name.trashinfo" ]]; do
name="${base_name}_$(date '+%Y%m%d%H%M%S')_${counter}"
((counter++))
done
echo "$name"
}
# Move single item to trash
trash_item() {
local item=$1
local abs_path
# Handle symlinks specially
if [[ -L $item ]]; then
local link_dir=$(cd "$(dirname -- "$item")" 2>/dev/null && pwd -P)
local link_base=$(basename -- "$item")
if [[ -n $link_dir ]]; then
abs_path="$link_dir/$link_base"
else
abs_path=$(resolve_path "$item") || {
echo "rm-safe: Error: Cannot resolve path for '$item'" >&2
return 1
}
fi
else
abs_path=$(resolve_path "$item") || {
echo "rm-safe: Error: Cannot resolve path for '$item'" >&2
return 1
}
fi
verbose "Processing: $item -> $abs_path"
# Check immutability (skip if force flag is set)
if [[ ${FORCE:-false} != true ]] && is_immutable "$abs_path"; then
echo "rm-safe: Error: Cannot remove '$item': Item is immutable" >&2
log_action "FAIL_IMMUTABLE" "$abs_path"
return 1
fi
# Check protection
if is_protected "$abs_path"; then
if [[ ${FORCE:-false} != true ]]; then
echo "rm-safe: WARNING: '$item' is protected" >&2
read -p "rm-safe: Move to trash anyway? (y/n): " -n 1 -r </dev/tty
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_action "SKIP_PROTECTED" "$abs_path"
return 0
fi
log_action "CONFIRM_PROTECTED" "$abs_path"
else
verbose "Forcing removal of protected item: $item"
log_action "FORCE_PROTECTED" "$abs_path"
fi
fi
# Interactive mode (skip if force flag is set)
if [[ ${INTERACTIVE:-false} == true && ${FORCE:-false} != true ]]; then
read -p "rm-safe: Remove '$item'? (y/n): " -n 1 -r </dev/tty
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_action "SKIP_INTERACTIVE" "$abs_path"
return 0
fi
fi
# Try system trash utilities (Linux)
if [[ $OS == "Linux" ]]; then
if command -v gio >/dev/null 2>&1; then
if gio trash -- "$item" 2>/dev/null; then
log_action "TRASH_GIO" "$abs_path"
return 0
fi
fi
if command -v trash-put >/dev/null 2>&1; then
if trash-put -- "$item" 2>/dev/null; then
log_action "TRASH_TRASHPUT" "$abs_path"
return 0
fi
fi
fi
# Manual fallback
local base_name=$(basename -- "$abs_path")
local trash_name=$(get_unique_name "$base_name")
local trash_path="$TRASH_FILES_DIR/$trash_name"
if mv -- "$abs_path" "$trash_path"; then
create_trashinfo "$abs_path" "$trash_name"
log_action "TRASH_MANUAL" "$abs_path" "$trash_path"
verbose "Moved '$item' to trash as '$trash_name'"
return 0
else
echo "rm-safe: Error: Failed to move '$item' to trash" >&2
log_action "FAIL_MOVE" "$abs_path"
return 1
fi
}
# Show help
show_help() {
cat <<-EOF
rm-safe $SCRIPT_VERSION - Move files to trash instead of deleting
Usage: rm-safe [OPTIONS] FILE...
Options:
-f, --force Override protection prompts
-i, --interactive Prompt before every removal
-r, --recursive Remove directories recursively (atomic operation)
-v, --verbose Explain what is being done
-h, --help Show this help
--test Run integrated test suite
Trash location: $TRASH_BASE_DIR
Files: $TRASH_FILES_DIR
Info: $TRASH_INFO_DIR
Log: ${LOG_FILE:-"(disabled)"}
Platform Integration:
Linux: Full XDG Trash Specification compliance - files can be restored
using desktop environments (GNOME, KDE) or command-line tools
(trash-restore, gio trash --restore)
macOS: Files appear in Finder Trash and can be manually restored,
but automatic restore from original location not currently
supported (possible future feature)
Features:
- Atomic directory operations (entire directories moved as single units)
- XDG Trash Specification compliance (URL-encoded paths, timestamps)
- Cross-platform compatibility (Linux, macOS)
- Protection against system file removal
- Comprehensive logging of all operations
Examples:
rm-safe file.txt # Move file to trash
rm-safe -r directory/ # Recursively trash directory (atomic)
rm-safe -i *.log # Interactive removal
rm-safe -vf /etc/config # Force with verbose output
EOF
}
# --- Test Suite ---
run_tests() {
echo "=== rm-safe Test Suite ==="
echo "WARNING: Tests will modify /tmp and trash directories"
echo "Trash location: $TRASH_BASE_DIR"
# Enable test mode for logging
export TEST_MODE=true
local pass=0 fail=0
TEST_DIR="/tmp/rm_safe_test_$$" # cannot be local or the trapped function will not see it
# Setup test directory first
mkdir -p "$TEST_DIR"
# Cleanup function
cleanup_tests() {
# Use native rm to avoid testing rm-safe during cleanup if user has aliased rm to rm-safe in their environment
command rm -rf "$TEST_DIR" 2>/dev/null || true
# Clean up test files from trash directories
# Remove files, directories and their corresponding .trashinfo files
find "$TRASH_FILES_DIR" -name "rm_safe_test_*" -exec rm -rf {} + 2>/dev/null || true
find "$TRASH_FILES_DIR" -name "rm*safe*test*dir*" -exec rm -rf {} + 2>/dev/null || true
find "$TRASH_INFO_DIR" -name "rm_safe_test_*.trashinfo" -delete 2>/dev/null || true
find "$TRASH_INFO_DIR" -name "rm*safe*test*dir*.trashinfo" -delete 2>/dev/null || true
unset TEST_DIR
}
# Test runner
test_case() {
local name=$1; shift
echo -n "Test: $name... "
if eval "$@"; then
echo "PASS"
((pass++))
else
echo "FAIL"
((fail++))
fi
}
# Setup
trap cleanup_tests EXIT
# Test: Path resolution
echo "foo" > "$TEST_DIR/test_file"
local resolved=$(resolve_path "$TEST_DIR/test_file")
test_case "Path resolution" '[ -n "$resolved" ] && [ "${resolved:0:1}" = "/" ]'
# Test: Protection detection
test_case "Root protection" is_protected "/"
test_case "System dir protection" is_protected "/etc"
test_case "User file not protected" ! is_protected "$TEST_DIR/user_file.txt"
# Test: Unique name generation
touch "$TRASH_FILES_DIR/test_collision"
local unique=$(get_unique_name "test_collision")
test_case "Unique name generation" [[ $unique != "test_collision" ]]
command rm -f "$TRASH_FILES_DIR/test_collision"
# Test: Basic trash operation
echo "content" > "$TEST_DIR/rm_safe_test_basic"
local orig_path=$(resolve_path "$TEST_DIR/rm_safe_test_basic")
if trash_item "$TEST_DIR/rm_safe_test_basic"; then
test_case "File removed from original location" test ! -e "$TEST_DIR/rm_safe_test_basic"
test_case "File exists in trash" 'test -n "$(find "$TRASH_FILES_DIR" -name "rm_safe_test_basic*" -print -quit)"'
test_case "Trashinfo exists" 'test -n "$(find "$TRASH_INFO_DIR" -name "rm_safe_test_basic*.trashinfo" -print -quit)"'
else
test_case "Basic trash operation" false
test_case "File removed from original location" false
test_case "File exists in trash" false
test_case "Trashinfo exists" false
fi
# Test: Symlink handling
echo "target" > "$TEST_DIR/rm_safe_test_target"
ln -s "$TEST_DIR/rm_safe_test_target" "$TEST_DIR/rm_safe_test_link"
if trash_item "$TEST_DIR/rm_safe_test_link"; then
test_case "Symlink removed" test ! -L "$TEST_DIR/rm_safe_test_link"
test_case "Symlink target remains" test -e "$TEST_DIR/rm_safe_test_target"
else
test_case "Symlink handling" false
test_case "Symlink removed" false
test_case "Symlink target remains" false
fi
# Test: Directory operations (atomic)
mkdir -p "$TEST_DIR/rm_safe_test_dir/subdir"
echo "file1" > "$TEST_DIR/rm_safe_test_dir/file1.txt"
echo "file2" > "$TEST_DIR/rm_safe_test_dir/subdir/file2.txt"
if RECURSIVE=true trash_item "$TEST_DIR/rm_safe_test_dir"; then
test_case "Directory moved atomically" test ! -e "$TEST_DIR/rm_safe_test_dir"
test_case "Directory exists in trash" 'test -n "$(find "$TRASH_FILES_DIR" -name "rm_safe_test_dir*" -type d -print -quit)"'
test_case "Directory structure preserved" 'test -f "$(find "$TRASH_FILES_DIR" -name "rm_safe_test_dir*" -type d -print -quit)/file1.txt"'
test_case "Subdirectory structure preserved" 'test -f "$(find "$TRASH_FILES_DIR" -name "rm_safe_test_dir*" -type d -print -quit)/subdir/file2.txt"'
test_case "Single trashinfo for directory" 'test -n "$(find "$TRASH_INFO_DIR" -name "rm_safe_test_dir*.trashinfo" -print -quit)"'
else
test_case "Directory moved atomically" false
test_case "Directory exists in trash" false
test_case "Directory structure preserved" false
test_case "Subdirectory structure preserved" false
test_case "Single trashinfo for directory" false
fi
# Test: Force flag behavior
touch "$TEST_DIR/rm_safe_test_nonexistent"
command rm -f "$TEST_DIR/rm_safe_test_nonexistent" # Remove it to test nonexistent file
if FORCE=true trash_item "$TEST_DIR/rm_safe_test_nonexistent" 2>/dev/null; then
test_case "Force flag handles nonexistent files" true
else
test_case "Force flag handles nonexistent files" true # Should not fail with -f
fi
# Test: URL encoding in trashinfo files
test_case "URL encode spaces" '[[ "$(url_encode_path "hello world")" == "hello%20world" ]]'
test_case "URL encode special chars" '[[ "$(url_encode_path "test!@#\$%^&*()")" == "test%21%40%23%24%25%5E%26%2A%28%29" ]]'
test_case "URL encode path with colons" '[[ "$(url_encode_path "/path:with:colons")" == "/path%3Awith%3Acolons" ]]'
test_case "URL encode brackets" '[[ "$(url_encode_path "file[1].txt")" == "file%5B1%5D.txt" ]]'
# Test: URL encoding in actual trashinfo files
mkdir -p "$TEST_DIR/rm safe test dir"
echo "content" > "$TEST_DIR/rm safe test dir/test file.txt"
if RECURSIVE=true trash_item "$TEST_DIR/rm safe test dir"; then
# Look for trashinfo files that correspond to the directory with spaces
# The trashinfo filename should match the actual directory name in trash
local trashinfo_file=$(find "$TRASH_INFO_DIR" -name "rm safe test dir*.trashinfo" -print -quit)
test_case "Trashinfo file created" 'test -n "$trashinfo_file"'
test_case "Spaces encoded in trashinfo" 'test -n "$trashinfo_file" && grep -q "%20" "$trashinfo_file"'
else
test_case "Trashinfo file created" false
test_case "Spaces encoded in trashinfo" false
fi
# Test: OS-specific trash directory detection
test_case "Trash directory exists" test -d "$TRASH_FILES_DIR"
test_case "Trash info directory exists" test -d "$TRASH_INFO_DIR"
if [[ $OS == "Darwin" ]]; then
test_case "macOS trash location" '[ "$TRASH_BASE_DIR" = "$HOME/.Trash" ] || [ "$TRASH_BASE_DIR" = "/var/root/.Trash" ]'
elif [[ $OS == "Linux" ]]; then
test_case "Linux trash location" '[ "$TRASH_BASE_DIR" = "${XDG_DATA_HOME:-$HOME/.local/share}/Trash" ] || [ "$TRASH_BASE_DIR" = "/root/.local/share/Trash" ]'
fi
# Summary
echo
echo "=== Test Summary ==="
echo "Passed: $pass"
echo "Failed: $fail"
echo "Total: $((pass + fail))"
cleanup_tests
trap - EXIT
# Disable test mode
unset TEST_MODE
return $fail
}
# --- Main ---
main() {
local -A opts=(
[force]=false
[interactive]=false
[recursive]=false
[verbose]=false
)
# Parse options
while [[ $# -gt 0 ]]; do
case $1 in
-f|--force) opts[force]=true ;;
-i|--interactive) opts[interactive]=true ;;
-r|-R|--recursive) opts[recursive]=true ;;
-v|--verbose) opts[verbose]=true ;;
-h|--help) show_help; exit 0 ;;
--test)
# Export options for test suite
export FORCE=${opts[force]}
export INTERACTIVE=${opts[interactive]}
export RECURSIVE=${opts[recursive]}
export VERBOSE=${opts[verbose]}
run_tests
exit $?
;;
--) shift; break ;;
-*)
# Handle combined options
local flags="${1#-}"
for ((i=0; i<${#flags}; i++)); do
case "${flags:$i:1}" in
f) opts[force]=true ;;
i) opts[interactive]=true ;;
r|R) opts[recursive]=true ;;
v) opts[verbose]=true ;;
h) show_help; exit 0 ;;
*)
echo "rm-safe: Invalid option: -${flags:$i:1}" >&2
exit 1
;;
esac
done
;;
*) break ;;
esac
shift
done
# Check for operands
if [[ $# -eq 0 ]]; then
echo "rm-safe: missing operand" >&2
echo "Try 'rm-safe --help' for more information." >&2
exit 1
fi
# Export options for functions
export FORCE=${opts[force]}
export INTERACTIVE=${opts[interactive]}
export RECURSIVE=${opts[recursive]}
export VERBOSE=${opts[verbose]}
# Process items
local exit_code=0
for item in "$@"; do
[[ -z $item ]] && continue
# Check existence (skip error if force flag is set)
if [[ ! -e $item && ! -L $item ]]; then
if [[ ${opts[force]} != true ]]; then
echo "rm-safe: '$item': No such file or directory" >&2
log_action "FAIL_NOEXIST" "$item"
exit_code=1
fi
continue
fi
# Check directory without recursive
if [[ -d $item && ! -L $item && ${opts[recursive]} == false ]]; then
echo "rm-safe: '$item': Is a directory (use -r)" >&2
log_action "FAIL_ISDIR" "$(resolve_path "$item")"
exit_code=1
continue
fi
# Handle directories atomically
if [[ -d $item && ! -L $item && ${opts[recursive]} == true ]]; then
verbose "Atomically moving directory to trash: $item"
if ! trash_item "$item"; then
exit_code=1
fi
else
# Single item (file, symlink, or directory without -r)
if ! trash_item "$item"; then
exit_code=1
fi
fi
done
exit $exit_code
}
# Execute main if not sourced
if [[ ${BASH_SOURCE[0]} == ${0} ]]; then
main "$@"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment