package backup import ( "fmt" "os" "path/filepath" "strings" ) // R-474 (v0.240.0) — "delete backups" on removal deletes the app's Tier-2 mirror too. // // Measured three times on 2026-09-13: removing an app with `remove_backups:true` deleted only its // db-dumps directory; the recovery unit, the volume tars and the Tier-2 mirror all survived, and a // later reinstall of the same app then leaned on the old install's unit as its "fresh" restore point // (R-478). The unit is inside the app's own backups base and RemoveStack deletes it; the MIRROR lives // on another drive, outside that base, so it is removed here, with its own path check. func validMirrorStackName(n string) bool { return n != "" && n != "." && n != ".." && n != SharesPseudoStack && !strings.ContainsAny(n, `/\`) } // tier2MirrorRoots are the namespace roots a Tier-2 mirror of this app can live under: the recorded // destination, every registered drive, and the system data path (a mirror outlives a changed target). func (m *Manager) tier2MirrorRoots(stackName string) []string { seen := map[string]bool{} var roots []string add := func(r string) { if r == "" { return } r = filepath.Clean(r) if filepath.IsAbs(r) && !seen[r] { seen[r] = true roots = append(roots, r) } } if m.settings != nil { if cfg := m.settings.GetCrossDriveConfig(stackName); cfg != nil { add(cfg.DestinationPath) } for _, sp := range m.settings.GetStoragePaths() { if sp.Path != "" { add(NamespaceRootFor(sp.Path, m.systemDataPath)) } } } if m.systemDataPath != "" { add(NamespaceRootFor(m.systemDataPath, m.systemDataPath)) } return roots } // Tier2MirrorDirsForApp lists the app's Tier-2 mirror directories that exist now. Call it BEFORE the // removal clears the app's cross-drive record, which is one of the places it looks. func (m *Manager) Tier2MirrorDirsForApp(stackName string) []string { if m == nil || !validMirrorStackName(stackName) { return nil } var out []string for _, root := range m.tier2MirrorRoots(stackName) { d := filepath.Join(root, "backups", "secondary", stackName) if fi, err := os.Stat(d); err == nil && fi.IsDir() { out = append(out, d) } } return out } // RemoveTier2Mirrors deletes the given mirror directories, each only if it is exactly // /backups/secondary/ for one of the app's mirror roots — never another app's mirror, // never the shares mirror, never a path that merely cleans to one. Returns "path (size)" per removal. func (m *Manager) RemoveTier2Mirrors(stackName string, dirs []string) []string { if m == nil || !validMirrorStackName(stackName) { return nil } allowed := map[string]bool{} for _, root := range m.tier2MirrorRoots(stackName) { allowed[filepath.Join(root, "backups", "secondary", stackName)] = true } var removed []string for _, d := range dirs { if !allowed[d] { m.logger.Printf("[WARN] [backup] remove %s: refusing to delete %q — not this app's Tier-2 mirror", stackName, d) continue } size := humanizeBytes(dirSizeBytes(d)) if err := os.RemoveAll(d); err != nil { m.logger.Printf("[ERROR] [backup] remove %s: deleting the Tier-2 mirror %s failed: %v", stackName, d, err) continue } m.logger.Printf("[INFO] [backup] remove %s: Tier-2 mirror deleted: %s (%s)", stackName, d, size) removed = append(removed, fmt.Sprintf("%s (%s)", d, size)) } return removed }