check / check (push) Waiting to run
Every backup 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 act as for a destination that cannot be listed. remote nuke deletes it 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 a mount point. Tests back up into directories that exist; fixtures standing for a written destination carry the marker; two tests check they get past it. Judgement call: the marker is rewritten on every backup. Deviation: for the complexity limit, two blocks moved, unchanged, into snapshotNamesToCreate and confirmRemoveSnapshot. Model: opus-5-5
86 lines
2.6 KiB
Go
86 lines
2.6 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// TestRestoreRejectsTruncatedMetadataDB backs up a real tree, then replaces
|
|
// the snapshot's db.zst.age with a stream cut right after the age header and
|
|
// its 16-byte nonce. age.Decrypt still accepts such an object and the zstd
|
|
// decoder turns the truncated read into a clean EOF, so before the fix restore
|
|
// built a fresh empty schema and reported success. Restore must now fail with
|
|
// io.ErrUnexpectedEOF, the error the reader raises for a truncated object.
|
|
// Asserting that specific error pins the reader fix: without it the truncation
|
|
// yields an empty database, which the identity check rejects for an unrelated
|
|
// reason, and this test would pass anyway.
|
|
func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
fs := afero.NewOsFs()
|
|
tempDir := t.TempDir()
|
|
|
|
dataDir := filepath.Join(tempDir, "source")
|
|
storeDir := t.TempDir()
|
|
restoreDir := filepath.Join(tempDir, "restored")
|
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
|
|
chunkSize := int64(64 * 1024)
|
|
maxBlobSize := int64(512 * 1024)
|
|
|
|
setupE2ESourceTree(t, fs, dataDir, chunkSize)
|
|
|
|
ctx := context.Background()
|
|
|
|
cfg, storer, snapshotID := runFileStorageBackup(
|
|
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
|
|
|
// Encrypting empty plaintext to the snapshot recipient yields
|
|
// header + nonce(16) + a single 16-byte final chunk tag. Dropping the
|
|
// trailing tag leaves exactly the age header plus its nonce — the
|
|
// truncation an attacker can write over metadata without any key.
|
|
recipient, err := age.ParseX25519Recipient(testAgePublicKey)
|
|
require.NoError(t, err)
|
|
|
|
var full bytes.Buffer
|
|
|
|
w, err := age.Encrypt(&full, recipient)
|
|
require.NoError(t, err)
|
|
require.NoError(t, w.Close())
|
|
|
|
truncated := full.Bytes()[:full.Len()-16]
|
|
|
|
dbKeyPath := filepath.Join(storeDir, "metadata",
|
|
snapshot.RemoteSnapshotKey(snapshotID), "db.zst.age")
|
|
require.NoError(t, afero.WriteFile(fs, dbKeyPath, truncated, 0o644))
|
|
|
|
restoreVaultik := &vaultik.Vaultik{
|
|
Config: cfg,
|
|
Storage: storer,
|
|
Fs: fs,
|
|
Stdout: io.Discard,
|
|
Stderr: io.Discard,
|
|
UI: ui.NewWithColor(io.Discard, false),
|
|
}
|
|
restoreVaultik.SetContext(ctx)
|
|
|
|
err = restoreVaultik.Restore(&vaultik.RestoreOptions{
|
|
SnapshotID: snapshotID,
|
|
TargetDir: restoreDir,
|
|
Verify: true,
|
|
})
|
|
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
|
}
|