List remote snapshots without requiring the private key (closes #64)
check / check (push) Successful in 6s

ListSnapshots built its table entirely from the local SQLite index. The
only remote access, reportRemoteDrift, was gated on AgeSecretKey != "",
so on a correctly configured host - which by design holds no private key
- snapshot list never contacted the destination store at all. A user who
lost their local index could not see their own backups, and the
"<remote only>" cell the README documents was unreachable dead code.

The listing is now the union of the local index and the destination
store, with no age_secret_key gate. Remote-only snapshots cannot have
their hostname or name recovered - RemoteSnapshotKey is one-way and the
manifest stores the hash - so they are listed by abbreviated remote key
with the real timestamp and compressed size from the manifest, and
"<remote only>" in the two columns that require the local index. Nothing
new is written to remote storage and the human ID is never fabricated.

An unreachable destination degrades to local-only with a warning and a
zero exit code. remote_present is null rather than false in that case,
so "absent" and "unknown" stay distinguishable and no drift is claimed
from a listing that never happened.

Also:

- Snapshot timestamps are normalised to UTC in scanSnapshotRows, the
  single point where they enter the domain. Previously one of three
  scanners omitted .UTC(), so on a non-UTC host the same snapshot
  rendered a different time depending on whether it was locally tracked.
- The 1000-row cap and the unreadable-manifest count are reported in
  --json mode as well as table mode, so machine consumers cannot be
  silently truncated. The JSON shape is unchanged.
- Warnings raised while listing are routed to stderr rather than the
  logger, which writes to stdout and would corrupt the JSON document.
  This is a local workaround for the logger bug tracked in #82 and
  should be removed when that lands.
- downloadManifestByKey is now the only remote manifest reader, so the
  manifest privacy question in #81 has a single call site to change.
- The orphaned "vaultik snapshot cleanup" hint now names vaultik prune;
  that command was folded into prune by the 2026-07-02 consolidation.
This commit was merged in pull request #83.
This commit is contained in:
2026-08-09 07:34:15 +02:00
parent af607e3597
commit 50e20b460e
10 changed files with 1637 additions and 314 deletions
+8 -239
View File
@@ -9,10 +9,8 @@ import (
"regexp"
"sort"
"strings"
"text/tabwriter"
"time"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
)
@@ -447,243 +445,6 @@ func (v *Vaultik) getSnapshotBlobSizes(snapshotID string) (int64, int64) {
return compressed, uncompressed
}
// ListSnapshots prints the table of snapshots, plus any reconciliation
// warnings/notes between the local index and the backup destination
// store.
//
// The local index database is always the primary source for the
// table — it has the human snapshot IDs, timestamps, and per-snapshot
// stats.
//
// If an age secret key is configured AND remote listing succeeds, we
// cross-reference: any local snapshot whose hashed key isn't visible
// remotely gets a "local-only" cleanup hint, and any remote key that
// doesn't correspond to a known local snapshot gets reported in a
// NOTE.
//
// If no age key is set the local machine is assumed write-only
// (backup-only), so we skip remote listing entirely — there's no
// value showing keys the user couldn't restore anyway.
//
// If remote listing fails (unmounted volume, permission denied,
// network), we degrade to local-only with a warning. List never
// fails just because the destination is unreachable.
func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
log.Info("Listing snapshots")
localSnaps, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
if err != nil {
return fmt.Errorf("listing local snapshots: %w", err)
}
snapshots := make([]SnapshotInfo, 0, len(localSnaps))
for _, ls := range localSnaps {
if ls.CompletedAt == nil {
continue
}
snapshots = append(snapshots, v.snapshotInfoFromLocal(ls))
}
sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
})
if jsonOutput {
encoder := json.NewEncoder(v.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(snapshots)
}
err = v.printSnapshotTable(snapshots)
if err != nil {
return err
}
if v.Config.AgeSecretKey == "" {
return nil
}
v.reportRemoteDrift(localSnaps)
return nil
}
// reportRemoteDrift cross-references local snapshot records against the
// remote metadata keys and reports local-only records and unknown
// remote keys. Never fails: remote listing errors degrade to a warning.
func (v *Vaultik) reportRemoteDrift(localSnaps []*database.Snapshot) {
remoteKeys, err := v.listAllRemoteSnapshotKeys()
if err != nil {
v.UI.Warningf("Could not list backup destination store: %v.", err)
return
}
localKeys := make(map[string]string, len(localSnaps))
for _, ls := range localSnaps {
if ls.CompletedAt == nil {
continue
}
localKeys[snapshot.RemoteSnapshotKey(ls.ID.String())] = ls.ID.String()
}
remoteSet := make(map[string]bool, len(remoteKeys))
for _, k := range remoteKeys {
remoteSet[k] = true
}
var localOnly []string
for key, humanID := range localKeys {
if !remoteSet[key] {
localOnly = append(localOnly, humanID)
}
}
var remoteOnlyCount int
for key := range remoteSet {
if _, ok := localKeys[key]; !ok {
remoteOnlyCount++
}
}
if len(localOnly) > 0 {
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
"destination store:", len(localOnly))
for _, id := range localOnly {
v.UI.Infof("%s", v.UI.Snapshot(id))
}
v.UI.Infof("Run 'vaultik snapshot cleanup' to remove stale local records.")
}
if remoteOnlyCount > 0 {
v.UI.Noticef("NOTE: %d remote snapshot(s) found in backup destination "+
"store but not in local database.", remoteOnlyCount)
}
}
// snapshotInfoFromLocal builds a SnapshotInfo row from a local snapshot
// record. Failures from any per-snapshot stat query degrade that
// column to its snapshot-row fallback but never fail the listing.
func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
idStr := ls.ID.String()
totalSize, err := v.Repositories.Snapshots.GetSnapshotTotalCompressedSize(
v.ctx, idStr)
if err != nil {
log.Warn("Failed to get total compressed size", "id", idStr, "error", err)
totalSize = ls.BlobSize
}
uncompressedSize, err := v.Repositories.Snapshots.GetSnapshotUncompressedChunkSize(
v.ctx, idStr)
if err != nil {
log.Warn("Failed to get uncompressed chunk size", "id", idStr, "error", err)
}
newChunkSize, err := v.Repositories.Snapshots.GetSnapshotNewChunkSize(v.ctx, idStr)
if err != nil {
log.Warn("Failed to get new chunk size", "id", idStr, "error", err)
}
return SnapshotInfo{
ID: ls.ID,
Timestamp: ls.StartedAt,
CompressedSize: totalSize,
UncompressedSize: uncompressedSize,
NewChunkSize: newChunkSize,
LocallyTracked: true,
}
}
// tabPadding is the tabwriter cell padding for the snapshot table.
const tabPadding = 3
// printSnapshotTable renders the snapshot list as a formatted table
func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error {
w := tabwriter.NewWriter(v.Stdout, 0, 0, tabPadding, ' ', 0)
_, err := fmt.Fprintln(w, "CONFIGURED SNAPSHOTS:")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, "NAME\tPATHS")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, "────\t─────")
if err != nil {
return err
}
for _, name := range v.Config.SnapshotNames() {
snap := v.Config.Snapshots[name]
paths := strings.Join(snap.Paths, ", ")
_, err = fmt.Fprintf(w, "%s\t%s\n", name, paths)
if err != nil {
return err
}
}
_, err = fmt.Fprintln(w)
if err != nil {
return err
}
_, err = fmt.Fprintln(w, "REMOTE SNAPSHOTS:")
if err != nil {
return err
}
_, err = fmt.Fprintln(w,
"SNAPSHOT ID\tTIMESTAMP\tCOMPRESSED SIZE\t"+
"UNCOMPRESSED SIZE\tNEW CHUNK SIZE")
if err != nil {
return err
}
_, err = fmt.Fprintln(w,
"───────────\t─────────\t───────────────\t"+
"─────────────────\t──────────────")
if err != nil {
return err
}
const remoteOnlyCell = "<remote only>"
for _, snap := range snapshots {
uncompressed := remoteOnlyCell
newChunks := remoteOnlyCell
if snap.LocallyTracked {
uncompressed = formatBytes(snap.UncompressedSize)
newChunks = formatBytes(snap.NewChunkSize)
}
_, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
snap.ID,
snap.Timestamp.Format("2006-01-02 15:04:05"),
formatBytes(snap.CompressedSize),
uncompressed,
newChunks)
if err != nil {
return err
}
}
return w.Flush()
}
// SnapshotPurgeOptions contains options for the snapshot purge command.
type SnapshotPurgeOptions struct {
KeepLatest bool // Keep only the most recent snapshot per name
@@ -1123,6 +884,14 @@ func (v *Vaultik) CleanupLocalSnapshots() error {
// metadata/<remoteKey>/manifest.json.zst. The remoteKey is the double-
// SHA256 derivation produced by snapshot.RemoteSnapshotKey, not the
// human snapshot ID. Callers that have a human ID must hash first.
//
// This is the only place vaultik reads a manifest off the destination
// store, deliberately: the manifest is currently stored compressed but
// unencrypted, which is what lets `snapshot list` enumerate the
// destination on a host holding no private key. Whether to encrypt it
// is open (issue #81), and routing every read through here means that
// decision has exactly one call site to change. Keep it that way — do
// not open metadata/<key>/manifest.json.zst directly elsewhere.
func (v *Vaultik) downloadManifestByKey(remoteKey string) (*snapshot.Manifest, error) {
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)