package backup import ( "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "syscall" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // ── D25 (operator ruling 2026-10-21; R-1175 + R-1181): a second-drive copy whose SOURCE is gone is KEPT ───────────── // // A source is gone when its drive is away or decommissioned, or a declared folder is missing. The night job never // removes such a copy: it records it (settings.KeptCopy), the backups page lists it as kept, and only the household's // „Eltávolítás" removes it — by its recorded path, identity-checked (device + inode), never by size or a name pattern. // A share the household DELETED or RENAMED on a present drive is still pruned (that is the household's own act). // // 07 §8.2 chose „data legs are not guarded" for a leg that LEAVES the class set; a gone source is a different case, // and D25 decides it the other way: the copy may be the only one left. // keepCopy records path (an existing directory inside destBase) as a kept copy. A missing path records nothing — there // is nothing to keep. func (m *Manager) keepCopy(kind, source, drive, reason, destBase, path string) { if m.settings == nil { return } st, err := os.Stat(path) if err != nil || !st.IsDir() { return } dev, ino := statIdentity(st) k := settings.KeptCopy{Kind: kind, Source: source, Drive: drive, Reason: reason, DestBase: filepath.Clean(destBase), Path: filepath.Clean(path), Dev: dev, Ino: ino, SizeBytes: dirSizeBytes(path)} if err := m.settings.UpsertKeptCopy(k); err != nil { m.logger.Printf("[WARN] [backup] kept copy %s could not be recorded (the copy itself is kept): %v", path, err) return } m.logger.Printf("[INFO] [backup] second-drive copy KEPT (source gone: %s): %s %q at %s", reason, kind, source, path) } // releaseKeptCopy forgets the record of path because its source is back (the run mirrored it again). Data untouched. func (m *Manager) releaseKeptCopy(path string) { if m.settings == nil { return } id := settings.KeptCopyID(filepath.Clean(path)) for _, k := range m.settings.GetKeptCopies() { if k.ID == id { _ = m.settings.DropKeptCopy(id) m.logger.Printf("[INFO] [backup] kept copy %s is live again (its source is back) — record dropped", path) return } } } // keptCopyRels (R-1252, HUNT-2 H2) is the slash-form path, relative to destBase, of every kept copy recorded inside // it — added to the reconcile's keep set so a kept copy is never reconciled away while its record stays. func (m *Manager) keptCopyRels(destBase string) []string { if m.settings == nil { return nil } base := filepath.Clean(destBase) var out []string for _, k := range m.settings.GetKeptCopies() { if filepath.Clean(k.DestBase) != base { continue } rel, err := filepath.Rel(base, filepath.Clean(k.Path)) if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { continue } out = append(out, filepath.ToSlash(rel)) } return out } // dropKeptCopiesUnder (R-1252, HUNT-2 H9) drops the records of kept copies inside dir — called after dir itself was // deleted (Remove with „remove backups"), so the backups page never lists a copy that is gone. func (m *Manager) dropKeptCopiesUnder(dir string) { if m.settings == nil { return } d := filepath.Clean(dir) for _, k := range m.settings.GetKeptCopies() { p := filepath.Clean(k.Path) if p == d || strings.HasPrefix(p, d+string(filepath.Separator)) { if err := m.settings.DropKeptCopy(k.ID); err != nil { m.logger.Printf("[WARN] [backup] the record of the deleted kept copy %s could not be dropped: %v", p, err) continue } m.logger.Printf("[INFO] [backup] kept copy %s was deleted with its app's backups — record dropped", p) } } } // KeptCopies lists the kept copies for the backups page. func (m *Manager) KeptCopies() []settings.KeptCopy { if m.settings == nil { return nil } return m.settings.GetKeptCopies() } // ErrKeptCopyNotFound / ErrKeptCopyChanged are the refusals of RemoveKeptCopy. var ( ErrKeptCopyNotFound = errors.New("kept copy not found") ErrKeptCopyChanged = errors.New("kept copy is not the recorded directory any more") ) // RemoveKeptCopy is the household's „Eltávolítás": it removes ONE kept copy by its record — the recorded path, still // strictly inside the recorded copy tree, and still the same directory (device + inode) the night recorded. Anything // else refuses and removes nothing. A copy already gone just drops its record. func (m *Manager) RemoveKeptCopy(id string) error { if m.settings == nil { return ErrKeptCopyNotFound } var rec *settings.KeptCopy for _, k := range m.settings.GetKeptCopies() { if k.ID == id { kk := k rec = &kk break } } if rec == nil { return ErrKeptCopyNotFound } st, err := os.Lstat(rec.Path) if errors.Is(err, fs.ErrNotExist) { m.logger.Printf("[INFO] [backup] kept copy %s is already gone — record dropped", rec.Path) return m.settings.DropKeptCopy(id) } if err != nil { return fmt.Errorf("kept copy %s: %w", rec.Path, err) } if !st.IsDir() { return fmt.Errorf("%w: %s is not a directory", ErrKeptCopyChanged, rec.Path) } if dev, ino := statIdentity(st); dev != rec.Dev || ino != rec.Ino { m.logger.Printf("[WARN] [backup] kept copy removal REFUSED: %s is not the recorded directory (identity changed)", rec.Path) return fmt.Errorf("%w: %s", ErrKeptCopyChanged, rec.Path) } rel, err := filepath.Rel(rec.DestBase, rec.Path) if err != nil || rel == "." || strings.HasPrefix(rel, "..") { return fmt.Errorf("%w: %s is not inside %s", ErrKeptCopyChanged, rec.Path, rec.DestBase) } if err := tier2SafeRemove(rec.DestBase, rec.Path); err != nil { return err } m.logger.Printf("[INFO] [backup] kept copy REMOVED by the household: %s %q (%s) at %s", rec.Kind, rec.Source, humanizeBytes(rec.SizeBytes), rec.Path) return m.settings.DropKeptCopy(id) } func statIdentity(st os.FileInfo) (dev, ino uint64) { if s, ok := st.Sys().(*syscall.Stat_t); ok { return uint64(s.Dev), s.Ino } return 0, 0 } // ── R-1181 second half: an EMPTIED present folder is not mirrored over a full copy ──────────────────────────────── // hasRegularFile reports whether dir holds at least one regular file anywhere below it (stops at the first). func hasRegularFile(dir string) bool { found := false _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil } if d.Type().IsRegular() { found = true return fs.SkipAll } return nil }) return found } // countRegularFiles counts the regular files below dir (for the log line only). func countRegularFiles(dir string) int { n := 0 _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { if err == nil && d.Type().IsRegular() { n++ } return nil }) return n } // emptiedSourceRefused reports whether mirroring src over dst would turn a copy that holds files into an empty one // (`rsync --delete` from an emptied folder). Such a leg is refused for this run: the copy stays, the run warns. func (m *Manager) emptiedSourceRefused(what, src, dst string) bool { if hasRegularFile(src) || !hasRegularFile(dst) { return false } m.logger.Printf("[WARN] [backup] Tier 2: %s — the source folder is EMPTY (0 files) and its second-drive copy holds %d file(s); NOT mirrored, the copy is kept: %s", what, countRegularFiles(dst), dst) return true } // storageLabel is the label the pages show for a registered drive path (its path when it has none). func (m *Manager) storageLabel(path string) string { if m.settings != nil { if l := m.settings.GetStorageLabel(path); l != "" { return l } } return path }