Files
vaultik/internal/vaultik/restore_snapshot_identity_test.go
T
sneak ca91e218b4
check / check (push) Waiting to run
Refuse a backup destination an unplugged volume left empty (closes #239)
Every backup now writes vaultik-destination.txt at the destination
root. While the local index holds snapshots, a destination without it
is not present: snapshot create refuses before writing, prune and
snapshot purge fail, and snapshot list and snapshot remove warn.
remote nuke deletes the marker last. The file:// backend no longer
creates its destination directory and checks for it on every write;
that protects a directory on a removable volume, not the mount point
itself.

Tests back up into existing directories; fixtures standing for a
written destination carry the marker.

Judgement call: the marker is rewritten on every backup.
Deviation: for the complexity limit, snapshot name selection left CreateSnapshot and the --force/--json test moved into confirmRemoveSnapshot, both unchanged.

Model: opus-5-5
2026-10-13 18:06:51 +00:00

252 lines
8.2 KiB
Go

package vaultik_test
import (
"context"
"io"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestRestoreAndDeepVerifyRejectSwappedDatabase proves that swapping two
// snapshots' encrypted databases on the store is caught. age decryption
// alone proves only that a database is readable; without an identity check
// restore would happily write the wrong snapshot's files and deep verify
// would report success. After the swap, restore and deep verify of A both
// fail, and the error names the snapshot the database actually holds (B).
func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
storeDir := t.TempDir()
chunkSize := int64(64 * 1024)
storer, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
ctx := context.Background()
// Two snapshots with different content, backed up into one shared
// store. Different names give them different remote keys, so their
// metadata directories are distinct and can be tampered with alone.
dataA := filepath.Join(tempDir, "srcA")
require.NoError(t, fs.MkdirAll(dataA, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataA, "a.bin"),
bytesPattern("alpha-", int(chunkSize*2)), 0o644))
dataB := filepath.Join(tempDir, "srcB")
require.NoError(t, fs.MkdirAll(dataB, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataB, "b.bin"),
bytesPattern("beta-", int(chunkSize*2)), 0o644))
idA := backupNamedSnapshotToStore(ctx, t, fs, dataA, storer,
filepath.Join(tempDir, "idxA.sqlite"), "alpha")
idB := backupNamedSnapshotToStore(ctx, t, fs, dataB, storer,
filepath.Join(tempDir, "idxB.sqlite"), "beta")
require.NotEqual(t, idA, idB)
keyA := snapshot.RemoteSnapshotKey(idA)
keyB := snapshot.RemoteSnapshotKey(idB)
require.NotEqual(t, keyA, keyB)
// Baseline: each snapshot verifies against its own intact metadata.
require.NoError(t, newStoreClient(ctx, t, fs, storer).RunDeepVerify(
idA, &vaultik.VerifyOptions{Deep: true}))
require.NoError(t, newStoreClient(ctx, t, fs, storer).RunDeepVerify(
idB, &vaultik.VerifyOptions{Deep: true}))
// Swap the two snapshots' encrypted databases on the store.
swapStoreFiles(t, fs,
filepath.Join(storeDir, "metadata", keyA, "db.zst.age"),
filepath.Join(storeDir, "metadata", keyB, "db.zst.age"))
// Restore of A now decrypts B's database; the identity check must
// reject it and name the snapshot it actually found.
restoreErr := newStoreClient(ctx, t, fs, storer).Restore(&vaultik.RestoreOptions{
SnapshotID: idA,
TargetDir: filepath.Join(tempDir, "restoreA"),
})
require.Error(t, restoreErr)
require.ErrorContains(t, restoreErr, idB)
// Deep verify of A must reject the swapped database for the same reason.
verifyErr := newStoreClient(ctx, t, fs, storer).RunDeepVerify(
idA, &vaultik.VerifyOptions{Deep: true})
require.Error(t, verifyErr)
require.ErrorContains(t, verifyErr, idB)
}
// TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest covers the case the
// issue calls out: swapping in a database whose blob set is empty and
// pairing it with an equally empty manifest. The manifest then agrees with
// the database, so every blob-level check passes and deep verify used to
// report success with zero blobs verified. The identity check rejects it
// before any blob check runs.
func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
storeDir := t.TempDir()
chunkSize := int64(64 * 1024)
storer, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
ctx := context.Background()
// Snapshot A: real content, so its manifest lists blobs.
dataA := filepath.Join(tempDir, "srcA")
require.NoError(t, fs.MkdirAll(dataA, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataA, "a.bin"),
bytesPattern("alpha-", int(chunkSize*2)), 0o644))
idA := backupNamedSnapshotToStore(ctx, t, fs, dataA, storer,
filepath.Join(tempDir, "idxA.sqlite"), "alpha")
// Snapshot C: a single empty file, so it references no blobs and its
// manifest is empty. This is the database/manifest pair an attacker
// would swap in to make the blob checks vacuously pass.
dataC := filepath.Join(tempDir, "srcC")
require.NoError(t, fs.MkdirAll(dataC, 0o755))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(dataC, "empty.bin"), []byte{}, 0o644))
idC := backupNamedSnapshotToStore(ctx, t, fs, dataC, storer,
filepath.Join(tempDir, "idxC.sqlite"), "charlie")
keyA := snapshot.RemoteSnapshotKey(idA)
keyC := snapshot.RemoteSnapshotKey(idC)
// Replace A's database and manifest with C's empty pair.
copyStoreFile(t, fs,
filepath.Join(storeDir, "metadata", keyC, "db.zst.age"),
filepath.Join(storeDir, "metadata", keyA, "db.zst.age"))
copyStoreFile(t, fs,
filepath.Join(storeDir, "metadata", keyC, "manifest.json.zst"),
filepath.Join(storeDir, "metadata", keyA, "manifest.json.zst"))
verifyErr := newStoreClient(ctx, t, fs, storer).RunDeepVerify(
idA, &vaultik.VerifyOptions{Deep: true})
require.Error(t, verifyErr)
require.ErrorContains(t, verifyErr, idC)
}
// backupNamedSnapshotToStore backs up dataDir into the shared storer under
// the given snapshot name and returns the human snapshot ID. Two snapshots
// backed up under different names get different remote keys, so their
// metadata directories on the store are distinct.
func backupNamedSnapshotToStore(
ctx context.Context, t *testing.T, fs afero.Fs,
dataDir string, storer storage.Storer, dbPath, name string,
) string {
t.Helper()
const (
chunkSize = int64(64 * 1024)
maxBlobSize = int64(512 * 1024)
)
cfg := &config.Config{
AgeRecipients: []string{testAgePublicKey},
AgeSecretKey: testAgeSecretKey,
CompressionLevel: 3,
Hostname: testHostname,
}
db, err := database.New(ctx, dbPath)
require.NoError(t, err)
repos := database.NewRepositories(db)
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
Repos: repos,
Storage: storer,
Config: cfg,
})
sm.SetFilesystem(fs)
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
FS: fs,
Storage: storer,
ChunkSize: chunkSize,
MaxBlobSize: maxBlobSize,
CompressionLevel: cfg.CompressionLevel,
AgeRecipients: cfg.AgeRecipients,
Repositories: repos,
})
snapshotID, err := sm.CreateSnapshotWithName(
ctx, cfg.Hostname, name, "test-version", "test-git")
require.NoError(t, err)
_, err = scanner.Scan(ctx, dataDir, snapshotID)
require.NoError(t, err)
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
require.NoError(t, db.Close())
return snapshotID
}
// newStoreClient builds a Vaultik that reads only from the store: the
// secret key, the storer, and a filesystem, with no local index. This is
// what restore and deep verify need.
func newStoreClient(
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
) *vaultik.Vaultik {
t.Helper()
v := &vaultik.Vaultik{
Config: &config.Config{
AgeSecretKey: testAgeSecretKey,
Hostname: testHostname,
},
Storage: storer,
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
return v
}
// swapStoreFiles exchanges the contents of two files on the store.
func swapStoreFiles(t *testing.T, fs afero.Fs, a, b string) {
t.Helper()
dataA, err := afero.ReadFile(fs, a)
require.NoError(t, err)
dataB, err := afero.ReadFile(fs, b)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, a, dataB, 0o644))
require.NoError(t, afero.WriteFile(fs, b, dataA, 0o644))
}
// copyStoreFile overwrites dst with the contents of src on the store.
func copyStoreFile(t *testing.T, fs afero.Fs, src, dst string) {
t.Helper()
data, err := afero.ReadFile(fs, src)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, dst, data, 0o644))
}