List remote snapshots without requiring the private key (closes #64) #83

Merged
clawbot merged 3 commits from fix-snapshot-list-remote into main 2026-08-09 07:34:15 +02:00

3 Commits

Author SHA1 Message Date
9a45221b79 Fix timezone drift and --json truncation in snapshot list (closes #64)
All checks were successful
check / check (pull_request) Successful in 2m24s
Three defects in the merged listing path, all found in review.

1. The TIMESTAMP column mixed two timezones. Remote-only rows render
   timestamp.UTC(); local rows come from ListRecent, whose scanner
   decoded Unix seconds in the host's local zone, unlike the two other
   snapshot scanners in that file. Both render through the same
   zone-less format string, so on a non-UTC host the same snapshot
   showed one time when locally tracked and another when remote-only,
   in the same column, with nothing to indicate why.

   Normalized at the point the timestamp enters the domain rather than
   at the display layer: scanSnapshotRows now decodes in UTC like its
   siblings, and GetIncompleteByHostname's copy of that loop was folded
   onto the shared scanner so the three call sites cannot drift apart
   again.

2. --json silently truncated. The early return skipped reportListDrift,
   so neither the 1000-row cap nor the unreadable-manifest count reached
   a machine consumer: past the cap the document was short with no
   signal at all. Both counts now go to stderr, where the
   unreachable-destination warning already goes. The document's shape is
   deliberately unchanged, so existing consumers keep parsing.

3. The --json stderr workaround was half-applied. Two per-snapshot
   log.Warn calls on the same new path were left unguarded, and the
   logger writes to stdout at default level, so one corrupt manifest put
   a log line ahead of the document and broke `| jq`. Both now route
   through the same JSON-aware writer. They are also collected during
   the concurrent manifest reads and emitted afterwards in key order,
   since that writer is not safe for concurrent use. Still a local
   workaround; the logger itself is issue #82.

Also from review, non-blocking: the destination-listing failure is no
longer printed twice in table mode, the identifier cell is no longer
computed and discarded for locally tracked rows, and the merge sort is
stable so that rows sharing the zero-timestamp fallback keep a
deterministic order.

Tests: each fix has a test that fails without it. The timezone tests
pin time.Local to a non-UTC zone and assert Location identity, so they
would have caught this on the UTC host where it was missed. The JSON
stdout test redirects the process's own stdout to a pipe and rebuilds
the logger over it, so it can actually observe a log line landing on
stdout ahead of the document rather than asserting on an injected
buffer the log never reaches.
2026-08-09 05:21:43 +00:00
0e2929d75d List remote snapshots without requiring the private key (closes #64)
All checks were successful
check / check (pull_request) Successful in 2m13s
ListSnapshots built its table entirely from the local SQLite index. The
only remote access, reportRemoteDrift, was gated on AgeSecretKey being
non-empty — 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. The manifest is unencrypted, so a
host holding only the public key can enumerate what it has backed up:
one streamed listing of the metadata/ prefix, then a manifest read per
remote key the local index does not already account for, bounded by
maxRemoteOnlyRows and run with bounded concurrency.

A remote-only snapshot's hostname and name are deliberately NOT
recovered. RemoteSnapshotKey is one-way and the manifest stores the
hash rather than the human ID, so they are recoverable only from the
encrypted per-snapshot database; making them readable from remote
storage would undo a deliberate privacy property (#81). Such rows are
labelled "<remote only:<12 hex chars>>", carry the real timestamp and
compressed size from the manifest, and show "<remote only>" in the two
columns that genuinely require the local index. --json carries the full
64-character key in remote_key, and remote_present distinguishes seen
(true), missing (false) and not-listable (null).

Local records with no counterpart on the destination store are still
surfaced as drift, and the remediation hint now names `vaultik prune`,
which exists, rather than `vaultik snapshot cleanup`, which does not:
CleanupLocalSnapshots is already wired as prune's first pass, and
re-adding a second entry point would undo the CLI consolidation.

reportRemoteDrift collapses. Its remote-only half is subsumed by the
table — those snapshots are rows now, not a footnote count — and its
local-only half reads the merge ListSnapshots already computed, so the
command lists the destination exactly once per invocation.

An unreachable destination stays a warning plus local-only output and a
zero exit code, as the doc comment always claimed. In --json mode that
warning goes to stderr, because the logger and the UI writer both emit
on stdout and would otherwise corrupt the document.

Tests cover remote-only rendering, the no-private-key property (a
storer that counts prefix listings and records fetched keys, asserting
the destination is read and nothing encrypted is touched), graceful
degradation on an unreachable destination in both output modes,
local-only drift, and an unreadable manifest not hiding other
snapshots.
2026-08-09 03:15:52 +00:00
0952925453 Route every remote manifest read through downloadManifestByKey
internal/vaultik/verify.go and internal/vaultik/info.go each built the
metadata/<remote-key>/manifest.json.zst path and decoded the manifest
inline, duplicating downloadManifestByKey. Both now call the helper.

The manifest is currently stored compressed but unencrypted, which is
what will let `snapshot list` enumerate the destination store on a host
holding no private key. Whether to encrypt it is still open (#81), and
a single reader means that decision has one call site to change rather
than four.

Refs #81
2026-08-09 03:15:36 +00:00