A Linux recycle bin that actually works — in scripts, cron jobs, and at the desktop.
Unlike safe-rm (which only blocks deletes) or trash-cli (which requires calling trash-put instead of rm), trashd intercepts destructive commands transparently across four independent layers. Scripts that call rm get trash protection without any code changes. Programs that call unlink() directly get caught too. Even statically-linked binaries making raw syscalls are intercepted at the kernel boundary.
Every layer is fail-safe: if trashing fails for any reason, the real delete executes normally. trashd never blocks or hangs a deletion.
User / Script / Cron
│
▼
┌──────────────────────────┐
│ Layer 1: PATH shims │ rm → move to trash
│ /usr/local/lib/trashd/ │ --permanent bypasses
└──────────┬───────────────┘
│ (if shim missed it)
▼
┌──────────────────────────┐
│ Layer 2: LD_PRELOAD │ intercepts unlink(), rmdir()
│ libtrashd_preload.so │ catches Python, Perl, Go, C...
└──────────┬───────────────┘
│ (if preload missed it)
▼
┌──────────────────────────┐
│ Layer 4: seccomp │ traps syscalls at kernel boundary
│ trashd-exec │ catches static binaries, raw syscalls
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ Trash Store │ FreeDesktop.org Trash spec v1.0
│ ~/.local/share/Trash/ │ + per-mountpoint .Trash-$UID/
└──────────┬───────────────┘
│ (Layer 3 watches everything)
▼
┌──────────────────────────┐
│ fanotify daemon │ detects ALL deletions system-wide
│ trashd │ audit log (cannot intercept)
└──────────────────────────┘
All four layers are enabled by default after install. They complement each other:
A drop-in rm replacement installed at /usr/local/lib/trashd/bin/rm, prepended to $PATH via /etc/profile.d/trashd.sh. Intercepts any rm invocation from shell scripts, cron jobs, find -exec, xargs, and interactive use.
Supports all standard GNU rm flags: -r/-R/--recursive, -f/--force, -i (prompt per file), -I (prompt once for 3+ files), -d/--dir (empty directories), -v/--verbose. Adds --permanent and --no-trash to bypass trash when needed.
Discovers the real rm binary via a stashed copy at /usr/local/lib/trashd/real/rm, falling back to PATH search (skipping trashd directories), then /usr/bin/rm. When passing through to real rm, sets TRASH_BYPASS=1 in the child environment so the LD_PRELOAD layer doesn't re-intercept the delete.
In GUI sessions (when $DISPLAY or $WAYLAND_DISPLAY is set), sends a desktop notification via notify-send when files are trashed.
A shared library that hooks unlink(), unlinkat(), rmdir(), and remove() at the libc level using dlsym(RTLD_NEXT, ...). (glibc's remove() calls hidden internal aliases that hooking unlink/rmdir cannot reach, so it is interposed directly.) Catches deletions from any dynamically-linked program — Python's os.remove(), Perl's unlink, Go's os.Remove(), compiled C programs, anything that calls libc.
Enabled system-wide via /etc/ld.so.preload, which install.sh and a native make install register. Distribution packages built from packaging/ install the library without registering it: add its path to /etc/ld.so.preload to enable the layer. The library is intentionally standalone — no dependency on trashd-common or SQLite — to keep the .so small (~870 KB) and avoid pulling heavy dependencies into every process on the system.
Key safety mechanisms:
- Re-entrancy guard — A thread-local
Cell<bool>prevents internalrename()/mkdir()calls during trash operations from re-entering the hookedunlink(). - Trash directory skip — Paths inside
~/.local/share/Trash/,.Trash-$UID/, and.Trash/are never intercepted. This prevents the preload from trashing SQLite journal files and.trashinfocleanup operations. - Seccomp deference — Preload defers after checking ACTIVE and a cookie-specific filter proof. Unrelated filters or stale markers retain preload protection.
- Config change detection — Checks config file mtime every 60 seconds and logs when changes are detected. Full reload requires process restart (intentional — mutating global state in a preload
.sois unsafe).
A system service that monitors all real filesystems for FAN_DELETE and FAN_DELETE_SELF events — files and directories (FAN_ONDIR) — using fanotify with FAN_REPORT_FID | FAN_REPORT_DFID_NAME (requires Linux 5.9+). Detection and audit only — it cannot intercept or prevent deletions.
Resolves deleted file paths by parsing extended fanotify_event_info_fid structs to extract the parent directory's file handle and the deleted filename. The parent is resolved via open_by_handle_at() against cached per-mount O_PATH file descriptors. Logs every deletion with PID, process name, and path.
Runs as a systemd service with AmbientCapabilities=CAP_SYS_ADMIN. Skips virtual filesystems (tmpfs, ramfs, devtmpfs, overlay, squashfs). Uses non-blocking I/O with a 1-second poll timeout.
Trash publication, compression and retirement share a persistent private .trashd/store.lock in each trash root. Cleanup revalidates the listed data identity and metadata version before mutation, so reused IDs cannot inherit old purge or compression selections. Install all rebuilt components together: older writers do not participate in this lock. Existing IDs and trashinfo remain compatible without migration.
The most robust layer. Traps unlink(2), unlinkat(2), and rmdir(2) at the kernel syscall boundary using a BPF seccomp filter with SECCOMP_RET_USER_NOTIF. Catches everything — statically-linked binaries, setuid programs, programs that clear LD_PRELOAD, and raw syscalls.
Process architecture:
-
Child — Installs the BPF seccomp filter via
syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ...), passes the notification file descriptor to the parent viaSCM_RIGHTSover a Unix socketpair, thenexecvp()'s the target command. Explicit wrapping setsPR_SET_NO_NEW_PRIVS;--preserve-privilegesinstalls using CAP_SYS_ADMIN instead and falls back when unavailable. -
Supervisor — Receives notifications via
ioctl(SECCOMP_IOCTL_NOTIF_RECV), asks the ancestor broker to read path arguments and duplicate target directory descriptors, resolves paths through pinned descriptors, applies config filters, and either trashes the file (responding with success) or lets the real syscall execute (responding withSECCOMP_USER_NOTIF_FLAG_CONTINUE). Absolute paths are confined to the target's pinned root. Relative walks that escape a pinned cwd/dirfd (.., symlinks) resolve normally when the target shares the supervisor's root mount and mount namespace; for chrooted or namespaced targets they are handed back to the target kernel, and the supervisor never retries them through a host-namespace display path. A trash on another filesystem (or one withoutRENAME_NOREPLACE, such as NFS) is filled by copying from the pinned parent, astrash()does. trashd's owntrashCLI and rm shim are never intercepted: their deletes are store-internal. Validates notification IDs to mitigate TOCTOU races. -
Watchdog — Holds a
dup()'d copy of the notification fd. Monitors the supervisor viawaitpid(). On supervisor death: immediately drains all pending notifications withCONTINUE(fail-safe — blocked processes resume with real deletes), then forks a new supervisor. If fork fails, enters emergency passthrough mode (respondsCONTINUEuntil no filtered task remains). Once every filtered task has exited, the listener reports hangup and the supervisor and watchdog exit instead of retrying. -
Orchestrator — Remains an ancestor and subreaper for the command and its descendants. It brokers
process_vm_readv()andpidfd_getfd()over a private socket, preserving access under Yamaptrace_scope=1across child forks and supervisor restarts. It forwardsSIGHUP,SIGINT, andSIGTERMthrough pidfds and reaps exited children. Protection remains available until all descendants finish; the wrapper then returns the original command's exit status. Background descendants therefore keep the wrapper running after their parent exits. The wrapper waits with the defaultSIGCHLDdisposition even when it inheritsSIG_IGN(WSL'sloginpasses one to every shell); the command gets the inherited disposition back.
The BPF filter is architecture-specific: x86_64 traps SYS_unlink (87), SYS_unlinkat (263), and SYS_rmdir (84). aarch64 traps only SYS_unlinkat (35) since the other two syscalls don't exist on that architecture.
Automatic nonroot login shells use preload and the PATH shim, preserving normal sudo behavior. Interactive root shells try trashd-exec --preserve-privileges without changing privilege state. Set TRASHD_SECCOMP_AUTO=0 to disable automatic wrapping. TRASHD_SECCOMP_ATTEMPTED prevents profile recursion.
Explicit trashd-exec <command> provides kernel interception but sets irreversible, inherited NoNewPrivs, preventing setuid/file-capability elevation. ACTIVE and a random cookie are published only after memory access, root/cwd and directory-descriptor pinning, openat2, and supervisor readiness succeed. Setup failure reaps the filtered child and execs the command in place of the unfiltered wrapper, with both markers cleared and the inherited signal state restored. No wrapper process stays resident in fallback mode. Supervisor startup failure aborts.
Device or resource busy (os error 16) means an earlier inherited notification filter already owns a listener. Another listener cannot be installed, and namespaces cannot remove the inherited filter. The wrapper reports the downgrade and retains preload/shim fallback where available; it cannot undo privilege restrictions imposed upstream.
Layer 4 provides kernel interception for explicitly wrapped commands and capable root shells. Layer 2 (LD_PRELOAD) provides system-wide fallback coverage for daemons, cron jobs, and non-interactive processes that don't go through profile.d. Preload checks ACTIVE plus the cookie-specific filter proof before deferring.
Layer 1 (shim) catches rm specifically and provides the user-facing flags (--permanent, -i, -v). When it passes through to real rm, it sets TRASH_BYPASS=1 so Layer 2 doesn't re-intercept.
Layer 3 (daemon) runs independently as an audit trail — it sees everything, including deletions that bypass all other layers.
| Scenario | Layer 1 | Layer 2 | Layer 4 |
|---|---|---|---|
rm file.txt in shell |
Catches | — | — |
python3 -c "os.remove(...)" |
— | Catches | — |
| Statically-linked binary | — | Misses | Catches |
setuid program (LD_PRELOAD stripped) |
— | Misses | Catches |
| Cron job | Catches (rm) | Catches (unlink) | Misses |
| Systemd service | — | Catches (unless explicitly bypassed) | — |
git clone https://github.com/faratech/trashd.git
cd trashd
sudo ./install.shRequires Rust (cargo). For a source checkout, run the installer through sudo
from the non-root account that owns the checkout. Compilation runs as that
account in a private staging directory and uses the committed Cargo.lock.
The install script:
- Builds all crates in release mode with locked dependencies
- Installs binaries to
/usr/local/bin/and/usr/local/lib/trashd/ - Stashes the real
rmbinary at/usr/local/lib/trashd/real/rm - Creates the PATH shim at
/usr/local/lib/trashd/bin/rm(+unlinksymlink) - Adds
libtrashd_preload.soto/etc/ld.so.preload(system-wide) - Installs
/etc/profile.d/trashd.sh(PATH shim + seccomp wrapper) - Installs and starts the
trashdsystemd service and a per-usertrashd-cleanup.timer(disabled; enable it withsystemctl --user enable --now trashd-cleanup.timerto purge items older than 30 days weekly) - Installs man pages to
/usr/local/share/man/man1/ - Installs shell completions for bash, zsh, and fish
- Creates global config at
/etc/trashd/config.toml
Start a new shell or run:
source /etc/profile.d/trashd.shsudo ./install.sh --uninstall # remove trashd; KEEP trashed files
sudo ./install.sh --uninstall --purge # also delete every trash directoryRemoves all binaries, libraries, config, man pages, shell completions, the systemd service (and any running daemon process), the PATH/seccomp hook, and the /etc/ld.so.preload entry (removed before deleting the .so to prevent error messages). Trash contents are preserved by default — your deleted files stay recoverable.
Pass --purge to also remove all FreeDesktop.org trash directories across all users and all mount points: ~/.local/share/Trash/ for every user in /home/ and /root, plus $mountpoint/.Trash-$UID/ and $mountpoint/.Trash/ on every mounted filesystem. This permanently destroys trashed files.
make builds as your own user; sudo make install only copies the build
output and never compiles as root:
make
sudo make installOr by hand:
cargo build --release --locked
cp target/release/trash /usr/local/bin/
cp target/release/trashd-rm /usr/local/lib/trashd/bin/rm
cp target/release/libtrashd_preload.so /usr/local/lib/trashd/
cp target/release/trashd-exec /usr/local/bin/
cp target/release/trashd /usr/local/bin/rm file.txt # moved to trash, not deleted
rm -rf project/ # directory moved to trash
rm -i important.txt # prompts before trashing (like real rm)
rm -I *.tmp # prompt once if 3+ files
rm -v file.txt # verbose: "trashed 'file.txt' [file.txt]"
rm -d empty_dir/ # remove empty directory (checks emptiness first)trash ls # list trashed files (all partitions, newest first)
trash ls '*.py' # filter by glob pattern (matches name or full path)
trash find ~/projects # search by original path substring
trash info <id> # show full metadata: command, PID, size, hash, trash dir, storage path
trash restore foo.txt # restore to original location
trash restore foo --to . # restore to current directory
trash restore ~/.local/share/Trash/files/foo.txt # select one entry by its trashed path
trash undo # restore the most recently trashed item
trash purge foo.txt # permanently delete a specific entry
trash empty # permanently empty all trash (prompts for confirmation)
trash empty -y # skip confirmation prompt
trash empty --older 7d # purge items older than 7 days
trash empty --older 2w # purge items older than 2 weeks
trash empty --dry-run # preview what would be deleted (with sizes)
trash du # show largest items in trash, sorted by size
trash du -n 10 # show top 10 largest
trash compress # compress items older than 7 days (native zstd)
trash compress --older 3d # compress items older than 3 days
trash compress --dry-run # preview what would be compressed
trash status # show total size, count, per-partition breakdown
trash log # show recent operations (audit trail)
trash log -n 50 # show last 50 operations
trash fsck # check trash directory integrity
trash fsck --fix # fix orphaned and corrupt entries
trash --version # show versionrestore, purge and info also accept an entry's trashed path (the trashed_path field of trash ls --json). It stays unique when the same name was trashed on two partitions, where the ID alone is ambiguous. Non-UTF-8 names are matched byte for byte.
trash self-update verifies the release's build attestation with the GitHub CLI (gh attestation verify) before running the installer as root; without gh, pass --allow-unverified to install anyway. It only updates installations in a root-owned prefix and leaves package-managed /usr installs to the package manager. trash config edit starts from a fully commented template, so nothing is overridden until you uncomment it.
rm --permanent file.txt # real delete through the shim
rm --no-trash file.txt # same thing
TRASH_BYPASS=1 rm file.txt # real delete via env var
TRASH_BYPASS=1 ./deploy.sh # disable for an entire script# Wrap a single command — catches static binaries, raw syscalls
trashd-exec ./deploy.sh
# Wrap a shell session
trashd-exec bash
# Already active in interactive shells (via profile.d)
echo $TRASHD_SECCOMP_ACTIVE # "1" if active# Per-command
LD_PRELOAD=/usr/local/lib/trashd/libtrashd_preload.so python3 cleanup.py
# System-wide (enabled by default after install via /etc/ld.so.preload)
# Debug logging (shows every interception on stderr)
TRASHD_PRELOAD_LOG=1 rm file.txt
# stderr: [trashd-preload] trashed: /home/user/file.txt -> ...Files are always trashed on the same filesystem to avoid slow cross-device copies. Per the FreeDesktop.org spec:
- Same filesystem as
$HOME→~/.local/share/Trash/ - Different filesystem, shared
.Trash/with sticky bit →$mountpoint/.Trash/$UID/ - Different filesystem, no shared trash →
$mountpoint/.Trash-$UID/ - Both fail → falls back to home trash (cross-device copy)
Filesystem boundaries are detected by comparing st_dev (device IDs) from stat(). Mount points are discovered by parsing /proc/mounts and selecting the longest matching prefix.
$ trash status
Trash Status
Items: 15
Size: 2.3 MB
Per-partition:
/mnt/data (ext4) — 3 items, 1.8 MB
/mnt/data/.Trash-1000
home — 12 items, 512 KB
/home/user/.local/share/Trash
Listing, restore, purge, and empty all work across all partitions automatically.
When the rename cannot work (EXDEV across filesystems, or EINVAL/ENOSYS where RENAME_NOREPLACE is unsupported, e.g. NFS or 9p), trashd copies instead. Any other rename error (EACCES, EPERM, EROFS) is returned as is: the source could not be removed either.
The copy works through pinned descriptors and never re-resolves the path:
- Snapshot — Directories are snapshotted (device, inode, mode, size, mtime, ctime of every node) before copying.
- Copy — Regular files, symlinks (recreated, never followed), directories and FIFOs are copied with exclusive creation; each file is checked to be unchanged after copying and synced. Owner, group and mode are preserved where permitted; set-id bits are dropped when the owner cannot be. Device nodes and sockets cannot be reproduced and abort the copy. Depth-limited to 100 levels.
- Retire — Only nodes that still match the snapshot (or, for a single file, the exact copied version) are removed from the source. Files created or changed during the move stay where they are, and the call reports the move as incomplete while keeping the complete trash entry.
If the copy fails, the orphaned .trashinfo and any partial copy are cleaned up before returning the error.
Four layers, each optional, merged in order:
- Hardcoded defaults — Built into the binary
- Global
/etc/trashd/config.toml— Admin-managed, applies to all users - User
~/.config/trashd/config.toml— Personal overrides - Per-directory
.trashd.toml— Project-level rules (nearest one found walking up to/)
Merge rules:
- Scalars (retention days, size limits, hash algorithm): user overrides global overrides defaults
- Lists (
never_trash,bypass_processes,bypass_paths): user extends global — admin-set patterns cannot be removed by individual users only_trash: user replaces global (it's a whitelist — extending doesn't make sense)
# Paths that should never be trashed (real-deleted instead).
# User configs extend this list — admin patterns can't be removed.
never_trash = [
"/tmp/*",
"/var/tmp/*",
"/var/cache/*",
"/proc/*",
"/sys/*",
"/dev/*",
"/dev/shm/*",
"/run/*",
"*.o",
"*.pyc",
"*.class",
"*.lock",
"*.pid",
"*.sock",
"*.socket",
"*.tmp",
"*.swp",
"*~",
"__pycache__/*",
"node_modules/*",
"target/debug/*",
"target/release/*",
"*/.git/*",
]
# If set, ONLY files matching these patterns are trashed.
# Everything else is real-deleted. never_trash still takes priority.
# Empty (default) means all files are eligible for trash.
only_trash = []
# Processes that bypass trash automatically.
# Checks the deleting process and its ancestors via /proc.
bypass_processes = [
"apt", "apt-get", "dpkg",
"yum", "dnf", "pacman", "rpm",
"pip", "cargo", "npm", "make",
"git",
"journald",
"containerd", "dockerd",
]
# Executable paths that bypass trash (prefix match on /proc/self/exe).
# More precise than bypass_processes — matches the full exe path.
bypass_paths = []
max_file_size_mb = 1024 # files over this skip trash (0 = no limit)
max_dir_size_mb = 0 # directories over this skip trash (0 = no limit)
hash_algorithm = "xxhash" # "xxhash" (XXH3-128, ~10x faster) or "sha256" (cryptographic)
sha256_max_size_mb = 1 # only hash files smaller than this (default: 1 MB)
auto_purge_interval_secs = 60 # min seconds between auto-purge scans (default: 60)
[retention]
max_age_days = 30 # auto-purge items older than this (default: 30)
max_size_gb = 10.0 # cap total trash size (default: 10.0)
disk_pressure_percent = 90 # purge oldest when disk usage exceeds this (default: 90)| Pattern | Example | Matches |
|---|---|---|
prefix/* |
/tmp/* |
Anything starting with /tmp/ |
*.ext |
*.pyc |
Anything ending with .pyc |
*/.infix/* |
*/.git/* |
Anything containing /.git/ in the path |
*/name |
*/core |
The exact core path component and its descendants |
*~ |
*~ |
Anything ending with ~ (editor backups) |
?, [...] |
file?.[ch] |
One character, or a character class/range |
** |
src/**/*.rs |
Like *, spans directory separators |
~/prefix/* |
~/docs/* |
Expands the current user’s home directory |
| exact | /var/run/lock |
Exact string match |
Policy patterns are matched against the file’s absolute path; relative patterns such as src/* can start at any path-component boundary. Wildcards may appear anywhere in a pattern, and * and ** can span directory separators. Brace expansion ({a,b}) is unsupported and produces a warning. trash ls uses the same wildcard syntax against names and absolute paths.
Place a .trashd.toml in any project directory to customize trash behavior for that tree:
# These patterns are checked first (before global config)
never_trash = ["build/*", "dist/*", "*.log"]
only_trash = ["src/*", "*.config", "*.env"]Searched through all parent directories from the file being deleted. Global never_trash still wins over local only_trash — an admin-excluded pattern can't be overridden by a project config.
A .trashd.toml only counts when it is a regular file owned by the deleting user (or root) that no one else can write. Anything else — a symlink, a file owned by another user (a shared directory, a USB stick) or a group/world-writable file — is ignored with a warning and, like an unparseable file, stops the search, so an ancestor's narrower rules never apply in its place. The fanotify daemon never reads per-directory files.
After every trash operation, trashd runs an automatic retention policy (throttled to at most once per auto_purge_interval_secs, default 60 seconds):
Phase 1 — Age purge: Delete items older than retention.max_age_days.
Phase 2 — Auto-compress: Before purging by size, compress items older than 7 days using native zstd (level 3). This reclaims space without losing data — often enough to avoid purging at all. Only trashd's own entries are compressed: entries other FreeDesktop tools created stay readable to those tools. Skips already-compressed files (detected by zstd magic 0xFD2FB528), files under 1 KB, files with extended attributes, and directories. Only replaces the file if compression actually reduced the size; the replacement keeps the original timestamps, and the sidecar changes only by its X-Trashd-Compressed line.
Phase 3 — Size trim: If total trash exceeds retention.max_size_gb, purge the oldest surviving items until under the limit. Sizes recorded in sidecars on removable media never count for more than the tree actually on disk.
Phase 4 — Disk pressure: If the home trash filesystem exceeds retention.disk_pressure_percent usage, purge the oldest 10% of surviving items that are at least an hour old. Recently trashed items are never purged for pressure, so trash undo keeps working right after a delete.
trash compress # compress items older than 7 days
trash compress --older 3d # compress items older than 3 days
trash compress --dry-run # preview without compressingUses native zstd (the zstd Rust crate — no system dependency). Typical results: text files see 95%+ compression; binaries ~50%. Already-compressed files and files under 1 KB are automatically skipped.
When restoring a compressed file, trashd detects the zstd magic header (0x28B52FFD) and decompresses the content in-place before returning it to the user. Hash verification runs against the decompressed content, so the original hash matches correctly. This is fully transparent — the user always gets their original file back.
A file hash is computed for files smaller than sha256_max_size_mb (default 1 MB). The algorithm is configurable:
- xxhash (default) — XXH3-128, ~10x faster than SHA-256. Suitable for integrity verification (detecting bit rot, bad disks, partial copies). Not cryptographic.
- sha256 — SHA-256, cryptographic. Use when you need to verify file authenticity, not just integrity.
The hash is stored in the .trashinfo file as X-Trashd-Hash=.... Older entries may use X-Trashd-SHA256=... — both are read for backward compatibility.
After restoring a file, trashd verifies the hash against the restored content. It tries both xxhash and sha256 (since the algorithm may have changed between trash and restore). If the hash doesn't match, a warning is printed:
trashd: warning: hash mismatch for restored file /home/user/data.csv
expected: af8f72d9a92c0c4fceb70b3c89911f2b
actual: 3cdfb8a99b35162b65d20bd3eda6cafa
file may be corrupted — verify contents before use
The restore still succeeds — the warning is informational, not blocking. The user can decide whether to trust the file.
Changing hash_algorithm in the config does not require rehashing existing entries. Old hashes continue to verify correctly because restore tries both algorithms.
Interception failures can fall back to permanent deletion:
- Shim — Uses real deletion only for explicit bypasses and configured excluded paths. Unsafe or unavailable trash storage returns an error without deleting.
- Preload — Rejects an unsafe trash directory (one another user could control) with
EACCES. A trash that simply cannot be created — a service account whose home is/nonexistentor root-owned — lets the realunlink()/rmdir()run, and root running with another user'sHOMEuses its own home trash. Other operational failures return the result of the real call. - Seccomp — Responds with
SECCOMP_USER_NOTIF_FLAG_CONTINUEwhen pinned resolution or storage is unavailable, so the target kernel executes the syscall in the target's own namespace. - Watchdog — On supervisor crash, drains all pending notifications with
CONTINUE
These fallback paths preserve the calling program's ability to delete, but the files may be unrecoverable. The seccomp wrapper aborts if it installs a filter but cannot establish a supervisor, preventing the command from running with a stranded listener.
trash empty prompts for confirmation before permanently destroying all trash:
Permanently delete 142 items (1.3 GB)? [y/N]
Use -y/--yes to skip the prompt (for scripts).
| Mechanism | Scope | How |
|---|---|---|
rm --permanent / rm --no-trash |
Single command | Shim strips flag, passes to real rm with TRASH_BYPASS=1 |
TRASH_BYPASS=1 |
Environment | Checked by shim, preload, and seccomp init |
bypass_processes |
Process tree | Checks the deleting process and its ancestors via /proc/{pid}/stat |
bypass_paths |
Executable path | Matches /proc/self/exe against prefix list |
never_trash |
File path patterns | Glob matching (global + per-directory) |
only_trash |
File path whitelist | If set, only matching files are trashed |
max_file_size_mb / max_dir_size_mb |
Size limits | Files/directories exceeding the limit are real-deleted |
- Trash entry IDs — Claimed via
O_CREAT|O_EXCLon the.trashinfofile. If two processes trash the same filename simultaneously, each gets a unique ID (appending timestamp + counter). Filenames are truncated to 223 bytes to stay within the 255-byte filesystem limit after adding the.trashinfosuffix. directorysizescache — Written via temp file + atomicrename()per spec.- Operation log — Append-only file, one write per operation.
All metadata operations use symlink_metadata() (equivalent to lstat()) — symlinks are never followed. Trashing a symlink removes the link itself, preserving the target. Dangling symlinks are trashed normally (not silently dropped). During cross-device directory copies, symlinks are recreated via std::os::unix::fs::symlink(), not followed.
Per the FreeDesktop spec: "If info file corresponding to file in $trash/files is unavailable, this is emergency case and MUST be presented as such."
trash ls scans both info/ and files/ directories. Entries in files/ without matching .trashinfo are listed as orphaned:
2026-03-19 22:38 ? (orphaned: mysterious_file) mysterious_file
trash fsck detects three types of problems:
- Orphaned
.trashinfo(no matching file infiles/) - Orphaned files (no matching
.trashinfoininfo/) - Corrupt
.trashinfo(unparseable)
trash fsck --fix removes orphaned .trashinfo files and — after per-item confirmation — orphaned data files. Corrupt .trashinfo metadata is never auto-removed: the data file is intact, so it is preserved in place for manual recovery.
trashd implements the complete FreeDesktop.org Trash specification v1.0:
$XDG_DATA_HOME/Trash/home directory trash with private, current-user-ownedfiles/andinfo/subdirectories$topdir/.Trash/$UID/shared topdir trash (the shared parent must be root/current-user owned, real, and sticky; the UID directory is owner-validated and mode0700)$topdir/.Trash-$UID/private, owner-validated per-user topdir trash (fallback when shared trash validation fails; unsafe candidates fall back to the home trash)$trash/directorysizescache (size, trashinfo mtime, percent-encoded name — updated via atomic rename)
- First line validated as exactly
[Trash Info](files without this header are rejected) Path=— percent-encoded per RFC 2396. Absolute paths for home trash, relative paths (from topdir) for topdir trash. Relative paths validated to not contain..DeletionDate=— ISO 8601YYYY-MM-DDThh:mm:ssin local time. DST ambiguity handled (uses latest time). DST gaps handled (shifts forward 1 hour)- Duplicate keys: first occurrence wins (per spec)
Path=value is not trimmed before decoding (percent-encoded spaces are meaningful)- Unknown keys are ignored (per spec — allows future extension)
[Trash Info]
Path=/home/user/project/main.py
DeletionDate=2026-03-19T14:30:00
X-Trashd-Command=rm -rf project/
X-Trashd-PID=48231
X-Trashd-Size=4096
X-Trashd-Hash=a1b2c3...Desktop file managers (Nautilus, Dolphin, Thunar, Nemo) see the same trash and can restore files trashed by trashd, and vice versa. The extended X-Trashd-* fields are ignored by other implementations per spec.
Every trash, restore, purge, and empty operation is logged to ~/.local/share/Trash/.trashd/operations.log:
2026-03-19T22:51:22 pid=20574 PURGE id=old_backup.tar.gz
2026-03-19T22:51:22 pid=20577 TRASH id=report.pdf path=/home/user/report.pdf cmd=rm report.pdf
2026-03-19T22:51:22 pid=20579 EMPTY count=3 filter=all
2026-03-19T22:51:22 pid=20582 EMPTY count=1 filter=older than 7d
2026-03-19T22:51:22 pid=20598 RESTORE id=report.pdf to=/home/user/report.pdf
View with trash log (default: last 20 lines) or trash log -n 100.
- Same-filesystem trash — A single
rename()syscall. Same speed as a normal delete. - LD_PRELOAD overhead — A few string comparisons per
unlink()call (skip-list check). Negligible for non-trashed files. - fanotify daemon — Kernel delivers events asynchronously. No blocking overhead on the deleting process.
- Hash (xxhash) — XXH3-128 runs at ~10 GB/s. A 1 MB file hashes in ~100 microseconds.
- Auto-purge — Scans all
.trashinfofiles, but only runs once perauto_purge_interval_secs(default 60 seconds). Controlled via a timestamp marker file at~/.local/share/Trash/.trashd/last_purge. - Directory size calculation — Recursive walk capped at 10,000 files. Returns partial size if cap is hit.
- Hash computation — Only for files ≤
sha256_max_size_mb(default 1 MB). Large files skip hashing entirely. - Auto-compression — Runs during auto-purge (already throttled). Only compresses files >7 days old and >1 KB.
- Cross-device trash — Copies the entire file (same as
mvacross filesystems). Unavoidable. - SQLite index write — One
INSERTper trash operation with implicit fsync (~1-5 ms).
trashd/
├── crates/
│ ├── trashd-common/ # shared core library
│ │ ├── config.rs # layered TOML config with pattern matching
│ │ ├── store.rs # TrashStore: trash, restore, purge, empty, auto-purge
│ │ ├── trashinfo.rs # .trashinfo parser/serializer (FreeDesktop spec)
│ │ ├── mounts.rs # multi-partition trash directory discovery
│ │ ├── index.rs # SQLite index (supplementary to .trashinfo)
│ │ ├── directorysizes.rs # $trash/directorysizes cache (spec v1.0)
│ │ └── oplog.rs # operation log + desktop notifications
│ ├── trashd-cli/ # `trash` CLI (clap-based, 12 subcommands)
│ ├── trashd-shim/ # `rm` drop-in replacement
│ ├── trashd-preload/ # LD_PRELOAD .so (standalone, no SQLite)
│ ├── trashd-seccomp/ # seccomp supervisor + watchdog + BPF filter
│ └── trashd/ # fanotify filesystem monitor
├── config/
│ └── trashd.toml # default config template
├── tests/
│ └── integration.sh # 12 end-to-end integration tests
└── install/
├── profile.d/ # PATH shim + seccomp activation
└── systemd/ # trashd.service
| Binary | Crate | Purpose |
|---|---|---|
trash |
trashd-cli | CLI: ls, find, info, restore, undo, purge, empty, compress, du, status, log, fsck |
trashd-rm |
trashd-shim | Drop-in rm replacement (installed as rm in shim PATH) |
libtrashd_preload.so |
trashd-preload | LD_PRELOAD shared library (~870 KB, no SQLite) |
trashd-exec |
trashd-seccomp | Seccomp supervisor, watchdog, and ancestor broker |
trashd |
trashd | fanotify filesystem monitor (systemd service) |
TRASH_BYPASS=1 cargo test # 32 unit tests (store, trashinfo, globs)TRASH_BYPASS=1 is required when LD_PRELOAD is system-wide to prevent the preload from intercepting test operations.
sudo ./tests/integration.sh # 12 end-to-end tests (requires install)Covers: Layer 1 shim, Layer 2 LD_PRELOAD, --permanent bypass, TRASH_BYPASS=1 bypass, trash undo, trash restore --to, trash purge, trash empty -y, */.git/* pattern skip, restore conflict detection, duplicate filename unique IDs, and trash fsck orphan detection.
- Rust 1.97+ (for building from source)
- Linux 5.6+ (for seccomp notification and pinned
openat2resolution — Layer 4) - Linux 5.9+ (for fanotify FID reporting — Layer 3)
- CAP_SYS_ADMIN or root (for fanotify daemon)
- Layers 1 and 2 work on any Linux kernel
MIT