Hash snapshot IDs at the storage boundary; make snapshot list resilient

Two related changes, both addressing leakage and brittleness around
the public bytes the destination store sees.

First, every remote storage path that previously embedded a human
snapshot ID (e.g. metadata/heraklion_berlin.sneak.fs.photos.2026.
catalog_2026-06-24T07:00:15Z/...) now uses the hashed remote key:

  RemoteSnapshotKey(id) = hex(SHA256(SHA256("vaultik|" + id)))

Applied at:

  * uploadSnapshotArtifacts (snapshot create write path)
  * the manifest.json.zst snapshot_id field — manifest is
    unencrypted, so the human ID would otherwise be readable to
    anyone with bucket-list permission
  * cleanupIncompleteSnapshots metadata-existence probe
  * snapshot restore / verify (downloadSnapshotDB,
    loadVerificationData)
  * downloadManifestByKey, deleteRemoteSnapshotByKey
  * CleanupLocalSnapshots reconciliation
  * the locally-driven removal paths (RemoveSnapshot,
    RemoveAllSnapshots, confirmAndExecutePurge)

The local index database keeps human IDs everywhere — the hash is a
boundary translation, not a rename. A directory listing of the
backup destination now looks like
"metadata/<64-hex>/{db.zst.age,manifest.json.zst}" with no host,
snapshot-name, or timestamp information visible.

Second, snapshot list no longer fails just because remote storage is
unreachable, and only consults the remote when the local machine can
plausibly decrypt:

  * Listing is always driven by the local index database — that's
    what holds the human IDs, timestamps, and per-snapshot stats
    that the table actually shows.
  * If no age secret key is configured, we skip remote listing
    entirely (the box is treated as a write-only backup machine —
    there's no value showing it remote-only keys it could never
    restore).
  * If a key IS configured, we try the remote listing; failures
    (volume unmounted, permission denied, network error) downgrade
    to a warning instead of aborting the command.
  * When the remote listing succeeds, we cross-reference by hashing
    each local human ID and diffing against the returned key set.
    Local-only snapshots get the existing "stale local record"
    cleanup hint; remote-only keys are surfaced as a single
    "NOTE: N remote snapshot(s) found in backup destination store
    but not in local database" line.

FileStorer construction also no longer does an eager mkdir — the
basePath is recorded and the directory is created lazily on first
write. A missing or unmounted destination during `snapshot list`
should NOT block the command, and now it doesn't.

RemoveAllSnapshots is rewritten to drive deletion from the local
index instead of from a remote listing, hashing each local ID to
find the corresponding remote key. Orphan remote keys (no matching
local snapshot) are handled separately and only deleted when
--remote is set. Existing tests are updated to hash storage paths
through the new RemoteSnapshotKey helper.

The hash format is a hard pre-1.0 break: existing remote snapshots
written under the human-ID path scheme are no longer readable; they
need to be either re-uploaded under the new scheme or manually
renamed. There is no fallback path; matching the project policy of
"no migrations pre-1.0."
This commit is contained in:
2026-06-26 01:54:35 +02:00
parent a84b911155
commit fd759a921a
9 changed files with 328 additions and 237 deletions

View File

@@ -0,0 +1,40 @@
package snapshot
import (
"crypto/sha256"
"encoding/hex"
)
// remoteKeyPrefix is mixed into the snapshot ID hash so the resulting
// hex digest is domain-separated from any other "double SHA256 of a
// string" identifier the user might also use. Keeping this stable is a
// hard compatibility requirement: changing it invalidates every
// existing snapshot's remote storage path.
const remoteKeyPrefix = "vaultik|"
// RemoteSnapshotKey returns the storage-side identifier for a snapshot
// given its human snapshot ID. It is hex(SHA256(SHA256(prefix + id))).
// The two SHA256 rounds match Bitcoin's "hash256" convention so the
// output looks like a 64-character hex blob with no exploitable
// structure visible to a remote observer.
//
// We use this in three places:
//
// - the "metadata/<remote-key>/..." subdirectory on the storage
// backend so a directory listing of the bucket / file:// dest
// doesn't reveal hostnames, configured snapshot names, or backup
// timestamps;
// - the `snapshot_id` field of the unencrypted manifest.json.zst
// for the same reason;
// - any code path that needs to translate a known local snapshot ID
// into the path it would occupy on remote storage.
//
// The human ID stays the user-visible handle everywhere else — local
// database joins, CLI arguments, summary lines, log fields — because
// it's never written to the public bytes once this function gates
// every storage-path construction.
func RemoteSnapshotKey(snapshotID string) string {
first := sha256.Sum256([]byte(remoteKeyPrefix + snapshotID))
second := sha256.Sum256(first[:])
return hex.EncodeToString(second[:])
}

View File

