Skip to main content

max / goingson

fix(backup): enforce a retention floor so pruning can't delete the last good backup backup_scheduler pruned down to max_backups_to_keep with a default of 1 and a 15-minute cadence, so a single bad generation (corruption, crash mid-write, or a backup taken right after accidental data loss) could leave no good backup to recover from. prune_old_backups now clamps the effective limit up to a MIN_BACKUPS_TO_KEEP floor of 3 (a setting of 0 still means keep-all), and the auto-created default rises from 1 to 10. Tests cover the floor, the keep-all exemption, and an above-floor limit being honored. Partial fix for the ultra-fuzz Run #26 backup_scheduler SERIOUS finding; the remaining half (include time_sessions/milestones/daily_notes/attachments/ sync_accounts in the backup) is tracked separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:29 UTC
Commit: 180a687a37e85cdd6db3a88536b83a0e7f339b7b
Parent: 65b732a
1 file changed, +63 insertions, -1 deletion
@@ -14,6 +14,13 @@ use tracing::{debug, error, info, warn};
14 14 /// Check interval for automated backups (1 minute)
15 15 const CHECK_INTERVAL_SECS: u64 = 60;
16 16
17 + /// Retention floor: never keep fewer than this many backups, regardless of the
18 + /// configured `max_backups_to_keep`. With a 15-minute cadence and a single-backup
19 + /// limit, one bad write (corruption, crash mid-write, a backup taken just after
20 + /// accidental data loss) could otherwise leave no good generation to recover from.
21 + /// A setting of 0 still means "keep everything" and is exempt.
22 + const MIN_BACKUPS_TO_KEEP: usize = 3;
23 +
17 24 /// Build a unique backup filename. The second-granular timestamp keeps files
18 25 /// human-sortable; the short random suffix prevents a manual backup and the
19 26 /// scheduler firing in the same second from colliding and silently overwriting
@@ -71,7 +78,7 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu
71 78 let defaults = goingson_core::NewBackupSettings {
72 79 auto_backup_enabled: true,
73 80 backup_frequency_minutes: 15,
74 - max_backups_to_keep: 1,
81 + max_backups_to_keep: 10,
75 82 };
76 83 state
77 84 .backup_settings
@@ -174,6 +181,10 @@ fn prune_old_backups(backup_dir: &std::path::Path, max_to_keep: usize) -> Result
174 181 return Ok(()); // Keep all backups
175 182 }
176 183
184 + // Enforce the retention floor so an aggressive setting cannot delete the last
185 + // good backup.
186 + let max_to_keep = max_to_keep.max(MIN_BACKUPS_TO_KEEP);
187 +
177 188 let mut backups: Vec<_> = std::fs::read_dir(backup_dir)
178 189 .map_err(|e| format!("Failed to read backup directory: {}", e))?
179 190 .filter_map(|entry| entry.ok())
@@ -290,4 +301,55 @@ mod tests {
290 301 assert_ne!(a, b, "same-second backups must get distinct filenames");
291 302 assert!(a.starts_with("goingson-backup-") && a.ends_with(".json.gz"));
292 303 }
304 +
305 + /// Write `n` backup files, each stamped with a distinct increasing mtime so
306 + /// newest-first pruning is deterministic.
307 + fn seed_backups(dir: &std::path::Path, n: usize) {
308 + use std::time::{Duration, SystemTime};
309 + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
310 + for i in 0..n {
311 + let path = dir.join(format!("goingson-backup-{i:03}.json.gz"));
312 + std::fs::write(&path, [i as u8]).unwrap();
313 + let f = std::fs::File::options().write(true).open(&path).unwrap();
314 + f.set_modified(base + Duration::from_secs(i as u64)).unwrap();
315 + }
316 + }
317 +
318 + fn gz_count(dir: &std::path::Path) -> usize {
319 + std::fs::read_dir(dir)
320 + .unwrap()
321 + .filter_map(|e| e.ok())
322 + .filter(|e| e.path().extension().map(|x| x == "gz").unwrap_or(false))
323 + .count()
324 + }
325 +
326 + #[test]
327 + fn prune_enforces_retention_floor() {
328 + // Even with max_to_keep = 1, the floor keeps MIN_BACKUPS_TO_KEEP so a
329 + // single bad generation can never wipe out the last good backup.
330 + let dir = tempfile::tempdir().unwrap();
331 + seed_backups(dir.path(), 6);
332 + prune_old_backups(dir.path(), 1).unwrap();
333 + assert_eq!(
334 + gz_count(dir.path()),
335 + MIN_BACKUPS_TO_KEEP,
336 + "max_to_keep below the floor must be clamped up to the floor"
337 + );
338 + }
339 +
340 + #[test]
341 + fn prune_keep_all_is_exempt_from_floor() {
342 + let dir = tempfile::tempdir().unwrap();
343 + seed_backups(dir.path(), 5);
344 + prune_old_backups(dir.path(), 0).unwrap();
345 + assert_eq!(gz_count(dir.path()), 5, "0 means keep everything");
346 + }
347 +
348 + #[test]
349 + fn prune_above_floor_uses_configured_limit() {
350 + let dir = tempfile::tempdir().unwrap();
351 + seed_backups(dir.path(), 9);
352 + prune_old_backups(dir.path(), 5).unwrap();
353 + assert_eq!(gz_count(dir.path()), 5, "a limit above the floor is honored");
354 + }
293 355 }