package offsitekeys import ( "context" "fmt" "regexp" "strings" "time" ) // R-895, THE MEASUREMENT SLICE (hub v0.157.0; design `audits/agent-allowlist-rotation-2026-10-17/P4/design-R-895.md`). // // The clean-up window's drop check trusts the snapshot counts the box sends. Option B of the design: the hub counts the // box's restic `snapshots/` files ITSELF, over the login it already holds (the sub-account password, sealed — no new // credential), and later alarms on files no box run explains. This slice only LISTS and LOGS — count, newest and // oldest upload time — once a day beside the authorized_keys audit, so the listing's shape on the provider is measured // before anything alarms on it. READ-ONLY: one `ls -l`; it writes, moves and deletes nothing. // SnapshotListing is the hub's own read of one repository's snapshots/ directory. type SnapshotListing struct { Files int // entries whose name is a restic snapshot id (64 hex) Other int // lines that are not (a header, a temp file, an unparsable line) Newest time.Time // zero when no time could be read Oldest time.Time // Entries (slice 2, hub v0.158.0): each snapshot id with its upload time (zero when unreadable). A restic // snapshot file is written once and never changed, so its mtime is its upload time. Entries map[string]time.Time } var snapshotID = regexp.MustCompile(`^[0-9a-f]{64}$`) // ListSnapshots lists /snapshots with `ls -l` in the restricted shell. now anchors a year-less date. func (r *Registrar) ListSnapshots(ctx context.Context, t Target, password string, now time.Time) (SnapshotListing, error) { sh, err := r.open(ctx, t, password) if err != nil { return SnapshotListing{}, err } defer sh.Close() out, err := sh.Run(ctx, "ls -l "+strings.TrimRight(t.RepoPath, "/")+"/snapshots", nil) if err != nil { return SnapshotListing{}, fmt.Errorf("offsitekeys: list snapshots: %w", err) } return parseLsL(string(out), now), nil } // parseLsL reads `ls -l` lines: mode links owner group size MON DD (HH:MM|YYYY) name. func parseLsL(out string, now time.Time) SnapshotListing { var l SnapshotListing for _, line := range strings.Split(out, "\n") { f := strings.Fields(line) if len(f) == 0 || strings.HasPrefix(line, "total ") { continue } if len(f) < 9 || !snapshotID.MatchString(f[len(f)-1]) { l.Other++ continue } l.Files++ if l.Entries == nil { l.Entries = map[string]time.Time{} } l.Entries[f[len(f)-1]] = time.Time{} if ts, ok := lsTime(f[len(f)-4], f[len(f)-3], f[len(f)-2], now); ok { l.Entries[f[len(f)-1]] = ts if l.Newest.IsZero() || ts.After(l.Newest) { l.Newest = ts } if l.Oldest.IsZero() || ts.Before(l.Oldest) { l.Oldest = ts } } } return l } // lsTime reads ls's two date forms: "Oct 17 03:12" (within six months, year implied) and "Oct 17 2025". func lsTime(mon, day, hm string, now time.Time) (time.Time, bool) { if strings.Contains(hm, ":") { ts, err := time.Parse("Jan 2 15:04 2006", fmt.Sprintf("%s %s %s %d", mon, day, hm, now.Year())) if err != nil { return time.Time{}, false } if ts.After(now.Add(24 * time.Hour)) { // December's file read in January ts = ts.AddDate(-1, 0, 0) } return ts, true } ts, err := time.Parse("Jan 2 2006", fmt.Sprintf("%s %s %s", mon, day, hm)) return ts, err == nil }