@@ -314,10 +314,17 @@ func (sm *SnapshotManager) prepareExportDB(ctx context.Context, dbPath, snapshot
return finalData, tempDBPath, nil return finalData, tempDBPath, nil
} }
// uploadSnapshotArtifacts uploads the database backup and blob manifest to S3 // uploadSnapshotArtifacts uploads the database backup and blob manifest
// to remote storage at metadata/<remote-key>/, where remote-key is the
// double-SHA256 derivation of the snapshot ID (see RemoteSnapshotKey).
// We never write the human-readable snapshot ID into any unencrypted
// part of remote storage so a listing of the destination bucket leaks
// no host, configuration, or scheduling information.
func (sm *SnapshotManager) uploadSnapshotArtifacts(ctx context.Context, snapshotID string, dbData, manifestData []byte) error { func (sm *SnapshotManager) uploadSnapshotArtifacts(ctx context.Context, snapshotID string, dbData, manifestData []byte) error {
remoteKey := RemoteSnapshotKey(snapshotID)
// Upload database backup (compressed and encrypted) // Upload database backup (compressed and encrypted)
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", snapshotID) dbKey := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
dbUploadStart := time.Now() dbUploadStart := time.Now()
if err := sm.storage.Put(ctx, dbKey, bytes.NewReader(dbData)); err != nil { if err := sm.storage.Put(ctx, dbKey, bytes.NewReader(dbData)); err != nil {
@@ -332,7 +339,7 @@ func (sm *SnapshotManager) uploadSnapshotArtifacts(ctx context.Context, snapshot
"speed", humanize.SI(dbUploadSpeed, "bps")) "speed", humanize.SI(dbUploadSpeed, "bps"))
// Upload blob manifest (compressed only, not encrypted) // Upload blob manifest (compressed only, not encrypted)
manifestKey := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID) manifestKey := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)
manifestUploadStart := time.Now() manifestUploadStart := time.Now()
if err := sm.storage.Put(ctx, manifestKey, bytes.NewReader(manifestData)); err != nil { if err := sm.storage.Put(ctx, manifestKey, bytes.NewReader(manifestData)); err != nil {
return fmt.Errorf("uploading blob manifest: %w", err) return fmt.Errorf("uploading blob manifest: %w", err)
@@ -607,9 +614,11 @@ func (sm *SnapshotManager) generateBlobManifest(ctx context.Context, dbPath stri
} }
} }
// Create manifest // Create manifest. SnapshotID in the unencrypted manifest is the
// double-SHA256 remote key, not the human ID, so the public bytes
// don't reveal hostname/snapshot-name/timestamp metadata.
manifest := &Manifest{ manifest := &Manifest{
SnapshotID: snapshotID, SnapshotID: RemoteSnapshotKey(snapshotID),
Timestamp: time.Now().UTC().Format(time.RFC3339), Timestamp: time.Now().UTC().Format(time.RFC3339),
BlobCount: len(blobs), BlobCount: len(blobs),
TotalCompressedSize: totalCompressedSize, TotalCompressedSize: totalCompressedSize,
@@ -680,8 +689,9 @@ func (sm *SnapshotManager) CleanupIncompleteSnapshots(ctx context.Context, hostn
// Check each incomplete snapshot for metadata in storage // Check each incomplete snapshot for metadata in storage
for _, snapshot := range incompleteSnapshots { for _, snapshot := range incompleteSnapshots {
// Check if metadata exists in storage // Check if metadata exists in storage (paths use the hashed
metadataKey := fmt.Sprintf("metadata/%s/db.zst", snapshot.ID) // remote key so we don't leak host info to the listing).
metadataKey := fmt.Sprintf("metadata/%s/db.zst", RemoteSnapshotKey(snapshot.ID.String()))
_, err := sm.storage.Stat(ctx, metadataKey) _, err := sm.storage.Stat(ctx, metadataKey)
if err != nil { if err != nil {

View File

@@ -19,15 +19,20 @@ type FileStorer struct {
} }
// NewFileStorer creates a new filesystem storage backend. // NewFileStorer creates a new filesystem storage backend.
// The basePath directory will be created if it doesn't exist. //
// Uses the real OS filesystem by default; call SetFilesystem to override for testing. // Construction is intentionally cheap and does not touch the filesystem.
// The basePath is recorded; the directory is created lazily on first
// write. Reads (Get/Stat/List) tolerate a missing basePath — a missing
// or unmounted destination during `snapshot list` should NOT block the
// command, it should degrade to "no remote snapshots reachable" with a
// warning. Write operations (Put/PutWithProgress) call MkdirAll for the
// per-blob parent directory, which also covers basePath on first use.
//
// Uses the real OS filesystem by default; call SetFilesystem to
// override for testing.
func NewFileStorer(basePath string) (*FileStorer, error) { func NewFileStorer(basePath string) (*FileStorer, error) {
fs := afero.NewOsFs()
if err := fs.MkdirAll(basePath, 0755); err != nil {
return nil, fmt.Errorf("file:// storage: cannot create or access %s: %w (check that the volume is mounted and writable)", basePath, err)
}
return &FileStorer{ return &FileStorer{
fs: fs, fs: afero.NewOsFs(),
basePath: basePath, basePath: basePath,
}, nil }, nil
} }

View File

@@ -651,10 +651,12 @@ func TestEndToEndFileStorage(t *testing.T) {
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID)) require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
// Verify the backup actually landed on disk under blobs/ and metadata/. // Verify the backup actually landed on disk under blobs/ and metadata/.
// The metadata subdirectory uses the hashed remote key, not the human
// snapshot ID, so the on-disk structure doesn't leak hostname/name/time.
blobInfo, err := os.Stat(filepath.Join(storeDir, "blobs")) blobInfo, err := os.Stat(filepath.Join(storeDir, "blobs"))
require.NoError(t, err) require.NoError(t, err)
require.True(t, blobInfo.IsDir()) require.True(t, blobInfo.IsDir())
metaInfo, err := os.Stat(filepath.Join(storeDir, "metadata", snapshotID)) metaInfo, err := os.Stat(filepath.Join(storeDir, "metadata", snapshot.RemoteSnapshotKey(snapshotID)))
require.NoError(t, err) require.NoError(t, err)
require.True(t, metaInfo.IsDir()) require.True(t, metaInfo.IsDir())

View File

@@ -123,20 +123,22 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
// collectReferencedBlobs downloads all manifests and returns the set of referenced blob hashes // collectReferencedBlobs downloads all manifests and returns the set of referenced blob hashes
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) { func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
log.Info("Listing remote snapshots") log.Info("Listing remote snapshots")
snapshotIDs, err := v.listUniqueSnapshotIDs() // IDs returned by listUniqueSnapshotIDs are remote keys (hashed
// subdirectories under metadata/), not human snapshot IDs.
remoteKeys, err := v.listUniqueSnapshotIDs()
if err != nil { if err != nil {
return nil, fmt.Errorf("listing snapshot IDs: %w", err) return nil, fmt.Errorf("listing snapshot keys: %w", err)
} }
log.Info("Found manifests in remote storage", "count", len(snapshotIDs)) log.Info("Found manifests in remote storage", "count", len(remoteKeys))
allBlobsReferenced := make(map[string]bool) allBlobsReferenced := make(map[string]bool)
manifestCount := 0 manifestCount := 0
for _, snapshotID := range snapshotIDs { for _, remoteKey := range remoteKeys {
log.Debug("Processing manifest", "snapshot_id", snapshotID) log.Debug("Processing manifest", "remote_key", remoteKey)
manifest, err := v.downloadManifest(snapshotID) manifest, err := v.downloadManifestByKey(remoteKey)
if err != nil { if err != nil {
log.Error("Failed to download manifest", "snapshot_id", snapshotID, "error", err) log.Error("Failed to download manifest", "remote_key", remoteKey, "error", err)
continue continue
} }
for _, blob := range manifest.Blobs { for _, blob := range manifest.Blobs {

View File

@@ -132,7 +132,9 @@ func (s *testStorer) Info() storage.StorageInfo {
} }
} }
// addManifest creates a compressed manifest in storage // addManifest creates a compressed manifest in storage at the same
// hashed path the production code uses. snapshotID is the human ID;
// the storage path uses RemoteSnapshotKey(id).
func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes []string) { func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes []string) {
t.Helper() t.Helper()
@@ -144,8 +146,9 @@ func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes
} }
} }
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
manifest := &snapshot.Manifest{ manifest := &snapshot.Manifest{
SnapshotID: snapshotID, SnapshotID: remoteKey,
BlobCount: len(blobs), BlobCount: len(blobs),
Blobs: blobs, Blobs: blobs,
} }
@@ -153,11 +156,19 @@ func addManifest(t *testing.T, store *testStorer, snapshotID string, blobHashes
data, err := snapshot.EncodeManifest(manifest, 3) data, err := snapshot.EncodeManifest(manifest, 3)
require.NoError(t, err) require.NoError(t, err)
key := "metadata/" + snapshotID + "/manifest.json.zst" key := "metadata/" + remoteKey + "/manifest.json.zst"
err = store.Put(context.Background(), key, bytes.NewReader(data)) err = store.Put(context.Background(), key, bytes.NewReader(data))
require.NoError(t, err) require.NoError(t, err)
} }
// remoteKeyPath returns the storage-relative path to a snapshot's
// metadata directory or manifest under the hashed remote-key scheme.
// Tests use this in hasKey/asserts to avoid scattering RemoteSnapshotKey
// calls throughout.
func remoteKeyPath(snapshotID, suffix string) string {
return "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/" + suffix
}
// addBlob adds a fake blob to storage // addBlob adds a fake blob to storage
func addBlob(t *testing.T, store *testStorer, hash string) { func addBlob(t *testing.T, store *testStorer, hash string) {
t.Helper() t.Helper()
@@ -198,7 +209,7 @@ func TestRemoveSnapshot_LocalOnly(t *testing.T) {
// Blobs should NOT be deleted (that's what prune is for) // Blobs should NOT be deleted (that's what prune is for)
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA)) assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
// Remote metadata should NOT be deleted (no --remote flag) // Remote metadata should NOT be deleted (no --remote flag)
assert.True(t, store.hasKey("metadata/snapshot-001/manifest.json.zst")) assert.True(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
// Verify output // Verify output
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database") assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
@@ -225,7 +236,7 @@ func TestRemoveSnapshot_WithRemote(t *testing.T) {
// Blobs should NOT be deleted // Blobs should NOT be deleted
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA)) assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
// Remote metadata SHOULD be deleted // Remote metadata SHOULD be deleted
assert.False(t, store.hasKey("metadata/snapshot-001/manifest.json.zst")) assert.False(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
// Verify output mentions prune // Verify output mentions prune
assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database") assert.Contains(t, tv.Stdout.String(), "Removed snapshot 'snapshot-001' from local database")
@@ -255,7 +266,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
// Nothing should be deleted // Nothing should be deleted
assert.Equal(t, initialCount, store.keyCount()) assert.Equal(t, initialCount, store.keyCount())
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA)) assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
assert.True(t, store.hasKey("metadata/snapshot-001/manifest.json.zst")) assert.True(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
// Verify dry run message // Verify dry run message
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]") assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
@@ -299,8 +310,8 @@ func TestRemoveAllSnapshots_WithForce(t *testing.T) {
// Blobs should NOT be deleted // Blobs should NOT be deleted
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA)) assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
// Remote metadata SHOULD be deleted // Remote metadata SHOULD be deleted
assert.False(t, store.hasKey("metadata/snapshot-001/manifest.json.zst")) assert.False(t, store.hasKey(remoteKeyPath("snapshot-001", "manifest.json.zst")))
assert.False(t, store.hasKey("metadata/snapshot-002/manifest.json.zst")) assert.False(t, store.hasKey(remoteKeyPath("snapshot-002", "manifest.json.zst")))
// Verify output // Verify output
assert.Contains(t, tv.Stdout.String(), "Removed 2 snapshot(s)") assert.Contains(t, tv.Stdout.String(), "Removed 2 snapshot(s)")
@@ -318,7 +329,10 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
tv := vaultik.NewForTesting(store) tv := vaultik.NewForTesting(store)
opts := &vaultik.RemoveOptions{All: true, Force: true, DryRun: true} // --remote is required to enumerate orphan remote keys; without
// it, RemoveAll only acts on local snapshots, and NewForTesting
// has no local DB.
opts := &vaultik.RemoveOptions{All: true, Force: true, DryRun: true, Remote: true}
result, err := tv.RemoveAllSnapshots(opts) result, err := tv.RemoveAllSnapshots(opts)
require.NoError(t, err) require.NoError(t, err)

