package backup import ( "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // D62 (operator ruling 2026-10-29; `07` §6.3): the household sees its restore copies in the kept-copies list // („Megőrzött másolatok") on the restore page — the app, the date of the restore the copy protects, its size, and the // day the box deletes it by itself (restoreCopyUnusedAge after the restore) — and may delete one earlier with // „Törlés most", after a warning and the app's name typed. // // A copy a hold or a pending put-back needs is NEVER deletable (the same three states the sweep leaves alone, R-1256 // H8): the row says why in one line and the button is off; the handler refuses it too. While any backup or restore // operation runs on the box, a delete is refused (409). The check and the removal are ONE step under m.mu, so a // restore cannot begin in between (the R-1256 H6 shape). Pinned by TestD62_*. // Why a restore copy is needed — the row's one line, and the refusal's reason. "" = not needed: deletable. const ( RestoreCopyNeededHold = "hold" // the app is held (a failed put-back keeps its copy, D20) — support decides RestoreCopyNeededPutBack = "putback" // a put-back waits for its drive (R-1251) or a cut restore names it (D22) RestoreCopyNeededUnknown = "unknown" // the app-stop marker cannot be read: nothing is offered (fail closed) // RestoreCopyNeededChecking (R-1342): the box still watches whether the restored app runs stably (up to 30 minutes // after the restore, R-1270) — the copy is the way back if it does not. RestoreCopyNeededChecking = "checking" ) var ( ErrRestoreCopyNotFound = errors.New("restore copy not found") ErrRestoreCopyNeeded = errors.New("restore copy is needed by a hold or a pending put-back") ErrRestoreCopyBusy = errors.New("a backup or restore operation runs") ) // RestoreCopy is one restore copy (/backups/restore-undo//). type RestoreCopy struct { App string Stamp string // the folder's name; with App, the copy's identity on the page At time.Time // when the restore began (the stamp; the folder's time when the stamp does not parse) SizeBytes int64 Path string Needed string // "" or one of RestoreCopyNeeded* DeleteOn time.Time // when the sweep removes it; zero when Needed != "" (a needed copy is never swept) } // restoreCopyAt is a copy's restore time: its stamp, or the folder's time when the stamp does not parse. func restoreCopyAt(dir, name string) (time.Time, bool) { if at, err := time.Parse(restoreCopyStampLayout, name); err == nil { return at, true } fi, err := os.Stat(dir) if err != nil { return time.Time{}, false } return fi.ModTime(), true } // restoreCopyNeededFor is why app's copies are needed ("" = deletable). named is appStopNamedApps' answer, ok its ok. func (m *Manager) restoreCopyNeededFor(app string, named map[string]bool, ok bool) string { if !ok || m.settings == nil { return RestoreCopyNeededUnknown } if named[app] { return RestoreCopyNeededPutBack } if h, held := m.settings.GetRestoreHold(app); held { if h.Reason == settings.HoldReasonPutBackPending { return RestoreCopyNeededPutBack } return RestoreCopyNeededHold } return "" } func dirSize(dir string) int64 { var n int64 _ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { if err == nil && d.Type().IsRegular() { if fi, e := d.Info(); e == nil { n += fi.Size() } } return nil }) return n } // RestoreCopies lists every restore copy on the box's drives, oldest first. func (m *Manager) RestoreCopies() []RestoreCopy { named, ok := m.appStopNamedApps() var out []RestoreCopy for _, nsRoot := range m.primaryUnitRoots() { base := filepath.Join(nsRoot, "backups", "restore-undo") apps, err := os.ReadDir(base) if err != nil { continue } for _, a := range apps { if !a.IsDir() { continue } app := a.Name() needed := m.restoreCopyNeededFor(app, named, ok) copies, err := os.ReadDir(filepath.Join(base, app)) if err != nil { continue } for _, c := range copies { if !c.IsDir() { continue } dir := filepath.Join(base, app, c.Name()) at, ok := restoreCopyAt(dir, c.Name()) if !ok { continue } rc := RestoreCopy{App: app, Stamp: c.Name(), At: at, SizeBytes: dirSize(dir), Path: dir, Needed: needed} if rc.Needed == "" && m.watchingCopy(dir) { rc.Needed = RestoreCopyNeededChecking } if rc.Needed == "" { rc.DeleteOn = at.Add(restoreCopyUnusedAge) } out = append(out, rc) } } } sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) }) return out } // validCopyName is a single path element: no separator, not empty, not "." or "..". func validCopyName(s string) bool { return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, `/\`) && filepath.Base(s) == s } // DeleteRestoreCopy removes ONE restore copy, named by its app and its folder's name, and nothing else (the path // guard: both names are single path elements, the folder is a real directory — not a link — directly under // /backups/restore-undo//). Refused while any backup or restore operation runs (ErrRestoreCopyBusy) // and for a copy a hold or a pending put-back needs (ErrRestoreCopyNeeded). The check and the removal hold m.mu, so // no restore can begin in between. Returns the copy as it was (for the log line and the operator event). func (m *Manager) DeleteRestoreCopy(app, stamp string) (RestoreCopy, error) { if !validCopyName(app) || !validCopyName(stamp) { return RestoreCopy{}, ErrRestoreCopyNotFound } m.mu.Lock() defer m.mu.Unlock() if busy, _ := m.restoreBusyLocked(); busy != "" { return RestoreCopy{}, ErrRestoreCopyBusy } var found *RestoreCopy for _, nsRoot := range m.primaryUnitRoots() { appDir := filepath.Join(nsRoot, "backups", "restore-undo", app) dir := filepath.Join(appDir, stamp) fi, err := os.Lstat(dir) if err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 { continue } if filepath.Dir(dir) != appDir || filepath.Dir(appDir) != filepath.Join(nsRoot, "backups", "restore-undo") { continue } at, ok := restoreCopyAt(dir, stamp) if !ok { continue } found = &RestoreCopy{App: app, Stamp: stamp, At: at, SizeBytes: dirSize(dir), Path: dir} break } if found == nil { return RestoreCopy{}, ErrRestoreCopyNotFound } named, ok := m.appStopNamedApps() if found.Needed = m.restoreCopyNeededFor(app, named, ok); found.Needed != "" { return *found, ErrRestoreCopyNeeded } if m.watching[found.Path] { // R-1342: under m.mu, the same lock the watch marks its copy under found.Needed = RestoreCopyNeededChecking return *found, ErrRestoreCopyNeeded } if err := os.RemoveAll(found.Path); err != nil { return *found, fmt.Errorf("removing the restore copy %s: %w", found.Path, err) } _ = os.Remove(filepath.Dir(found.Path)) // the per-app folder, when it is now empty return *found, nil } // setWatching marks (on) or clears a copy a stable-start watch holds (R-1342). func (m *Manager) setWatching(dir string, on bool) { if dir == "" { return } m.mu.Lock() defer m.mu.Unlock() if on { if m.watching == nil { m.watching = map[string]bool{} } m.watching[dir] = true return } delete(m.watching, dir) } // watchingCopy reports whether a stable-start watch still holds the copy. func (m *Manager) watchingCopy(dir string) bool { m.mu.Lock() defer m.mu.Unlock() return m.watching[dir] }