Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions bin/clean.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions bin/optimize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion bin/purge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ for arg in "$@"; do
echo " Scans for files larger than <MB> (default 100) and lets you delete them."
exit 0
;;
-*) ;;
-*)
echo "Unknown option: $arg" >&2
exit 1
;;
*) SCAN_ROOT="$arg" ;;
esac
done
Expand Down
126 changes: 124 additions & 2 deletions lib/core/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Key macOS trash metadata by more than basename

On macOS, this lookup reuses any sidecar with the same basename, even if the original trash item was restored or deleted outside Vole and the sidecar remained. If a new item with that basename is later put in Trash by Finder or another tool, clean can apply the stale old epoch and permanently delete the new item on its first run, bypassing the intended grace period; include a unique file identity or clear orphaned sidecars before trusting them.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat invalid trash timestamps as unknown

When a Linux .trashinfo file has a DeletionDate that date -d cannot parse, this final [[ ... ]] returns 1. Because bin/clean.sh runs with set -e and assigns deleted_epoch=$(trash_deleted_epoch "$item"), one malformed trash sidecar aborts the whole clean instead of taking the unknown-time backfill path; return success with no output for invalid timestamps, the same way missing timestamps are handled.

Useful? React with 👍 / 👎.

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)
# ============================================================================
Expand Down
102 changes: 97 additions & 5 deletions lib/optimize/tasks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
Loading