View File

@@ -18,6 +18,7 @@ import (
"sneak.berlin/go/vaultik/internal/blobgen" "sneak.berlin/go/vaultik/internal/blobgen"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types" "sneak.berlin/go/vaultik/internal/types"
) )
@@ -394,10 +395,12 @@ func (v *Vaultik) handleRestoreVerification(
return nil return nil
} }
// downloadSnapshotDB downloads and decrypts the snapshot metadata database // downloadSnapshotDB downloads and decrypts the snapshot metadata
// database. The snapshotID is the human ID; we hash it to the remote
// key for the storage path.
func (v *Vaultik) downloadSnapshotDB(snapshotID string, identity age.Identity) (*database.DB, error) { func (v *Vaultik) downloadSnapshotDB(snapshotID string, identity age.Identity) (*database.DB, error) {
// Download encrypted database from storage // Download encrypted database from storage
dbKey := fmt.Sprintf("metadata/%s/db.zst.age", snapshotID) dbKey := fmt.Sprintf("metadata/%s/db.zst.age", snapshot.RemoteSnapshotKey(snapshotID))
reader, err := v.Storage.Get(v.ctx, dbKey) reader, err := v.Storage.Get(v.ctx, dbKey)
if err != nil { if err != nil {

View File

@@ -8,16 +8,13 @@ import (
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"sync"
"text/tabwriter" "text/tabwriter"
"time" "time"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"golang.org/x/sync/errgroup"
"sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/types"
) )
// SnapshotCreateOptions contains options for the snapshot create command // SnapshotCreateOptions contains options for the snapshot create command
@@ -383,25 +380,43 @@ func (v *Vaultik) getSnapshotBlobSizes(snapshotID string) (compressed int64, unc
return compressed, uncompressed return compressed, uncompressed
} }
// ListSnapshots lists all snapshots // 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 { func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
log.Info("Listing snapshots") log.Info("Listing snapshots")
remoteSnapshots, err := v.listRemoteSnapshotIDs()
localSnaps, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
if err != nil { if err != nil {
return err return fmt.Errorf("listing local snapshots: %w", err)
} }
localSnapshotMap, err := v.reconcileLocalWithRemote(remoteSnapshots) snapshots := make([]SnapshotInfo, 0, len(localSnaps))
if err != nil { for _, ls := range localSnaps {
return err if ls.CompletedAt == nil {
continue
}
snapshots = append(snapshots, v.snapshotInfoFromLocal(ls))
} }
snapshots, err := v.buildSnapshotInfoList(remoteSnapshots, localSnapshotMap)
if err != nil {
return err
}
// Sort by timestamp (newest first)
sort.Slice(snapshots, func(i, j int) bool { sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].Timestamp.After(snapshots[j].Timestamp) return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
}) })
@@ -416,173 +431,85 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
return err return err
} }
// Warn about local snapshots that don't exist in remote storage. if v.Config.AgeSecretKey == "" {
var stale []string return nil
for id := range localSnapshotMap { }
if !remoteSnapshots[id] {
stale = append(stale, id) remoteKeys, err := v.listAllRemoteSnapshotKeys()
if err != nil {
v.UI.Warning("Could not list backup destination store: %v.", err)
return nil
}
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)
} }
} }
if len(stale) > 0 { var remoteOnlyCount int
v.UI.Warning("%d local snapshot record(s) not found in backup destination store:", len(stale)) for key := range remoteSet {
for _, id := range stale { if _, ok := localKeys[key]; !ok {
remoteOnlyCount++
}
}
if len(localOnly) > 0 {
v.UI.Warning("%d local snapshot record(s) not found in backup destination store:", len(localOnly))
for _, id := range localOnly {
v.UI.Info("%s", v.UI.Snapshot(id)) v.UI.Info("%s", v.UI.Snapshot(id))
} }
v.UI.Info("Run 'vaultik snapshot cleanup' to remove stale local records.") v.UI.Info("Run 'vaultik snapshot cleanup' to remove stale local records.")
} }
if remoteOnlyCount > 0 {
v.UI.Notice("NOTE: %d remote snapshot(s) found in backup destination store but not in local database.", remoteOnlyCount)
}
return nil return nil
} }
// listRemoteSnapshotIDs returns a set of snapshot IDs found in remote storage // snapshotInfoFromLocal builds a SnapshotInfo row from a local snapshot
func (v *Vaultik) listRemoteSnapshotIDs() (map[string]bool, error) { // record. Failures from any per-snapshot stat query degrade that
remoteSnapshots := make(map[string]bool) // column to its snapshot-row fallback but never fail the listing.
objectCh := v.Storage.ListStream(v.ctx, "metadata/") func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
idStr := ls.ID.String()
for object := range objectCh { totalSize, err := v.Repositories.Snapshots.GetSnapshotTotalCompressedSize(v.ctx, idStr)
if object.Err != nil {
return nil, fmt.Errorf("listing remote snapshots: %w", object.Err)
}
parts := strings.Split(object.Key, "/")
if len(parts) >= 2 && parts[0] == "metadata" && parts[1] != "" {
if strings.HasPrefix(parts[1], ".") {
continue
}
remoteSnapshots[parts[1]] = true
}
}
return remoteSnapshots, nil
}
// reconcileLocalWithRemote builds a map of local snapshots keyed by ID for cross-referencing with remote
func (v *Vaultik) reconcileLocalWithRemote(remoteSnapshots map[string]bool) (map[string]*database.Snapshot, error) {
localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
if err != nil { if err != nil {
return nil, fmt.Errorf("listing local snapshots: %w", err) log.Warn("Failed to get total compressed size", "id", idStr, "error", err)
totalSize = ls.BlobSize
} }
localSnapshotMap := make(map[string]*database.Snapshot) uncompressedSize, err := v.Repositories.Snapshots.GetSnapshotUncompressedChunkSize(v.ctx, idStr)
for _, s := range localSnapshots { if err != nil {
localSnapshotMap[s.ID.String()] = s log.Warn("Failed to get uncompressed chunk size", "id", idStr, "error", err)
} }
return localSnapshotMap, nil newChunkSize, err := v.Repositories.Snapshots.GetSnapshotNewChunkSize(v.ctx, idStr)
} if err != nil {
log.Warn("Failed to get new chunk size", "id", idStr, "error", err)
// buildSnapshotInfoList constructs SnapshotInfo entries from remote IDs and local data
func (v *Vaultik) buildSnapshotInfoList(remoteSnapshots map[string]bool, localSnapshotMap map[string]*database.Snapshot) ([]SnapshotInfo, error) {
snapshots := make([]SnapshotInfo, 0, len(remoteSnapshots))
// remoteOnly collects snapshot IDs that need a manifest download.
var remoteOnly []string
for snapshotID := range remoteSnapshots {
if localSnap, exists := localSnapshotMap[snapshotID]; exists && localSnap.CompletedAt != nil {
totalSize, err := v.Repositories.Snapshots.GetSnapshotTotalCompressedSize(v.ctx, snapshotID)
if err != nil {
log.Warn("Failed to get total compressed size", "id", snapshotID, "error", err)
totalSize = localSnap.BlobSize
}
uncompressedSize, err := v.Repositories.Snapshots.GetSnapshotUncompressedChunkSize(v.ctx, snapshotID)
if err != nil {
log.Warn("Failed to get uncompressed chunk size", "id", snapshotID, "error", err)
}
newChunkSize, err := v.Repositories.Snapshots.GetSnapshotNewChunkSize(v.ctx, snapshotID)
if err != nil {
log.Warn("Failed to get new chunk size", "id", snapshotID, "error", err)
}
snapshots = append(snapshots, SnapshotInfo{
ID: localSnap.ID,
Timestamp: localSnap.StartedAt,
CompressedSize: totalSize,
UncompressedSize: uncompressedSize,
NewChunkSize: newChunkSize,
LocallyTracked: true,
})
} else {
timestamp, err := parseSnapshotTimestamp(snapshotID)
if err != nil {
log.Warn("Failed to parse snapshot timestamp", "id", snapshotID, "error", err)
continue
}
// Pre-add with zero size; will be filled by concurrent downloads.
snapshots = append(snapshots, SnapshotInfo{
ID: types.SnapshotID(snapshotID),
Timestamp: timestamp,
CompressedSize: 0,
LocallyTracked: false,
})
remoteOnly = append(remoteOnly, snapshotID)
}
} }
// Download manifests concurrently for remote-only snapshots. return SnapshotInfo{
if len(remoteOnly) > 0 { ID: ls.ID,
// maxConcurrentManifestDownloads bounds parallel manifest fetches to Timestamp: ls.StartedAt,
// avoid overwhelming the S3 endpoint while still being much faster CompressedSize: totalSize,
// than serial downloads. UncompressedSize: uncompressedSize,
const maxConcurrentManifestDownloads = 10 NewChunkSize: newChunkSize,
LocallyTracked: true,
type manifestResult struct {
snapshotID string
size int64
}
var (
mu sync.Mutex
results []manifestResult
)
g, gctx := errgroup.WithContext(v.ctx)
g.SetLimit(maxConcurrentManifestDownloads)
for _, sid := range remoteOnly {
g.Go(func() error {
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", sid)
reader, err := v.Storage.Get(gctx, manifestPath)
if err != nil {
return fmt.Errorf("downloading manifest for %s: %w", sid, err)
}
defer func() { _ = reader.Close() }()
manifest, err := snapshot.DecodeManifest(reader)
if err != nil {
return fmt.Errorf("decoding manifest for %s: %w", sid, err)
}
mu.Lock()
results = append(results, manifestResult{
snapshotID: sid,
size: manifest.TotalCompressedSize,
})
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return nil, fmt.Errorf("fetching manifest sizes: %w", err)
}
// Build a lookup from results and patch the pre-added entries.
sizeMap := make(map[string]int64, len(results))
for _, r := range results {
sizeMap[r.snapshotID] = r.size
}
for i := range snapshots {
if sz, ok := sizeMap[string(snapshots[i].ID)]; ok {
snapshots[i].CompressedSize = sz
}
}
} }
return snapshots, nil
} }
// printSnapshotTable renders the snapshot list as a formatted table // printSnapshotTable renders the snapshot list as a formatted table
@@ -768,7 +695,7 @@ func (v *Vaultik) confirmAndExecutePurge(toDelete []SnapshotInfo, force, quiet b
if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil { if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil {
log.Error("Failed to delete from local database", "snapshot_id", snapshotID, "error", err) log.Error("Failed to delete from local database", "snapshot_id", snapshotID, "error", err)
} }
if err := v.deleteSnapshotFromRemote(snapshotID); err != nil { if err := v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID)); err != nil {
return fmt.Errorf("deleting snapshot %s from remote: %w", snapshotID, err) return fmt.Errorf("deleting snapshot %s from remote: %w", snapshotID, err)
} }
} }
@@ -813,8 +740,9 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio
v.printVerifyHeader(snapshotID, opts) v.printVerifyHeader(snapshotID, opts)
// Download and parse manifest // Download and parse manifest. The caller supplies a human
manifest, err := v.downloadManifest(snapshotID) // snapshot ID; we hash it to address remote storage.
manifest, err := v.downloadManifestByKey(snapshot.RemoteSnapshotKey(snapshotID))
if err != nil { if err != nil {
if opts.JSON { if opts.JSON {
result.Status = "failed" result.Status = "failed"
@@ -929,12 +857,18 @@ func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
// CleanupLocalSnapshots removes local snapshot records that have no // CleanupLocalSnapshots removes local snapshot records that have no
// corresponding metadata in remote storage. These are typically left // corresponding metadata in remote storage. These are typically left
// behind by incomplete or interrupted backups. // behind by incomplete or interrupted backups. Each local snapshot's
// human ID is hashed via RemoteSnapshotKey and compared against the
// remote listing.
func (v *Vaultik) CleanupLocalSnapshots() error { func (v *Vaultik) CleanupLocalSnapshots() error {
remoteSnapshots, err := v.listRemoteSnapshotIDs() remoteKeys, err := v.listAllRemoteSnapshotKeys()
if err != nil { if err != nil {
return err return err
} }
remoteSet := make(map[string]bool, len(remoteKeys))
for _, k := range remoteKeys {
remoteSet[k] = true
}
localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000) localSnapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, 10000)
if err != nil { if err != nil {
@@ -944,7 +878,7 @@ func (v *Vaultik) CleanupLocalSnapshots() error {
var removed int var removed int
for _, snap := range localSnapshots { for _, snap := range localSnapshots {
id := snap.ID.String() id := snap.ID.String()
if !remoteSnapshots[id] { if !remoteSet[snapshot.RemoteSnapshotKey(id)] {
v.printfStdout("Removing stale local record: %s\n", id) v.printfStdout("Removing stale local record: %s\n", id)
if err := v.deleteSnapshotFromLocalDB(id); err != nil { if err := v.deleteSnapshotFromLocalDB(id); err != nil {
log.Error("Failed to delete local snapshot", "snapshot_id", id, "error", err) log.Error("Failed to delete local snapshot", "snapshot_id", id, "error", err)
@@ -964,8 +898,12 @@ func (v *Vaultik) CleanupLocalSnapshots() error {
// Helper methods that were previously on SnapshotApp // Helper methods that were previously on SnapshotApp
func (v *Vaultik) downloadManifest(snapshotID string) (*snapshot.Manifest, error) { // downloadManifestByKey fetches the manifest at
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID) // 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.
func (v *Vaultik) downloadManifestByKey(remoteKey string) (*snapshot.Manifest, error) {
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)
reader, err := v.Storage.Get(v.ctx, manifestPath) reader, err := v.Storage.Get(v.ctx, manifestPath)
if err != nil { if err != nil {
@@ -1100,7 +1038,7 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
// If --remote, also remove from remote storage // If --remote, also remove from remote storage
if opts.Remote { if opts.Remote {
log.Info("Removing snapshot metadata from remote storage", "snapshot_id", snapshotID) log.Info("Removing snapshot metadata from remote storage", "snapshot_id", snapshotID)
if err := v.deleteSnapshotFromRemote(snapshotID); err != nil { if err := v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID)); err != nil {
return result, fmt.Errorf("removing from remote storage: %w", err) return result, fmt.Errorf("removing from remote storage: %w", err)
} }
result.RemoteRemoved = true result.RemoteRemoved = true
@@ -1131,14 +1069,42 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
return result, nil return result, nil
} }
// RemoveAllSnapshots removes all snapshots from local database and optionally from remote // RemoveAllSnapshots removes every snapshot known to the local
// database from the local index, and (with --remote) every snapshot
// metadata directory in remote storage. Both sides are processed so a
// "remove --all" leaves nothing behind, even when the local DB and
// remote storage have diverged.
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) { func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
snapshotIDs, err := v.listAllRemoteSnapshotIDs() localSnaps, err := v.localSnapshotIDs()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("listing local snapshots: %w", err)
} }
if len(snapshotIDs) == 0 { // remoteKeys is the set of metadata/<key>/ subdirectories on the
// destination store; failures are downgraded to a warning so a
// permission-denied or unreachable remote can't block a local-only
// remove.
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
if remoteErr != nil {
log.Warn("Could not list remote snapshots", "error", remoteErr)
v.UI.Warning("Could not list remote snapshots: %v.", remoteErr)
}
// Anything visible on the remote that doesn't correspond to a
// known local human ID is treated as an orphan key — handled only
// when --remote is in effect.
knownLocalKeys := make(map[string]string, len(localSnaps))
for _, id := range localSnaps {
knownLocalKeys[snapshot.RemoteSnapshotKey(id)] = id
}
var orphanRemoteKeys []string
for _, key := range remoteKeys {
if _, known := knownLocalKeys[key]; !known {
orphanRemoteKeys = append(orphanRemoteKeys, key)
}
}
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
if !opts.JSON { if !opts.JSON {
v.printlnStdout("No snapshots found") v.printlnStdout("No snapshots found")
} }
@@ -1146,19 +1112,42 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
} }
if opts.DryRun { if opts.DryRun {
return v.handleRemoveAllDryRun(snapshotIDs, opts) return v.handleRemoveAllDryRun(localSnaps, orphanRemoteKeys, opts)
} }
return v.executeRemoveAll(snapshotIDs, opts) return v.executeRemoveAll(localSnaps, orphanRemoteKeys, opts)
} }
// listAllRemoteSnapshotIDs collects all unique snapshot IDs from remote storage // localSnapshotIDs returns every snapshot ID present in the local
func (v *Vaultik) listAllRemoteSnapshotIDs() ([]string, error) { // index database, sorted for deterministic iteration. Empty slice if
log.Info("Listing all snapshots") // the database has no Repositories (e.g. tests).
func (v *Vaultik) localSnapshotIDs() ([]string, error) {
if v.Repositories == nil {
return nil, nil
}
snaps, err := v.Repositories.Snapshots.ListRecent(v.ctx, 100000)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(snaps))
for _, s := range snaps {
ids = append(ids, s.ID.String())
}
sort.Strings(ids)
return ids, nil
}
// listAllRemoteSnapshotKeys collects the hashed remote keys
// (subdirectories under metadata/) currently present in the
// destination store. Returns (nil, err) when the store cannot be
// listed; callers must treat that as "no remote info available," not
// fatal.
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
log.Info("Listing all remote snapshots")
objectCh := v.Storage.ListStream(v.ctx, "metadata/") objectCh := v.Storage.ListStream(v.ctx, "metadata/")
seen := make(map[string]bool) seen := make(map[string]bool)
var snapshotIDs []string var keys []string
for object := range objectCh { for object := range objectCh {
if object.Err != nil { if object.Err != nil {
return nil, fmt.Errorf("listing remote snapshots: %w", object.Err) return nil, fmt.Errorf("listing remote snapshots: %w", object.Err)
@@ -1171,30 +1160,36 @@ func (v *Vaultik) listAllRemoteSnapshotIDs() ([]string, error) {
continue continue
} }
if strings.HasSuffix(object.Key, "/") || strings.Contains(object.Key, "/manifest.json.zst") { if strings.HasSuffix(object.Key, "/") || strings.Contains(object.Key, "/manifest.json.zst") {
sid := parts[1] key := parts[1]
if !seen[sid] { if !seen[key] {
seen[sid] = true seen[key] = true
snapshotIDs = append(snapshotIDs, sid) keys = append(keys, key)
} }
} }
} }
} }
return snapshotIDs, nil return keys, nil
} }
// handleRemoveAllDryRun handles the dry-run mode for removing all snapshots // handleRemoveAllDryRun handles the dry-run mode for removing all snapshots
func (v *Vaultik) handleRemoveAllDryRun(snapshotIDs []string, opts *RemoveOptions) (*RemoveResult, error) { func (v *Vaultik) handleRemoveAllDryRun(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
result := &RemoveResult{ result := &RemoveResult{DryRun: true}
DryRun: true, result.SnapshotsRemoved = append(result.SnapshotsRemoved, localSnaps...)
SnapshotsRemoved: snapshotIDs, if opts.Remote {
result.SnapshotsRemoved = append(result.SnapshotsRemoved, orphanRemoteKeys...)
} }
if !opts.JSON { if !opts.JSON {
v.printfStdout("Would remove %d snapshot(s):\n", len(snapshotIDs)) v.printfStdout("Would remove %d local snapshot(s):\n", len(localSnaps))
for _, id := range snapshotIDs { for _, id := range localSnaps {
v.printfStdout(" %s\n", id) v.printfStdout(" %s\n", id)
} }
if opts.Remote { if opts.Remote && len(orphanRemoteKeys) > 0 {
v.printfStdout("Would also remove %d orphan remote snapshot key(s):\n", len(orphanRemoteKeys))
for _, key := range orphanRemoteKeys {
v.printfStdout(" %s\n", key)
}
} else if opts.Remote {
v.printlnStdout("Would also remove from remote storage") v.printlnStdout("Would also remove from remote storage")
} }
v.printlnStdout("[Dry run - no changes made]") v.printlnStdout("[Dry run - no changes made]")
@@ -1205,17 +1200,19 @@ func (v *Vaultik) handleRemoveAllDryRun(snapshotIDs []string, opts *RemoveOption
return result, nil return result, nil
} }
// executeRemoveAll removes all snapshots from local database and optionally from remote storage // executeRemoveAll deletes every local snapshot (and, with --remote,
func (v *Vaultik) executeRemoveAll(snapshotIDs []string, opts *RemoveOptions) (*RemoveResult, error) { // every corresponding remote metadata directory plus any orphan remote
// keys that don't match a local snapshot).
func (v *Vaultik) executeRemoveAll(localSnaps, orphanRemoteKeys []string, opts *RemoveOptions) (*RemoveResult, error) {
// --all requires --force // --all requires --force
if !opts.Force { if !opts.Force {
return nil, fmt.Errorf("--all requires --force") return nil, fmt.Errorf("--all requires --force")
} }
log.Info("Removing all snapshots", "count", len(snapshotIDs)) log.Info("Removing all snapshots", "local_count", len(localSnaps), "orphan_remote_count", len(orphanRemoteKeys))
result := &RemoveResult{} result := &RemoveResult{}
for _, snapshotID := range snapshotIDs { for _, snapshotID := range localSnaps {
log.Info("Removing snapshot", "snapshot_id", snapshotID) log.Info("Removing snapshot", "snapshot_id", snapshotID)
if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil { if err := v.deleteSnapshotFromLocalDB(snapshotID); err != nil {
@@ -1224,7 +1221,7 @@ func (v *Vaultik) executeRemoveAll(snapshotIDs []string, opts *RemoveOptions) (*
} }
if opts.Remote { if opts.Remote {
if err := v.deleteSnapshotFromRemote(snapshotID); err != nil { if err := v.deleteRemoteSnapshotByKey(snapshot.RemoteSnapshotKey(snapshotID)); err != nil {
log.Error("Failed to remove from remote", "snapshot_id", snapshotID, "error", err) log.Error("Failed to remove from remote", "snapshot_id", snapshotID, "error", err)
continue continue
} }
@@ -1233,6 +1230,17 @@ func (v *Vaultik) executeRemoveAll(snapshotIDs []string, opts *RemoveOptions) (*
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID) result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
} }
if opts.Remote {
for _, key := range orphanRemoteKeys {
log.Info("Removing orphan remote snapshot", "remote_key", key)
if err := v.deleteRemoteSnapshotByKey(key); err != nil {
log.Error("Failed to remove orphan from remote", "remote_key", key, "error", err)
continue
}
result.SnapshotsRemoved = append(result.SnapshotsRemoved, key)
}
}
if opts.Remote { if opts.Remote {
result.RemoteRemoved = true result.RemoteRemoved = true
} }
@@ -1281,9 +1289,13 @@ func (v *Vaultik) deleteSnapshotFromLocalDB(snapshotID string) error {
return nil return nil
} }
// deleteSnapshotFromRemote removes snapshot metadata files from remote storage // deleteRemoteSnapshotByKey removes everything under
func (v *Vaultik) deleteSnapshotFromRemote(snapshotID string) error { // metadata/<remoteKey>/ on the destination store. The argument is a
prefix := fmt.Sprintf("metadata/%s/", snapshotID) // remote key (double-SHA256 derivation), not a human snapshot ID;
// callers that have a human ID must hash via snapshot.RemoteSnapshotKey
// first.
func (v *Vaultik) deleteRemoteSnapshotByKey(remoteKey string) error {
prefix := fmt.Sprintf("metadata/%s/", remoteKey)
objectCh := v.Storage.ListStream(v.ctx, prefix) objectCh := v.Storage.ListStream(v.ctx, prefix)
var objectsToDelete []string var objectsToDelete []string

View File

@@ -106,8 +106,11 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
// loadVerificationData downloads manifest, database, and blob list for verification // loadVerificationData downloads manifest, database, and blob list for verification
func (v *Vaultik) loadVerificationData(snapshotID string, opts *VerifyOptions, result *VerifyResult) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) { func (v *Vaultik) loadVerificationData(snapshotID string, opts *VerifyOptions, result *VerifyResult) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
// All remote paths use the hashed key derived from the human ID.
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
// Download manifest // Download manifest
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", snapshotID) manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)
log.Info("Downloading manifest", "path", manifestPath) log.Info("Downloading manifest", "path", manifestPath)
if !opts.JSON { if !opts.JSON {
v.printfStdout("Downloading manifest...\n") v.printfStdout("Downloading manifest...\n")
@@ -136,7 +139,7 @@ func (v *Vaultik) loadVerificationData(snapshotID string, opts *VerifyOptions, r
} }
// Download and decrypt database // Download and decrypt database
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", snapshotID) dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
log.Info("Downloading encrypted database", "path", dbPath) log.Info("Downloading encrypted database", "path", dbPath)
dbReader, err := v.Storage.Get(v.ctx, dbPath) dbReader, err := v.Storage.Get(v.ctx, dbPath)
if err != nil { if err != nil {