From 2ee1b18dc392b06c5b13fdf8000c853c84161644 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 24 Sep 2026 14:48:13 -0400 Subject: [PATCH] Stop the backup report depending on listing the Drive folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under launchd, macOS allows a named path on ~/Library/CloudStorage to be stat'd but blocks enumerating the directory. A glob therefore expands to nothing, and the reporting block read that as zero: the first real LaunchAgent run after the TCC fix logged "0 copies off-site" and emailed "no off-site copies at all" while 57 files sat in the folder. The copy loop was unaffected, because it uses filenames taken from the NAS rather than a glob. Freshness is now derived by testing explicit filenames for the last 60 days, which works in both contexts. Enumeration is still used for the total count, but a count of zero is reported as unknown rather than alerted on — an unreadable folder and an empty one are different things, and only one of them is an emergency. Preferred over granting Full Disk Access: a privacy grant is exactly what an OS update silently reset here in the first place. Co-Authored-By: Claude Opus 5 --- backup-to-drive.sh | 54 ++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/backup-to-drive.sh b/backup-to-drive.sh index 3c3acc7..f4f4a6f 100755 --- a/backup-to-drive.sh +++ b/backup-to-drive.sh @@ -150,11 +150,24 @@ fi # out. That is backwards: the whole value of the off-site copy is that it outlives the NAS's # 30-day window. Each file is ~260 KB, so a decade of them is under a gigabyte. If it ever # genuinely needs trimming, do it by hand, and never below what the NAS still holds. -cd "$DRIVE_DIR" || die "cannot enter $DRIVE_DIR" -TOTAL="$(ls -1 checklists-????-??-??.json 2>/dev/null | wc -l | tr -d ' ')" -OLDEST="$(ls -1 checklists-????-??-??.json 2>/dev/null | sort | head -1)" -NEWEST="$(ls -1 checklists-????-??-??.json 2>/dev/null | sort | tail -1)" -say "✓ done — $TOTAL copies off-site, ${OLDEST:-none} → ${NEWEST:-none}" +# Reporting and freshness. +# +# This deliberately does NOT rely on listing the Drive folder. Under launchd, macOS lets a +# named path be stat'd but blocks enumerating ~/Library/CloudStorage, so a glob quietly +# expands to nothing — which on 2026-09-24 made this script report "0 copies off-site" and +# email an alert while 57 files sat there. Checking explicit filenames works in both +# contexts, so freshness is derived that way and enumeration is treated as a bonus. +NEWEST="" +for i in $(seq 0 60); do + d="$(date -v-${i}d +%Y-%m-%d 2>/dev/null)" || break + if [ -s "$DRIVE_DIR/checklists-$d.json" ]; then NEWEST="checklists-$d.json"; NEWEST_DATE="$d"; AGE_DAYS=$i; break; fi +done + +# Enumeration is allowed when run by hand; when it is blocked the count is unknown rather +# than zero, and an unknown count is never treated as a missing backup. +TOTAL="$(ls -1 "$DRIVE_DIR"/checklists-????-??-??.json 2>/dev/null | wc -l | tr -d ' ')" +if [ "$TOTAL" = "0" ]; then TOTAL="unknown (cannot list the folder from here)"; fi +say "✓ done — $TOTAL copies off-site, newest ${NEWEST:-none}" # Some files were fetched but were not usable. The run "succeeded" in the sense that it did # not crash, which is exactly the kind of success that hides a broken backup. @@ -164,24 +177,19 @@ if [ "${FAILED:-0}" -gt 0 ]; then were skipped. See the log for which ones." fi -# The staleness check is the one that would have caught both of the incidents in the header -# comment. Every failure path above assumes this script RAN; if the Mac is asleep, or the -# LaunchAgent is unloaded, nothing above ever fires. This at least turns a quietly ageing -# off-site copy into mail the next time it does run. +# The check that would have caught both of the incidents in the header comment. Every +# failure path above assumes this script RAN; if the Mac is asleep or the LaunchAgent is +# unloaded, nothing fires at all. if [ -z "$NEWEST" ]; then - alert "no off-site copies at all" "$DRIVE_DIR contains no checklists-.json files." + alert "no recent off-site copy" \ + "No checklists-.json for any of the last 60 days was found in +$DRIVE_DIR +Either copying has stopped, or this machine cannot read that folder." +elif [ "$AGE_DAYS" -gt "$STALE_DAYS" ]; then + alert "off-site copy is $AGE_DAYS days old" \ + "Newest file off-site is $NEWEST ($AGE_DAYS days old); more than $STALE_DAYS days +means the chain from the NAS to Drive is not working, even though this run did +not error." else - NEWEST_DATE="$(printf '%s' "$NEWEST" | sed -E 's/^checklists-([0-9]{4}-[0-9]{2}-[0-9]{2})\.json$/\1/')" - NEWEST_EPOCH="$(date -j -f "%Y-%m-%d" "$NEWEST_DATE" +%s 2>/dev/null || echo 0)" - if [ "$NEWEST_EPOCH" -gt 0 ]; then - AGE_DAYS=$(( ( $(date +%s) - NEWEST_EPOCH ) / 86400 )) - if [ "$AGE_DAYS" -gt "$STALE_DAYS" ]; then - alert "off-site copy is $AGE_DAYS days old" \ - "Newest file off-site is $NEWEST ($AGE_DAYS days old); anything more than -$STALE_DAYS days means the chain from the NAS to Drive is not working, even though -this run did not error." - else - say "freshness OK — newest off-site copy is $AGE_DAYS day(s) old" - fi - fi + say "freshness OK — newest off-site copy is $AGE_DAYS day(s) old" fi