diff --git a/bin/clean.sh b/bin/clean.sh index 5149689..a588928 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -21,6 +21,10 @@ for arg in "$@"; do echo " --system Include the system deep clean without asking (needs sudo)" exit 0 ;; + *) + echo "Unknown option: $arg" >&2 + exit 1 + ;; esac done @@ -86,25 +90,47 @@ fi end_section # ============================================================================ -# Trash (items older than N days) +# Trash (items older than N days since they were TRASHED — not their +# original mtime; see trash_deleted_epoch in lib/core/common.sh) # ============================================================================ TRASH_AGE_DAYS="${VOLE_CFG_TRASH_AGE_DAYS:-$VOLE_TEMP_FILE_AGE_DAYS}" +TRASH_AGE_SECONDS=$((TRASH_AGE_DAYS * 86400)) start_section "Trash (older than ${TRASH_AGE_DAYS} days)" +NOW_EPOCH=$(get_epoch_seconds) if is_macos; then if [[ -d "$HOME/.Trash" ]]; then while IFS= read -r -d '' item; do - safe_delete "$item" "Trash: $(basename "$item")" - done < <(find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -mtime "+$TRASH_AGE_DAYS" -print0 2> /dev/null) + [[ "$(basename "$item")" == "$VOLE_TRASH_META_DIR_NAME" ]] && continue + deleted_epoch=$(trash_deleted_epoch "$item") + if [[ -z "$deleted_epoch" ]]; then + # Unknown deletion time — do NOT delete on this run. Start + # the grace-period clock now instead of guessing from mtime. + [[ "$DRY_RUN" == "true" ]] || trash_backfill_deleted_now "$item" + continue + fi + (((NOW_EPOCH - deleted_epoch) > TRASH_AGE_SECONDS)) || continue + if safe_delete "$item" "Trash: $(basename "$item")"; then + [[ "$DRY_RUN" == "true" ]] || rm -f -- "$HOME/.Trash/$VOLE_TRASH_META_DIR_NAME/$(basename "$item")" 2> /dev/null || true + fi + done < <(find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) fi else TRASH_DIR="$HOME/.local/share/Trash" if [[ -d "$TRASH_DIR/files" ]]; then while IFS= read -r -d '' item; do name="$(basename "$item")" + deleted_epoch=$(trash_deleted_epoch "$item") + if [[ -z "$deleted_epoch" ]]; then + # Unknown deletion time — do NOT delete on this run. Start + # the grace-period clock now instead of guessing from mtime. + [[ "$DRY_RUN" == "true" ]] || trash_backfill_deleted_now "$item" + continue + fi + (((NOW_EPOCH - deleted_epoch) > TRASH_AGE_SECONDS)) || continue if safe_delete "$item" "Trash: $name"; then [[ "$DRY_RUN" == "true" ]] || rm -f -- "$TRASH_DIR/info/$name.trashinfo" 2> /dev/null || true fi - done < <(find "$TRASH_DIR/files" -mindepth 1 -maxdepth 1 -mtime "+$TRASH_AGE_DAYS" -print0 2> /dev/null) + done < <(find "$TRASH_DIR/files" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) fi fi end_section diff --git a/bin/optimize.sh b/bin/optimize.sh index 13c325f..7898bb2 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -27,6 +27,10 @@ for arg in "$@"; do echo " --list List available tasks and exit" exit 0 ;; + *) + echo "Unknown option: $arg" >&2 + exit 1 + ;; esac done diff --git a/bin/purge.sh b/bin/purge.sh index c9bec39..14df60c 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -21,7 +21,10 @@ for arg in "$@"; do echo " Scans for files larger than (default 100) and lets you delete them." exit 0 ;; - -*) ;; + -*) + echo "Unknown option: $arg" >&2 + exit 1 + ;; *) SCAN_ROOT="$arg" ;; esac done diff --git a/lib/core/common.sh b/lib/core/common.sh index cd7d882..76255c6 100644 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -358,23 +358,145 @@ fda_hint() { # ============================================================================ # Trash +# +# Retention (see clean.sh) must be based on WHEN AN ITEM WAS TRASHED, not +# its original file mtime — otherwise trashing an old file and cleaning up +# an hour later permanently deletes it despite the advertised grace period. +# On Linux, gio already records this for us (DeletionDate in the freedesktop +# .trashinfo sidecar). On macOS, ~/.Trash has no such metadata, so we keep +# our own sidecar under ~/.Trash/.vole-trash-meta. # ============================================================================ -# Move a path to the user's trash. Returns 1 if no trash mechanism exists. +# Name of Vole's own bookkeeping dir inside ~/.Trash; never a real trashed item. +VOLE_TRASH_META_DIR_NAME=".vole-trash-meta" + +# Move a path to the user's trash, recording when it was trashed so +# retention can key off deletion time. Returns 1 if no trash mechanism exists. move_to_trash() { local path="$1" if is_macos; then local dest="$HOME/.Trash/$(basename "$path")" # Avoid clobbering an existing trash entry with the same name [[ -e "$dest" ]] && dest="$dest.$(get_epoch_seconds)" - mv -- "$path" "$dest" 2> /dev/null + if mv -- "$path" "$dest" 2> /dev/null; then + _vole_record_trash_time "$dest" + return 0 + fi + return 1 elif command -v gio > /dev/null 2>&1; then + # gio writes a freedesktop .trashinfo sidecar with a DeletionDate — + # see trash_deleted_epoch below, which reads it back. gio trash "$path" 2> /dev/null else return 1 fi } +# Record the deletion time for a macOS Trash item. +_vole_trash_identity() { + local trashed_path="$1" + if is_macos; then + stat -f '%d:%i' "$trashed_path" 2> /dev/null || true + else + stat -c '%d:%i' -- "$trashed_path" 2> /dev/null || true + fi +} + +_vole_record_trash_time() { + local trashed_path="$1" + local meta_dir="$HOME/.Trash/$VOLE_TRASH_META_DIR_NAME" + mkdir -p "$meta_dir" 2> /dev/null || return 0 + local epoch identity + epoch="$(get_epoch_seconds)" + identity="$(_vole_trash_identity "$trashed_path")" + [[ -n "$identity" ]] || return 0 + { + echo "epoch=$epoch" + echo "identity=$identity" + } > "$meta_dir/$(basename "$trashed_path")" 2> /dev/null || true +} + +# Look up when a trashed item was actually deleted, in epoch seconds. +# Prints nothing if unknown — callers MUST treat that conservatively (not +# yet eligible for permanent deletion) rather than falling back to mtime. +trash_deleted_epoch() { + local trashed_path="$1" + local name + name="$(basename "$trashed_path")" + if is_macos; then + local meta_file="$HOME/.Trash/$VOLE_TRASH_META_DIR_NAME/$name" + [[ -f "$meta_file" ]] || return 0 + local val epoch identity current_identity + val="$(cat "$meta_file" 2> /dev/null || true)" + # Older Vole metadata was keyed only by basename. Treat it as unknown + # rather than risking a stale timestamp for a different Trash item. + [[ "$val" =~ ^[0-9]+$ ]] && return 0 + epoch="$(printf '%s\n' "$val" | sed -n 's/^epoch=//p' | head -1)" + identity="$(printf '%s\n' "$val" | sed -n 's/^identity=//p' | head -1)" + [[ "$epoch" =~ ^[0-9]+$ && -n "$identity" ]] || return 0 + current_identity="$(_vole_trash_identity "$trashed_path")" + [[ -n "$current_identity" && "$current_identity" == "$identity" ]] || return 0 + echo "$epoch" + else + local trashinfo="$HOME/.local/share/Trash/info/$name.trashinfo" + [[ -f "$trashinfo" ]] || return 0 + local deletion_date epoch + deletion_date=$(sed -n 's/^DeletionDate=//p' "$trashinfo" 2> /dev/null | head -1) + [[ -n "$deletion_date" ]] || return 0 + epoch=$(date -d "$deletion_date" +%s 2> /dev/null || echo "") + [[ "$epoch" =~ ^[0-9]+$ ]] && echo "$epoch" + return 0 + fi +} + +# Backfill a deletion-time record for a trash item with no known deletion +# time (trashed before this tracking existed, or dropped in by another +# tool). Stamped as "discovered now" so it only becomes eligible for +# permanent deletion after a full grace period FROM THIS POINT ON — never +# on the run that first notices it. +trash_backfill_deleted_now() { + local trashed_path="$1" + local name + name="$(basename "$trashed_path")" + if is_macos; then + _vole_record_trash_time "$trashed_path" + else + local info_dir="$HOME/.local/share/Trash/info" iso info_file tmp_file + mkdir -p "$info_dir" 2> /dev/null || return 0 + iso=$(date '+%Y-%m-%dT%H:%M:%S') + info_file="$info_dir/$name.trashinfo" + if [[ -f "$info_file" ]]; then + tmp_file="$info_file.vole.$$" + if awk -v iso="$iso" ' + BEGIN { seen = 0 } + /^DeletionDate=/ { + if (!seen) { + print "DeletionDate=" iso + seen = 1 + } + next + } + { print } + END { + if (!seen) { + print "DeletionDate=" iso + } + } + ' "$info_file" > "$tmp_file" 2> /dev/null; then + mv -- "$tmp_file" "$info_file" 2> /dev/null || rm -f -- "$tmp_file" + else + rm -f -- "$tmp_file" + fi + else + { + echo "[Trash Info]" + echo "Path=$trashed_path" + echo "DeletionDate=$iso" + } > "$info_file" 2> /dev/null || true + fi + fi +} + # ============================================================================ # Step Runner (for update/optimize style tasks) # ============================================================================ diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index e3ae0ca..a58fe02 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -843,13 +843,70 @@ opt_linux_network_stack() { fi } +# Directories under $HOME that rootless container engines populate with +# subuid/subgid-mapped ownership on purpose. A recursive chown across these +# rewrites that mapping and corrupts the container storage, so ownership +# repair below never descends into them. +_opt_home_ownership_excludes() { + printf '%s\n' \ + "$HOME/.local/share/containers" \ + "$HOME/.local/share/docker" \ + "$HOME/.local/share/podman" +} + +_opt_chown_tree_preserving_home_excludes() { + local entry="$1" uidgid="$2" + shift 2 + + local d + local -a prune_expr=() + for d in "$@"; do + [[ "$d" == "$entry"/* ]] || continue + if ((${#prune_expr[@]} > 0)); then + prune_expr+=(-o) + fi + prune_expr+=(-path "$d") + done + + if ((${#prune_expr[@]} > 0)); then + sudo find "$entry" \( "${prune_expr[@]}" \) -prune -o -exec chown "$uidgid" {} + > /dev/null 2>&1 + else + sudo find "$entry" -exec chown "$uidgid" {} + > /dev/null 2>&1 + fi +} + opt_linux_home_ownership() { - local owner + local owner home_needs_repair=false needs_repair=false owner=$(get_file_owner "$HOME") - if [[ -z "$owner" || "$owner" == "$USER" ]]; then + if [[ -n "$owner" && "$owner" != "$USER" ]]; then + home_needs_repair=true + needs_repair=true + fi + + local -a excludes=() + mapfile -t excludes < <(_opt_home_ownership_excludes) + + local entry skip d entry_owner fixed=0 failed=0 + if [[ "$home_needs_repair" != "true" ]]; then + while IFS= read -r -d '' entry; do + skip=false + for d in "${excludes[@]}"; do + [[ "$entry" == "$d" ]] && skip=true && break + done + [[ "$skip" == "true" ]] && continue + entry_owner=$(get_file_owner "$entry") + if [[ -n "$entry_owner" && "$entry_owner" != "$USER" ]]; then + needs_repair=true + break + fi + done < <(find "$HOME" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) + fi + + if [[ "$needs_repair" != "true" ]]; then opt_report "Home directory ownership already optimal" return 0 fi + if [[ "$DRY_RUN" == "true" ]]; then opt_report "Home directory ownership would be repaired" return 0 @@ -858,10 +915,45 @@ opt_linux_home_ownership() { opt_warn "Ownership repair skipped — admin access unavailable" return 0 fi - if sudo chown -R "$(id -u):$(id -g)" "$HOME" 2> /dev/null; then - opt_report "Home directory ownership repaired" + + # Never blanket `chown -R "$HOME"` — that rewrites ownership inside + # rootless container storage (subuid-mapped) and corrupts it. Repair + # only entries that actually need it, pruning container storage even + # when the misowned entry is a parent such as ~/.local. + local uidgid + uidgid="$(id -u):$(id -g)" + + if [[ "$home_needs_repair" == "true" ]]; then + # Fix $HOME itself first so a previously unsearchable home can be + # traversed during this same optimize run. + if sudo chown "$uidgid" "$HOME" 2> /dev/null; then + fixed=$((fixed + 1)) + else + failed=$((failed + 1)) + fi + fi + + while IFS= read -r -d '' entry; do + skip=false + for d in "${excludes[@]}"; do + [[ "$entry" == "$d" ]] && skip=true && break + done + [[ "$skip" == "true" ]] && continue + entry_owner=$(get_file_owner "$entry") + [[ -z "$entry_owner" || "$entry_owner" == "$USER" ]] && continue + if _opt_chown_tree_preserving_home_excludes "$entry" "$uidgid" "${excludes[@]}"; then + fixed=$((fixed + 1)) + else + failed=$((failed + 1)) + fi + done < <(sudo find "$HOME" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) + + if ((failed > 0)); then + opt_warn "Failed to repair ownership of $failed item(s)" + elif ((fixed > 0)); then + opt_report "Home directory ownership repaired ($fixed item(s); container storage left untouched)" else - opt_warn "Failed to repair ownership" + opt_report "Home directory ownership already optimal" fi }