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
+21 -3
View File
@@ -53,18 +53,36 @@ const (
)
// SnapshotInfo contains information about a snapshot.
// UncompressedSize and NewChunkSize are populated only when the snapshot
// is present in the local database; LocallyTracked indicates whether
// those values are meaningful.
//
// LocallyTracked says which of the two sources this row came from, and
// therefore which fields are meaningful:
//
// - true: the snapshot is in the local index. ID is its human
// snapshot ID and UncompressedSize/NewChunkSize are populated.
// - false: the snapshot was found only on the destination store. ID
// is empty, because the human ID cannot be recovered from remote
// storage without the age secret key (see RemoteKey), and
// UncompressedSize/NewChunkSize are zero because they are computed
// from local index rows that do not exist.
//
// RemoteKey is always populated: for a locally tracked snapshot it is
// the key the snapshot would occupy on the destination store, and for a
// remote-only snapshot it is the only identifier available.
//
// RemotePresent reports whether the snapshot's metadata was seen on the
// destination store. It is nil when the destination could not be
// listed, so "absent" and "unknown" stay distinguishable.
//
//nolint:tagliatelle // snake_case is the established output format
type SnapshotInfo struct {
ID types.SnapshotID `json:"id"`
RemoteKey string `json:"remote_key"`
Timestamp time.Time `json:"timestamp"`
CompressedSize int64 `json:"compressed_size"`
UncompressedSize int64 `json:"uncompressed_size,omitempty"`
NewChunkSize int64 `json:"new_chunk_size,omitempty"`
LocallyTracked bool `json:"locally_tracked"`
RemotePresent *bool `json:"remote_present"`
}
// formatBytes formats bytes in a human-readable format