#!/bin/sh # Deterministically empty every .AGENTTRASH directory under the given roots. # # PURPOSE # The fleet's deletion policy parks deleted files in .AGENTTRASH/ — in-repo # beside the item, elsewhere in $HOME as .AGENTTRASH/YYYYMMDD-topic — and # waits for approval to purge. This script is that purge: given a machine, # it clears .AGENTTRASH directories and nothing else. # # CONTRACT # Default dry-run: the plan prints and nothing is deleted; --execute deletes. # Every line the script can ever print for a candidate is decided at plan time # and stored in the plan table, so a dry run prints exactly what --execute # will print. A .AGENTTRASH that is a root's own trash (a repo's # .AGENTTRASH, a machine's ~/.AGENTTRASH) prints `plan empty `: the # directory is the convention and survives, its staged contents are cleared. # A .AGENTTRASH deeper than that is content and prints `plan remove ` # plus, once executed, `removed `; its own nested .AGENTTRASH entries # belong to that tree and are never planned again. Candidates are found with # `find -xdev` per root, so a mounted tree can never be walked by accident; # ordering is lexicographic (LC_ALL=C). `/`, symlinked, and foreign-owned # candidates are refused and reported (exit 1), never touched. # A summary line `removed= deleted= refused=` closes # every run and one dated line is appended to the log. # Exit: 0 success, 1 operational failure, 2 usage error. A second run over # the same tree plans nothing further and exits 0. # # PORTABILITY # POSIX sh, clean under `shellcheck -S style`. Externals: find (must support # -delete), stat, rm, date, mkdir. No bashisms, no GNU-only flags, no arrays, # no `local`. Paths containing whitespace are rejected at parse time — the # same rule applied to roots and candidates — so the plan can be walked with # plain word splitting and nothing can be silently mis-split. # # USAGE # purge-agenttrash [--execute] [--quiet] [--log PATH] [PATH...] # --execute delete; without it the run is a plan and deletes nothing # --quiet suppress per-line output; errors and the summary still print # --log PATH log destination (default: $HOME/.AGENTTRASH-cleanup.log) # PATH... roots to search (default: $HOME) # # Everything else is fixed by design: the root default, the ordering, the # guards. There is no configuration file and no environment override. set -eu usage() { sed -n '3,44p' "$0" | sed 's/^# \{0,1\}//' } die() { printf '%s: %s\n' 'purge-agenttrash' "$1" >&2 exit "${2:-1}" } # ------------------------------------------------------------------ arg parser # Roots accumulate as one space-separated string: a root containing whitespace is # rejected at parse time rather than silently split later. That leaves the plan # iteratable with plain `$@`-free word splitting, the only portable construct # that cannot corrupt a path it has already vetted. MODE=plan QUIET=0 LOGPATH= ROOTS= while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage exit 0 ;; --execute) MODE=execute ;; --quiet) QUIET=1 ;; --log) [ "$#" -ge 2 ] || die '--log needs a value' 2 LOGPATH=$2 shift ;; --log=*) LOGPATH=${1#--log=} ;; -*) die "unknown option: $1" 2 ;; *) case $1 in *' '*) die "root contains whitespace: $1" 2 ;; esac if [ -z "$ROOTS" ]; then ROOTS=$1 else ROOTS="$ROOTS $1" fi ;; esac shift done [ -n "${HOME:-}" ] || die 'HOME is not set; pass roots explicitly or set HOME' 2 [ -n "$LOGPATH" ] || LOGPATH=$HOME/.AGENTTRASH-cleanup.log # ------------------------------------------------------------ tool availability require() { command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1" 1 } require find require stat require rm require date require mkdir # `find -delete` backs the in-place emptying. Probe it rather than trusting # `find --version`: a shell function named `find` must not be able to fake it. if ! find . -maxdepth 0 -name '.purge-probe-does-not-exist' -delete >/dev/null 2>&1; then die 'find does not support -delete; refusing to guess at deletion' 1 fi UID_SELF=$(id -u) # ------------------------------------------------------------------- locking # The lock dir also hosts this run's plan table, so both live and die together: # the exit trap removes the whole directory and only the log survives the run. LOCKDIR=$HOME/.agenttrash-purge.lock PLANFILE=$LOCKDIR/plan acquire_lock() { if ! mkdir "$LOCKDIR" 2>/dev/null; then held_pid=$(cat "$LOCKDIR/pid" 2>/dev/null || printf '%s\n' '') if [ -n "$held_pid" ] && kill -0 "$held_pid" 2>/dev/null; then die "another purge holds $LOCKDIR (pid $held_pid)" 1 fi # Owner died mid-run: drop its pid, then retry exactly once. An # unremovable/nonempty lock dir is not ours to reclaim. rm -f "$LOCKDIR/pid" 2>/dev/null || true rmdir "$LOCKDIR" 2>/dev/null || die "$LOCKDIR exists and is not ours to reclaim" 1 mkdir "$LOCKDIR" 2>/dev/null || die "cannot reacquire $LOCKDIR" 1 fi printf '%s\n' "$$" >"$LOCKDIR/pid" || die "cannot stamp $LOCKDIR/pid" 1 : >"$PLANFILE" || die "cannot write $PLANFILE" 1 trap 'rm -rf "$LOCKDIR"' EXIT trap 'exit 130' INT trap 'exit 143' TERM } # ------------------------------------------------------------------ predicates is_dir() { # true only for a real directory, never a symlink to one [ -d "$1" ] && [ ! -L "$1" ] } is_ours() { # true only when the invoking user owns the path owner=$(stat -c '%u' "$1" 2>/dev/null) || return 1 [ "$owner" = "$UID_SELF" ] } refuse() { # refuse — reported, never deleted printf '%s: refused %s (%s)\n' 'purge-agenttrash' "$1" "$2" >&2 REFUSED=$((REFUSED + 1)) } emit() { # emit — silenced by --quiet [ "$QUIET" -eq 1 ] || printf '%s\n' "$1" } # --------------------------------------------------------------- the plan table # One row per candidate: ` `. The first two fields tell # `act_on` what to do; the rest of the row is EXACTLY what the run prints for # that candidate — in plan mode verbatim, in execute mode as the first line of # its report. The table is a file, so no shell quoting rule (quoted-for, # heredoc, IFS games) can corrupt an approved plan, and the two modes can # never drift apart. A removal's second line (`removed `) is derived from # the row's action, so it is also fixed at plan time. CANDIDATES=0 REFUSED=0 record() { # record — append one row to the plan table printf '%s %s %s\n' "$1" "$2" "$3" >>"$PLANFILE" CANDIDATES=$((CANDIDATES + 1)) } # ------------------------------------------------------------- candidate rule # The whole contract in one place: # `/` is refused outright (never a candidate, never walked); # a root that is a symlink or a plain file is refused, never followed; # a root (or any path) that IS a real .AGENTTRASH prints `plan empty `: # the convention persists, its staged contents are what gets cleared; # otherwise the root's DIRECT .AGENTTRASH child is also `empty` — it is that # level's own trash ($HOME/.AGENTTRASH, x1/.AGENTTRASH, repo-a/.AGENTTRASH); # every .AGENTTRASH deeper than that is content: `remove`, printed as # `plan remove ` (+ `removed ` when executed), unless it lives # inside a tree already claimed (then it dies with that tree, never planned # twice) or is foreign-owned/symlinked (then refused). collect_root() { # collect_root -> 0 ok, 1 refused target=$1 if [ "$target" = / ]; then refuse / 'root refusal' return 1 fi if [ -L "$target" ] || ! [ -d "$target" ]; then refuse "$target" 'not a real directory' return 1 fi if ! is_ours "$target"; then refuse "$target" "not owned by uid $UID_SELF" return 1 fi if [ "${target##*/}" = .AGENTTRASH ]; then record empty "$target" "plan empty $target" return 0 fi # `own` is the root's own .AGENTTRASH once handled; hits below it belong to # that tree and must never be planned a second time. own= if [ -e "$target/.AGENTTRASH" ]; then if ! is_ours "$target/.AGENTTRASH"; then refuse "$target/.AGENTTRASH" "not owned by uid $UID_SELF" elif ! is_dir "$target/.AGENTTRASH"; then refuse "$target/.AGENTTRASH" 'not a real directory' else record empty "$target/.AGENTTRASH" "plan empty $target/.AGENTTRASH" own=$target/.AGENTTRASH fi fi # No `-type d`: implementations disagree on whether that matches a # symlink-to-directory (GNU find says no, others yes), so every entry named # .AGENTTRASH is collected and classified here instead of by `find`. if ! found=$(find "$target" -xdev -name .AGENTTRASH 2>/dev/null); then refuse "$target" 'find failed' return 1 fi previous= for entry in $found; do [ -n "$entry" ] || continue if [ -n "$own" ]; then case $entry in "$own"|"$own"/*) continue ;; # already emptied in place esac fi if [ -n "$previous" ]; then case $entry in "$previous"/*) continue ;; # nested: dies with its parent esac fi previous=$entry if ! is_ours "$entry"; then refuse "$entry" "not owned by uid $UID_SELF" continue fi if ! is_dir "$entry"; then refuse "$entry" 'not a real directory' continue fi record remove "$entry" "plan remove $entry" done return 0 } # ------------------------------------------------------------------ execution remove_tree() { # rm -rf a real directory; symlinks inside die as entries is_dir "$1" || return 1 rm -rf -- "$1" } empty_dir() { # empty a real directory in place, never following symlinks is_dir "$1" || return 1 find "$1" -mindepth 1 -xdev -delete } # act_on -> 0 when the action succeeded. # remove: prints the stored line (`plan remove `) then `removed `. # empty: prints the stored line (`plan empty `) and nothing else — the # directory survives, so the plan line IS the outcome. act_on() { # act_on -> 0 when the action succeeded if [ "$1" = remove ]; then if remove_tree "$2" 2>/dev/null; then emit "$3" emit "removed $2" return 0 fi printf '%s: failed to remove %s\n' 'purge-agenttrash' "$2" >&2 return 1 fi if empty_dir "$2" 2>/dev/null; then emit "$3" return 0 fi printf '%s: failed to empty %s\n' 'purge-agenttrash' "$2" >&2 return 1 } # Walk the plan table once: plan mode prints every row's line verbatim, execute # mode acts on each row and counts failures separately from plan-time refusals. walk_plan() { while read -r action path line; do [ -n "$action" ] || continue if [ "$MODE" = plan ]; then emit "$line" elif act_on "$action" "$path" "$line"; then REMOVED=$((REMOVED + 1)) else EXEC_REFUSED=$((EXEC_REFUSED + 1)) fi done <"$PLANFILE" } # ----------------------------------------------------------------------- main [ -n "$ROOTS" ] || ROOTS=$HOME acquire_lock for root in $ROOTS; do collect_root "$root" || true done REMOVED=0 EXEC_REFUSED=0 walk_plan STATUS=0 [ "$REFUSED" -eq 0 ] || STATUS=1 [ "$EXEC_REFUSED" -eq 0 ] || STATUS=1 printf 'removed=%s deleted=%s refused=%s\n' \ "$CANDIDATES" "$REMOVED" "$((REFUSED + EXEC_REFUSED))" if ! printf '%s\tplan\tcandidates=%s\tremoved=%s\trefused=%s\n' \ "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$CANDIDATES" "$REMOVED" \ "$((REFUSED + EXEC_REFUSED))" >>"$LOGPATH" 2>/dev/null; then printf '%s: cannot write log %s\n' 'purge-agenttrash' "$LOGPATH" >&2 STATUS=1 fi exit "$STATUS"