package backup import ( "context" "encoding/json" "errors" "sort" "time" ) // R-193 Part 3 — WHAT IS IN THERE. After a successful unlock the customer is shown the contents of the // repository they just opened: which apps, from when, how big. // // READ-ONLY, AND THAT IS THE POINT. This restores nothing, puts nothing back, and compares nothing // against live data. Unlocking and restoring are separate (operator ruling, 2026-08-05): restore is // already per-app and already lives in the backups area, and a screen that unlocks and then offers to // overwrite is two decisions wearing one button. // // WHY A LISTING AT ALL, rather than a success message: "unlocked" with nothing shown is // indistinguishable from having unlocked an EMPTY store, and the customer has no way to tell whether // what came back is the right thing. Seeing their own app names and dates is how they know. // errNoOffsiteTarget is returned when the repository cannot even be addressed — no off-site target is // configured on this box yet. Distinguished from a read failure because the remedy differs: this one // resolves by itself once the tier is re-applied. var errNoOffsiteTarget = errors.New("no off-site target is configured on this box yet") // ErrNoOffsiteTarget reports whether err is the not-yet-configured case, so a caller can say the right // thing rather than showing a generic failure. func ErrNoOffsiteTarget(err error) bool { return errors.Is(err, errNoOffsiteTarget) } // OffsiteInventoryApp is one app's presence in the opened repository. Non-secret throughout. type OffsiteInventoryApp struct { App string // the restic tag == the stack name LatestAt time.Time // the newest snapshot's time for this app SizeBytes int64 // restore size of that newest snapshot (0 = could not be determined) } // OffsiteInventory is the whole answer, including the EMPTY case stated explicitly. type OffsiteInventory struct { Apps []OffsiteInventoryApp // Empty is true when the repository opened cleanly and holds no snapshots. It is a real and // confusing outcome — a bare list there reads as a broken page — so it is named rather than // inferred from len(Apps)==0, which is also what a failed read looks like. Empty bool } // OffsiteInventoryList opens the repository and reports what is in it, grouped per app. One // `snapshots --json` call for the whole repo, then one `stats` per app for the newest snapshot's size. // // A per-app size failure is NOT fatal: the app is still listed, with SizeBytes 0, because knowing an // app is in there matters more than knowing how big it is, and dropping it would under-report the // customer's own data. func (m *Manager) OffsiteInventoryList(ctx context.Context) (OffsiteInventory, error) { var inv OffsiteInventory // A box can hold a recovered key and still have no off-site COORDINATES — the pristine rebuilt // shape, before its target is re-applied. Reading the repository is impossible then, and saying so // is the honest answer; without this guard offboxBaseArgs nil-derefs on the missing target. if !m.OffboxConfigured() { return inv, errNoOffsiteTarget } t := m.settings.GetOffboxTarget() base, env := m.offboxBaseArgs(t) sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout) defer cancel() out, err := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "--json")...) if err != nil { return inv, err } var snaps []struct { ShortID string `json:"short_id"` ID string `json:"id"` Time time.Time `json:"time"` Tags []string `json:"tags"` } if uerr := json.Unmarshal(out, &snaps); uerr != nil { return inv, uerr } if len(snaps) == 0 { inv.Empty = true return inv, nil } // Newest snapshot per tag. A snapshot may carry several tags; each names an app it belongs to. newest := map[string]struct { id string at time.Time }{} for _, s := range snaps { id := s.ShortID if id == "" { id = s.ID } for _, tag := range s.Tags { if tag == "" { continue } if cur, ok := newest[tag]; !ok || s.Time.After(cur.at) { newest[tag] = struct { id string at time.Time }{id: id, at: s.Time} } } } if len(newest) == 0 { // Snapshots exist but carry no tags — not "empty", and saying so would be a lie. Report an // empty app list without the Empty flag; the page renders the honest in-between wording. return inv, nil } for tag, n := range newest { app := OffsiteInventoryApp{App: tag, LatestAt: n.at} if size, serr := m.offboxSnapshotSize(ctx, n.id); serr == nil { app.SizeBytes = size } else { m.logger.Printf("[WARN] [offbox] inventory: size of %s's newest snapshot unknown: %v (listing it anyway)", tag, serr) } inv.Apps = append(inv.Apps, app) } sort.Slice(inv.Apps, func(i, j int) bool { return inv.Apps[i].App < inv.Apps[j].App }) return inv, nil } // HumanizeBytes exposes the shared byte formatter to the web layer so the recovery page renders sizes // the same way every other surface does. func HumanizeBytes(n int64) string { return humanizeBytes(n) }