check / check (push) Waiting to run
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
252 lines
8.2 KiB
Go
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))
|
|
}
|