List remote snapshots without requiring the private key (closes #64)
All checks were successful
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

View File

@@ -141,30 +141,21 @@ func (v *Vaultik) loadVerificationData(
// All remote paths use the hashed key derived from the human ID.
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
// Download manifest
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)
log.Info("Downloading manifest", "path", manifestPath)
// Download manifest. downloadManifestByKey is the single reader for
// remote manifests; see its doc comment.
log.Info("Downloading manifest", "remote_key", remoteKey)
if !opts.JSON {
v.stdoutf("Downloading manifest...\n")
}
manifestReader, err := v.Storage.Get(v.ctx, manifestPath)
manifest, err := v.downloadManifestByKey(remoteKey)
if err != nil {
return nil, nil, nil, v.deepVerifyFailure(result, opts,
fmt.Sprintf("failed to download manifest: %v", err),
fmt.Errorf("failed to download manifest: %w", err))
}
defer func() { _ = manifestReader.Close() }()
manifest, err := snapshot.DecodeManifest(manifestReader)
if err != nil {
return nil, nil, nil, v.deepVerifyFailure(result, opts,
fmt.Sprintf("failed to decode manifest: %v", err),
fmt.Errorf("failed to decode manifest: %w", err))
}
log.Info("Manifest loaded",
"manifest_blob_count", manifest.BlobCount,
"manifest_total_size", ubytes(manifest.TotalCompressedSize))