Last active
July 9, 2025 17:25
-
-
Save pmarreck/cf1992d9f1a51245f116bc0c465e4d87 to your computer and use it in GitHub Desktop.
rm-safe: a safer rm, inspired by Solaris' safer rm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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