Skip to content

Repository files navigation

trashd

CI License: MIT Rust

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.

How it works

  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:

Layer 1 — PATH shim (trashd-rm)

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.

Layer 2 — LD_PRELOAD (libtrashd_preload.so)

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 internal rename()/mkdir() calls during trash operations from re-entering the hooked unlink().
  • 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 .trashinfo cleanup 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 .so is unsafe).

Layer 3 — fanotify daemon (trashd)

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.

Layer 4 — seccomp supervisor (trashd-exec)

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:

  1. 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 via SCM_RIGHTS over a Unix socketpair, then execvp()'s the target command. Explicit wrapping sets PR_SET_NO_NEW_PRIVS; --preserve-privileges installs using CAP_SYS_ADMIN instead and falls back when unavailable.

  2. 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 with SECCOMP_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 without RENAME_NOREPLACE, such as NFS) is filled by copying from the pinned parent, as trash() does. trashd's own trash CLI and rm shim are never intercepted: their deletes are store-internal. Validates notification IDs to mitigate TOCTOU races.

  3. Watchdog — Holds a dup()'d copy of the notification fd. Monitors the supervisor via waitpid(). On supervisor death: immediately drains all pending notifications with CONTINUE (fail-safe — blocked processes resume with real deletes), then forks a new supervisor. If fork fails, enters emergency passthrough mode (responds CONTINUE until no filtered task remains). Once every filtered task has exited, the listener reports hangup and the supervisor and watchdog exit instead of retrying.

  4. Orchestrator — Remains an ancestor and subreaper for the command and its descendants. It brokers process_vm_readv() and pidfd_getfd() over a private socket, preserving access under Yama ptrace_scope=1 across child forks and supervisor restarts. It forwards SIGHUP, SIGINT, and SIGTERM through 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 default SIGCHLD disposition even when it inherits SIG_IGN (WSL's login passes 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.

How the layers interact

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) —

Install

git clone https://github.com/faratech/trashd.git
cd trashd
sudo ./install.sh

Requires 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:

  1. Builds all crates in release mode with locked dependencies
  2. Installs binaries to /usr/local/bin/ and /usr/local/lib/trashd/
  3. Stashes the real rm binary at /usr/local/lib/trashd/real/rm
  4. Creates the PATH shim at /usr/local/lib/trashd/bin/rm (+ unlink symlink)
  5. Adds libtrashd_preload.so to /etc/ld.so.preload (system-wide)
  6. Installs /etc/profile.d/trashd.sh (PATH shim + seccomp wrapper)
  7. Installs and starts the trashd systemd service and a per-user trashd-cleanup.timer (disabled; enable it with systemctl --user enable --now trashd-cleanup.timer to purge items older than 30 days weekly)
  8. Installs man pages to /usr/local/share/man/man1/
  9. Installs shell completions for bash, zsh, and fish
  10. Creates global config at /etc/trashd/config.toml

Start a new shell or run:

source /etc/profile.d/trashd.sh

Uninstall

sudo ./install.sh --uninstall          # remove trashd; KEEP trashed files
sudo ./install.sh --uninstall --purge  # also delete every trash directory

Removes 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.

Manual install

make builds as your own user; sudo make install only copies the build output and never compiles as root:

make
sudo make install

Or 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/

Usage

Transparent interception (just use rm normally)

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)

Manage trash

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 version

restore, 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.

Bypass trash when needed

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

Seccomp supervisor

# 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

LD_PRELOAD

# 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 -> ...

Multi-partition support

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.

Cross-device move mechanics

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:

  1. Snapshot — Directories are snapshotted (device, inode, mode, size, mtime, ctime of every node) before copying.
  2. 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.
  3. 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.

Configuration

Layered config

Four layers, each optional, merged in order:

  1. Hardcoded defaults — Built into the binary
  2. Global /etc/trashd/config.toml — Admin-managed, applies to all users
  3. User ~/.config/trashd/config.toml — Personal overrides
  4. 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)

Full config reference

# 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 matching syntax

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.

Per-directory overrides

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.

Auto-purge and compression

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.

Manual compression

trash compress                # compress items older than 7 days
trash compress --older 3d     # compress items older than 3 days
trash compress --dry-run      # preview without compressing

Uses 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.

Transparent decompression on restore

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.

Integrity and hash verification

On trash

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.

On restore

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.

Safety

Fail-safe design

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 /nonexistent or root-owned — lets the real unlink()/rmdir() run, and root running with another user's HOME uses its own home trash. Other operational failures return the result of the real call.
  • Seccomp — Responds with SECCOMP_USER_NOTIF_FLAG_CONTINUE when 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.

Confirmation on empty

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).

Bypass mechanisms

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

Atomic operations

  • Trash entry IDs — Claimed via O_CREAT|O_EXCL on the .trashinfo file. 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 .trashinfo suffix.
  • directorysizes cache — Written via temp file + atomic rename() per spec.
  • Operation log — Append-only file, one write per operation.

Symlink safety

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.

Orphan detection

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 in files/)
  • Orphaned files (no matching .trashinfo in info/)
  • 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.

FreeDesktop.org Trash spec v1.0 compliance

trashd implements the complete FreeDesktop.org Trash specification v1.0:

Directory structure

  • $XDG_DATA_HOME/Trash/ home directory trash with private, current-user-owned files/ and info/ 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 mode 0700)
  • $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/directorysizes cache (size, trashinfo mtime, percent-encoded name — updated via atomic rename)

.trashinfo format

  • 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 8601 YYYY-MM-DDThh:mm:ss in 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)

Extended metadata (spec-compliant X- fields)

[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...

Interoperability

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.

Operation log

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.

Performance

What's fast

  • 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.

What's throttled

  • Auto-purge — Scans all .trashinfo files, but only runs once per auto_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.

What's potentially slow

  • Cross-device trash — Copies the entire file (same as mv across filesystems). Unavoidable.
  • SQLite index write — One INSERT per trash operation with implicit fsync (~1-5 ms).

Project structure

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

Binaries produced

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)

Testing

Unit tests

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.

Integration tests

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.

Requirements

  • Rust 1.97+ (for building from source)
  • Linux 5.6+ (for seccomp notification and pinned openat2 resolution — 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

License

MIT

About

Transparent Linux recycle bin and rm undelete daemon. 4-layer interception (PATH shim, LD_PRELOAD, seccomp, fanotify). FreeDesktop.org trash spec compliant.

Topics

Resources

Contributing

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages