aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-01-18 11:15:57 -0600
committerCraig Jennings <c@cjennings.net>2026-01-18 11:15:57 -0600
commit2e8e5cdd980098241fbd5f6d92f05111818f574a (patch)
tree0f1df49d25586477c58439efe9fad74a5d5dfcf4
parent6505511f2e6b43a37570fc840f6d2851c7cc170c (diff)
downloadarchangel-2e8e5cdd980098241fbd5f6d92f05111818f574a.tar.gz
archangel-2e8e5cdd980098241fbd5f6d92f05111818f574a.zip
Add snapshot retention with automatic pruning
Implements hybrid retention policy: - Always keep 20 most recent snapshots - Delete snapshots beyond #20 only if older than 180 days - Genesis snapshot is always protected Features: - zfs-snap-prune script with --dry-run, --test, --verbose modes - Comprehensive test suite (22 tests) - Runs automatically after pacman operations - Daily systemd timer for cleanup - Regenerates GRUB menu after pruning This prevents unbounded snapshot growth while preserving recent history and the genesis snapshot.
-rw-r--r--README.org57
-rwxr-xr-xbuild.sh7
-rwxr-xr-xcustom/install-archzfs46
-rwxr-xr-xcustom/zfs-snap-prune208
-rwxr-xr-xscripts/test-zfs-snap-prune.sh303
5 files changed, 621 insertions, 0 deletions
diff --git a/README.org b/README.org
index 6306090..d24ed57 100644
--- a/README.org
+++ b/README.org
@@ -21,6 +21,7 @@ manual module loading or package installation during the install process.
- *Genesis Snapshot* - Automatic pristine-state snapshot after installation
- *Rollback Script* - One-command factory reset via ~/root/rollback-to-genesis~
- *Pre-Pacman Snapshots* - Automatic snapshots before package operations
+- *Snapshot Retention* - Automatic pruning keeps disk usage bounded
- *GRUB Snapshot Boot* - Boot into any ZFS snapshot directly from GRUB menu
- *NetworkManager* - WiFi configuration copied to installed system
- *SSH Ready* - Optional SSH with root login for headless servers
@@ -94,6 +95,7 @@ archzfs/
│ ├── install-archzfs # Interactive installation script
│ ├── install-archzfs.conf.example # Example config for unattended install
│ ├── grub-zfs-snap # ZFS snapshot GRUB menu generator
+│ ├── zfs-snap-prune # Snapshot retention/pruning script
│ ├── 40_zfs_snapshots # GRUB generator script (for /etc/grub.d/)
│ └── zz-grub-zfs-snap.hook # Pacman hook for auto-regeneration
├── scripts/
@@ -509,6 +511,61 @@ zpool status
zpool list
#+END_SRC
+** Snapshot Retention Policy
+
+The system automatically prunes old snapshots to prevent unbounded disk usage.
+
+*** Retention Rules
+
+| Rule | Description |
+|------+-------------|
+| Keep 20 most recent | Always preserved regardless of age |
+| Delete if >180 days | Snapshots beyond position 20, older than 6 months |
+| Genesis protected | Never deleted, regardless of position or age |
+
+*** How It Works
+
+Pruning runs automatically:
+- *After every pacman operation* - Pre-pacman hook triggers prune after creating new snapshot
+- *Daily via systemd timer* - Catches any missed pruning, syncs GRUB menu
+
+*** Manual Pruning
+
+#+BEGIN_SRC bash
+# Preview what would be deleted
+zfs-snap-prune --dry-run
+
+# Run with verbose output
+zfs-snap-prune --verbose
+
+# Check current snapshots
+zfs-snap-prune --help
+#+END_SRC
+
+*** Customizing Retention
+
+Set environment variables before running:
+
+#+BEGIN_SRC bash
+# Keep more snapshots (default: 20)
+KEEP_COUNT=50 zfs-snap-prune
+
+# Shorter retention period (default: 180 days)
+MAX_AGE_DAYS=90 zfs-snap-prune
+
+# Different pool/dataset
+POOL_NAME=tank ROOT_DATASET=ROOT/arch zfs-snap-prune
+#+END_SRC
+
+To change defaults permanently, edit ~/usr/local/bin/zfs-snap-prune~.
+
+*** Example: One Year of Use
+
+With weekly pacman updates (~52/year) plus genesis:
+- Total snapshots created: ~53
+- Snapshots kept: ~27 (20 recent + ~6 within 180 days + genesis)
+- Snapshots pruned: ~26 (older than 180 days, beyond position 20)
+
* Keeping Up-to-Date
** Kernel Updates on Installed Systems
diff --git a/build.sh b/build.sh
index cf5c086..67542ec 100755
--- a/build.sh
+++ b/build.sh
@@ -187,6 +187,10 @@ mkdir -p "$PROFILE_DIR/airootfs/usr/local/share/grub-zfs-snap"
cp "$CUSTOM_DIR/40_zfs_snapshots" "$PROFILE_DIR/airootfs/usr/local/share/grub-zfs-snap/"
cp "$CUSTOM_DIR/zz-grub-zfs-snap.hook" "$PROFILE_DIR/airootfs/usr/local/share/grub-zfs-snap/"
+# Copy zfs-snap-prune for snapshot retention
+info "Copying zfs-snap-prune..."
+cp "$CUSTOM_DIR/zfs-snap-prune" "$PROFILE_DIR/airootfs/usr/local/bin/"
+
# Copy example config for unattended installs
mkdir -p "$PROFILE_DIR/airootfs/root"
cp "$CUSTOM_DIR/install-archzfs.conf.example" "$PROFILE_DIR/airootfs/root/"
@@ -207,6 +211,9 @@ if grep -q "file_permissions=" "$PROFILE_DIR/profiledef.sh"; then
/)/ i\ ["/usr/local/bin/grub-zfs-snap"]="0:0:755"
}' "$PROFILE_DIR/profiledef.sh"
sed -i '/^file_permissions=(/,/)/ {
+ /)/ i\ ["/usr/local/bin/zfs-snap-prune"]="0:0:755"
+ }' "$PROFILE_DIR/profiledef.sh"
+ sed -i '/^file_permissions=(/,/)/ {
/)/ i\ ["/etc/shadow"]="0:0:400"
}' "$PROFILE_DIR/profiledef.sh"
fi
diff --git a/custom/install-archzfs b/custom/install-archzfs
index 660cf34..2cec709 100755
--- a/custom/install-archzfs
+++ b/custom/install-archzfs
@@ -1114,6 +1114,11 @@ if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then
else
echo "Warning: Failed to create snapshot" >&2
fi
+
+# Prune old snapshots (runs quietly, non-blocking)
+if [[ -x /usr/local/bin/zfs-snap-prune ]]; then
+ /usr/local/bin/zfs-snap-prune --quiet &
+fi
EOF
chmod +x /mnt/usr/local/bin/zfs-pre-snapshot
@@ -1121,6 +1126,46 @@ EOF
info "Pacman hook configured."
}
+configure_snapshot_retention() {
+ step "Configuring Snapshot Retention"
+
+ # Copy the prune script
+ cp /usr/local/bin/zfs-snap-prune /mnt/usr/local/bin/zfs-snap-prune
+ chmod +x /mnt/usr/local/bin/zfs-snap-prune
+
+ # Create systemd service for pruning
+ cat > /mnt/etc/systemd/system/zfs-snap-prune.service << 'EOF'
+[Unit]
+Description=Prune old ZFS snapshots
+After=zfs.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/bin/zfs-snap-prune --quiet
+EOF
+
+ # Create systemd timer for daily pruning
+ cat > /mnt/etc/systemd/system/zfs-snap-prune.timer << 'EOF'
+[Unit]
+Description=Daily ZFS snapshot pruning
+
+[Timer]
+OnCalendar=daily
+Persistent=true
+RandomizedDelaySec=1h
+
+[Install]
+WantedBy=timers.target
+EOF
+
+ # Enable the timer
+ arch-chroot /mnt systemctl enable zfs-snap-prune.timer
+
+ info "Snapshot retention configured."
+ info "Policy: Keep 20 recent, delete if older than 180 days"
+ info "Genesis snapshot is always preserved."
+}
+
copy_archsetup() {
step "Installing archsetup Launcher"
@@ -1299,6 +1344,7 @@ main() {
configure_grub_zfs_snap
configure_zfs_services
configure_pacman_hook
+ configure_snapshot_retention
copy_archsetup
sync_efi_partitions
create_genesis_snapshot
diff --git a/custom/zfs-snap-prune b/custom/zfs-snap-prune
new file mode 100755
index 0000000..762ff99
--- /dev/null
+++ b/custom/zfs-snap-prune
@@ -0,0 +1,208 @@
+#!/bin/bash
+# zfs-snap-prune - Prune old ZFS snapshots with hybrid retention policy
+#
+# Retention Policy:
+# - Always keep the N most recent snapshots (default: 20)
+# - Delete snapshots beyond N only if older than MAX_AGE (default: 180 days)
+# - Never delete genesis snapshot
+#
+# Usage:
+# zfs-snap-prune [OPTIONS]
+#
+# Options:
+# --dry-run Show what would be deleted without deleting
+# --verbose Show decision for every snapshot
+# --quiet Suppress non-error output
+# --test Use mock data from stdin instead of real ZFS
+# --help Show this help message
+#
+# Environment variables:
+# POOL_NAME - ZFS pool name (default: zroot)
+# ROOT_DATASET - Root dataset path (default: ROOT/default)
+# KEEP_COUNT - Number of recent snapshots to always keep (default: 20)
+# MAX_AGE_DAYS - Delete older snapshots beyond KEEP_COUNT (default: 180)
+# NOW_OVERRIDE - Override current timestamp for testing (epoch seconds)
+
+set -e
+
+# Configuration (can be overridden by environment)
+POOL_NAME="${POOL_NAME:-zroot}"
+ROOT_DATASET="${ROOT_DATASET:-ROOT/default}"
+KEEP_COUNT="${KEEP_COUNT:-20}"
+MAX_AGE_DAYS="${MAX_AGE_DAYS:-180}"
+
+FULL_DATASET="${POOL_NAME}/${ROOT_DATASET}"
+
+# Flags
+DRY_RUN=false
+VERBOSE=false
+QUIET=false
+TEST_MODE=false
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+usage() {
+ sed -n '2,/^$/p' "$0" | sed 's/^# \?//'
+ exit 0
+}
+
+info() {
+ [[ "$QUIET" == "true" ]] && return
+ echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+verbose() {
+ [[ "$VERBOSE" != "true" ]] && return
+ echo -e "${BLUE}[VERBOSE]${NC} $1"
+}
+
+warn() {
+ [[ "$QUIET" == "true" ]] && return
+ echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+error() {
+ echo -e "${RED}[ERROR]${NC} $1" >&2
+ exit 1
+}
+
+# Parse arguments
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dry-run)
+ DRY_RUN=true
+ shift
+ ;;
+ --verbose)
+ VERBOSE=true
+ shift
+ ;;
+ --quiet)
+ QUIET=true
+ shift
+ ;;
+ --test)
+ TEST_MODE=true
+ shift
+ ;;
+ --help|-h)
+ usage
+ ;;
+ *)
+ error "Unknown option: $1"
+ ;;
+ esac
+done
+
+# Check if running as root (skip in test mode)
+if [[ "$TEST_MODE" != "true" ]] && [[ $EUID -ne 0 ]]; then
+ error "This script must be run as root"
+fi
+
+# Get current timestamp (can be overridden for testing)
+NOW="${NOW_OVERRIDE:-$(date +%s)}"
+MAX_AGE_SECONDS=$((MAX_AGE_DAYS * 24 * 60 * 60))
+CUTOFF_TIME=$((NOW - MAX_AGE_SECONDS))
+
+info "Pruning snapshots for ${FULL_DATASET}"
+info "Policy: Keep ${KEEP_COUNT} recent, delete if older than ${MAX_AGE_DAYS} days"
+[[ "$DRY_RUN" == "true" ]] && info "DRY RUN - no changes will be made"
+
+# Get snapshots - either from ZFS or stdin (test mode)
+# Expected format: snapshot_name<TAB>creation_date_string
+# Example: zroot/ROOT/default@pre-pacman_2025-01-15 Wed Jan 15 10:30 2025
+if [[ "$TEST_MODE" == "true" ]]; then
+ # Read mock data from stdin
+ SNAPSHOTS=$(cat | tac)
+else
+ # Query real ZFS - sorted by creation (oldest first), then reversed for newest first
+ SNAPSHOTS=$(zfs list -H -t snapshot -o name,creation -s creation -r "$FULL_DATASET" 2>/dev/null | \
+ grep "^${FULL_DATASET}@" | \
+ tac) || true
+fi
+
+if [[ -z "$SNAPSHOTS" ]]; then
+ info "No snapshots found"
+ exit 0
+fi
+
+# Count snapshots
+TOTAL=$(echo "$SNAPSHOTS" | wc -l)
+info "Found ${TOTAL} snapshots"
+
+# Track results
+DELETED=0
+KEPT=0
+POSITION=0
+
+# Process each snapshot
+while IFS=$'\t' read -r snapshot creation_str; do
+ [[ -z "$snapshot" ]] && continue
+
+ POSITION=$((POSITION + 1))
+ SNAP_NAME="${snapshot##*@}"
+
+ # Parse creation time
+ if [[ "$TEST_MODE" == "true" ]]; then
+ # In test mode, creation_str is epoch seconds
+ SNAP_TIME="$creation_str"
+ else
+ # In real mode, parse date string
+ SNAP_TIME=$(date -d "$creation_str" +%s 2>/dev/null || echo "0")
+ fi
+
+ AGE_DAYS=$(( (NOW - SNAP_TIME) / 86400 ))
+
+ # Decision logic
+ if [[ $POSITION -le $KEEP_COUNT ]]; then
+ # Always keep the first KEEP_COUNT snapshots (most recent)
+ verbose "KEEP: ${SNAP_NAME} (position ${POSITION}/${KEEP_COUNT}, ${AGE_DAYS} days old) - within keep count"
+ KEPT=$((KEPT + 1))
+ elif [[ "$SNAP_NAME" == "genesis" ]]; then
+ # Never delete genesis
+ verbose "KEEP: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old) - genesis protected"
+ KEPT=$((KEPT + 1))
+ elif [[ $SNAP_TIME -ge $CUTOFF_TIME ]]; then
+ # Not old enough to delete
+ verbose "KEEP: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old) - younger than ${MAX_AGE_DAYS} days"
+ KEPT=$((KEPT + 1))
+ else
+ # Delete: beyond keep count AND older than max age
+ if [[ "$DRY_RUN" == "true" ]]; then
+ info "WOULD DELETE: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old)"
+ DELETED=$((DELETED + 1))
+ elif [[ "$TEST_MODE" == "true" ]]; then
+ # Test mode: simulate deletion (don't actually call zfs)
+ verbose "DELETE: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old)"
+ DELETED=$((DELETED + 1))
+ else
+ verbose "DELETE: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old)"
+ if zfs destroy "$snapshot" 2>/dev/null; then
+ DELETED=$((DELETED + 1))
+ else
+ warn "Failed to delete ${snapshot}"
+ fi
+ fi
+ fi
+done <<< "$SNAPSHOTS"
+
+# Summary
+info "Summary: ${KEPT} kept, ${DELETED} deleted"
+
+# Regenerate GRUB menu if we deleted anything (skip in dry-run and test modes)
+if [[ $DELETED -gt 0 ]] && [[ "$DRY_RUN" != "true" ]] && [[ "$TEST_MODE" != "true" ]]; then
+ if [[ -x /usr/local/bin/grub-zfs-snap ]]; then
+ info "Regenerating GRUB menu..."
+ /usr/local/bin/grub-zfs-snap
+ fi
+fi
+
+# Exit with special code for testing (number of deleted)
+if [[ "$TEST_MODE" == "true" ]]; then
+ echo "RESULT:kept=${KEPT},deleted=${DELETED}"
+fi
diff --git a/scripts/test-zfs-snap-prune.sh b/scripts/test-zfs-snap-prune.sh
new file mode 100755
index 0000000..d59a7cc
--- /dev/null
+++ b/scripts/test-zfs-snap-prune.sh
@@ -0,0 +1,303 @@
+#!/bin/bash
+# test-zfs-snap-prune.sh - Comprehensive test suite for zfs-snap-prune
+#
+# Runs various scenarios with mock data to verify the pruning logic.
+# No root or ZFS required - uses --test mode with mock data.
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PRUNE_SCRIPT="$SCRIPT_DIR/../custom/zfs-snap-prune"
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+# Test counters - use temp files to avoid subshell issues with pipes
+COUNTER_FILE=$(mktemp)
+echo "0 0 0" > "$COUNTER_FILE" # run passed failed
+trap "rm -f $COUNTER_FILE" EXIT
+
+# Time constants for generating test data
+DAY=$((24 * 60 * 60))
+NOW=$(date +%s)
+
+# Generate a snapshot line for mock data
+# Args: snapshot_name days_ago
+# Output: zroot/ROOT/default@name<TAB>epoch_timestamp
+make_snap() {
+ local name="$1"
+ local days_ago="$2"
+ local timestamp=$((NOW - (days_ago * DAY)))
+ echo -e "zroot/ROOT/default@${name}\t${timestamp}"
+}
+
+# Generate N snapshots with given prefix and starting age
+# Args: prefix count start_days_ago
+# NOTE: Outputs oldest first (like ZFS with -s creation), so start_age is the OLDEST
+make_snaps() {
+ local prefix="$1"
+ local count="$2"
+ local start_age="$3"
+ # Generate from oldest to newest (ZFS order with -s creation)
+ for ((i=count; i>=1; i--)); do
+ make_snap "${prefix}_${i}" $((start_age + i - 1))
+ done
+}
+
+# Increment counter in temp file
+# Args: position (1=run, 2=passed, 3=failed)
+inc_counter() {
+ local pos="$1"
+ local counters
+ read -r run passed failed < "$COUNTER_FILE"
+ case "$pos" in
+ 1) run=$((run + 1)) ;;
+ 2) passed=$((passed + 1)) ;;
+ 3) failed=$((failed + 1)) ;;
+ esac
+ echo "$run $passed $failed" > "$COUNTER_FILE"
+}
+
+# Get counter values
+get_counters() {
+ cat "$COUNTER_FILE"
+}
+
+# Run a test case
+# Args: test_name expected_kept expected_deleted env_vars
+# Reads mock snapshot data from stdin
+run_test() {
+ local test_name="$1"
+ local expected_kept="$2"
+ local expected_deleted="$3"
+ shift 3
+ local env_vars="$*"
+
+ inc_counter 1 # TESTS_RUN++
+ echo -e "${BLUE}TEST:${NC} $test_name"
+
+ # Capture stdin (mock data) and pass to prune script
+ local mock_data
+ mock_data=$(cat)
+
+ # Run prune script with mock data on stdin
+ local output
+ output=$(echo "$mock_data" | env NOW_OVERRIDE="$NOW" $env_vars "$PRUNE_SCRIPT" --test --quiet 2>&1)
+
+ # Extract results
+ local result_line
+ result_line=$(echo "$output" | grep "^RESULT:" || echo "RESULT:kept=0,deleted=0")
+ local actual_kept
+ actual_kept=$(echo "$result_line" | sed 's/.*kept=\([0-9]*\).*/\1/')
+ local actual_deleted
+ actual_deleted=$(echo "$result_line" | sed 's/.*deleted=\([0-9]*\).*/\1/')
+
+ # Compare
+ if [[ "$actual_kept" == "$expected_kept" ]] && [[ "$actual_deleted" == "$expected_deleted" ]]; then
+ echo -e " ${GREEN}PASS${NC} (kept=$actual_kept, deleted=$actual_deleted)"
+ inc_counter 2 # TESTS_PASSED++
+ return 0
+ else
+ echo -e " ${RED}FAIL${NC}"
+ echo -e " Expected: kept=$expected_kept, deleted=$expected_deleted"
+ echo -e " Actual: kept=$actual_kept, deleted=$actual_deleted"
+ inc_counter 3 # TESTS_FAILED++
+ return 1
+ fi
+}
+
+# Print section header
+section() {
+ echo ""
+ echo -e "${YELLOW}=== $1 ===${NC}"
+}
+
+# Verify prune script exists
+if [[ ! -x "$PRUNE_SCRIPT" ]]; then
+ chmod +x "$PRUNE_SCRIPT"
+fi
+
+echo -e "${GREEN}zfs-snap-prune Test Suite${NC}"
+echo "========================="
+echo "Using NOW=$NOW ($(date -d "@$NOW" '+%Y-%m-%d %H:%M:%S'))"
+echo "Default policy: KEEP_COUNT=20, MAX_AGE_DAYS=180"
+
+###############################################################################
+section "Basic Cases"
+###############################################################################
+
+# Test 1: Empty list
+echo -n "" | run_test "Empty snapshot list" 0 0
+
+# Test 2: Single snapshot
+make_snap "test1" 5 | run_test "Single snapshot (recent)" 1 0
+
+# Test 3: Under keep count - all recent
+make_snaps "recent" 10 1 | run_test "10 snapshots, all recent" 10 0
+
+# Test 4: Exactly at keep count
+make_snaps "exact" 20 1 | run_test "Exactly 20 snapshots" 20 0
+
+###############################################################################
+section "Over Keep Count - Age Matters"
+###############################################################################
+
+# Test 5: 25 snapshots, all recent (within 180 days)
+# Should keep all - none old enough to delete
+make_snaps "recent" 25 1 | run_test "25 snapshots, all recent (<180 days)" 25 0
+
+# Test 6: 25 snapshots, 5 are old (>180 days)
+# First 20 (most recent) kept by count, 5 oldest are >180 days old, so deleted
+{
+ make_snaps "old" 5 200 # oldest first (200-204 days ago)
+ make_snaps "recent" 20 1 # newest last (1-20 days ago)
+} | run_test "25 snapshots, 5 old (>180 days) - delete 5" 20 5
+
+# Test 7: 30 snapshots, 10 beyond limit but only 5 old enough
+{
+ make_snaps "old" 5 200 # 200-204 days old - delete these
+ make_snaps "medium" 5 100 # 100-104 days old - not old enough
+ make_snaps "recent" 20 1 # 1-20 days old
+} | run_test "30 snapshots, 5 medium age, 5 old - delete 5" 25 5
+
+###############################################################################
+section "Genesis Protection"
+###############################################################################
+
+# Test 8: Genesis at position 21, old - should NOT be deleted
+{
+ make_snap "genesis" 365 # oldest: 1 year old
+ make_snaps "recent" 20 1 # newest: 1-20 days ago
+} | run_test "Genesis at position 21 (old) - protected" 21 0
+
+# Test 9: Genesis at position 25, with other old snapshots
+{
+ make_snap "genesis" 365 # oldest: protected
+ make_snaps "old" 4 200 # 200-203 days old - should be deleted
+ make_snaps "recent" 20 1 # 1-20 days old
+} | run_test "Genesis at position 25 with 4 old - delete 4, keep genesis" 21 4
+
+# Test 10: Genesis within keep count (20 total snapshots)
+{
+ make_snap "genesis" 365 # oldest
+ make_snaps "more" 9 15 # 15-23 days ago
+ make_snaps "recent" 10 1 # 1-10 days ago
+} | run_test "Genesis at position 11 (within keep count)" 20 0
+
+###############################################################################
+section "Custom Configuration"
+###############################################################################
+
+# Test 11: Custom KEEP_COUNT=5
+make_snaps "test" 10 200 | \
+ run_test "KEEP_COUNT=5, 10 old snapshots - delete 5" 5 5 KEEP_COUNT=5
+
+# Test 12: Custom MAX_AGE_DAYS=30
+{
+ make_snaps "medium" 5 50 # 50-54 days old - now considered old with MAX_AGE=30
+ make_snaps "recent" 20 1 # 1-20 days ago
+} | run_test "MAX_AGE_DAYS=30, 5 snapshots >30 days - delete 5" 20 5 MAX_AGE_DAYS=30
+
+# Test 13: Very short retention
+make_snaps "test" 15 10 | \
+ run_test "KEEP_COUNT=3, MAX_AGE=7, 15 snaps (10+ days old) - delete 12" 3 12 KEEP_COUNT=3 MAX_AGE_DAYS=7
+
+# Test 14: Relaxed retention - nothing deleted
+make_snaps "test" 50 1 | \
+ run_test "KEEP_COUNT=100 - keep all 50" 50 0 KEEP_COUNT=100
+
+###############################################################################
+section "Edge Cases"
+###############################################################################
+
+# Test 15: Snapshot exactly at MAX_AGE boundary (180 days) - should be kept
+{
+ make_snap "boundary" 180 # Exactly 180 days - >= cutoff, kept
+ make_snaps "recent" 20 1 # 1-20 days ago
+} | run_test "1 snapshot exactly at 180 day boundary - keep" 21 0
+
+# Test 16: Snapshot just over MAX_AGE boundary (181 days) - should be deleted
+{
+ make_snap "over" 181 # 181 days - just over, should be deleted
+ make_snaps "recent" 20 1 # 1-20 days ago
+} | run_test "1 snapshot at 181 days - delete" 20 1
+
+# Test 17: Mixed boundary - some at 180, some at 181
+{
+ make_snap "over2" 182 # deleted
+ make_snap "over1" 181 # deleted
+ make_snap "boundary" 180 # kept (exactly at cutoff)
+ make_snaps "recent" 20 1 # 1-20 days ago
+} | run_test "2 over boundary, 1 at boundary - delete 2" 21 2
+
+# Test 18: Mixed naming patterns (ordered oldest to newest)
+{
+ make_snap "genesis" 365
+ make_snap "before-upgrade" 20
+ make_snap "manual_backup" 15
+ make_snap "pre-pacman_2025-01-10" 10
+ make_snap "pre-pacman_2025-01-15" 5
+} | run_test "Mixed snapshot names (5 total)" 5 0
+
+# Test 19: Large number of snapshots
+{
+ make_snaps "old" 100 200 # 200-299 days old
+ make_snaps "recent" 20 1 # 1-20 days ago
+} | run_test "120 snapshots, 100 old - delete 100" 20 100
+
+###############################################################################
+section "Realistic Scenarios"
+###############################################################################
+
+# Test 20: One year of weekly pacman updates + genesis
+# 52 snapshots (one per week) + genesis
+# Ordered oldest first: genesis (365 days), then week_51 (357 days), ..., week_0 (0 days)
+{
+ make_snap "genesis" 365
+ for ((week=51; week>=0; week--)); do
+ make_snap "pre-pacman_week_${week}" $((week * 7))
+ done
+} | run_test "1 year of weekly updates (52) + genesis" 27 26
+# Analysis (after tac, newest first):
+# Position 1-20: week_0 through week_19 (0-133 days) - kept by count
+# Position 21-26: week_20 through week_25 (140-175 days) - kept by age (<180)
+# Position 27-52: week_26 through week_51 (182-357 days) - deleted (>180)
+# Position 53: genesis (365 days) - protected
+# Kept: 20 + 6 + 1 = 27, Deleted: 26
+
+# Test 21: Fresh install with only genesis
+make_snap "genesis" 1 | run_test "Fresh install - only genesis" 1 0
+
+# Test 22: Burst of manual snapshots before big change
+{
+ make_snap "genesis" 30
+ make_snap "before-nvidia" 20
+ make_snap "before-DE-change" 19
+ make_snaps "pre-pacman" 18 1
+} | run_test "20 snaps + genesis (30 days old)" 21 0
+
+###############################################################################
+section "Results"
+###############################################################################
+
+# Read final counters
+read -r TESTS_RUN TESTS_PASSED TESTS_FAILED < "$COUNTER_FILE"
+
+echo ""
+echo "========================="
+echo -e "Tests run: $TESTS_RUN"
+echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}"
+echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}"
+echo ""
+
+if [[ $TESTS_FAILED -eq 0 ]]; then
+ echo -e "${GREEN}All tests passed!${NC}"
+ exit 0
+else
+ echo -e "${RED}Some tests failed.${NC}"
+ exit 1
+fi