package vaultik_test import ( "bytes" "context" "crypto/rand" "io" "os" "path" "path/filepath" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/vaultik" ) // TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob backs up a real // snapshot with the on-disk storage backend, runs deep verification on // it, then flips a byte inside one stored blob and runs deep // verification again. A healthy snapshot must pass; a corrupted blob // must fail. The healthy case is the regression guard: deep // verification used to hash the encrypted blob bytes and compare them // to the blob's ID (the double SHA256 of the plaintext), so it reported // every healthy blob as corrupt. func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(64 * 1024) maxBlobSize := int64(512 * 1024) // One file large enough to span several chunks within a single blob. require.NoError(t, fs.MkdirAll(dataDir, 0o755)) require.NoError(t, afero.WriteFile(fs, filepath.Join(dataDir, "data.bin"), bytesPattern("deep-", int(chunkSize*3)), 0o644)) ctx := context.Background() // runFileStorageBackup writes a real snapshot to storeDir and closes // the source index, so verification runs from remote bytes only. cfg, storer, snapshotID := runFileStorageBackup( ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize) newVerifier := func() *vaultik.Vaultik { v := &vaultik.Vaultik{ Config: cfg, Storage: storer, Fs: fs, Stdout: io.Discard, Stderr: io.Discard, UI: ui.NewWithColor(io.Discard, false), } v.SetContext(ctx) return v } require.NoError(t, newVerifier().RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true}), "deep verify should pass on a healthy snapshot") // Flip a byte inside one blob without changing its length, so the // blob-existence and size checks still pass and verification reaches // the blob-content stage. corruptOneBlob(t, fs, filepath.Join(storeDir, "blobs")) require.Error(t, newVerifier().RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true}), "deep verify should fail on a corrupted blob") } // TestDeepVerifyJSONCountsBlobs checks the verified and missing counts // that deep verify reports under --json: on a healthy snapshot, with a // damaged blob, and with missing blobs. Deep verify stops at the first // damaged blob, so the blobs read before it are the ones verified; when // blobs are missing it reads no blob contents, but counts every missing // blob. func TestDeepVerifyJSONCountsBlobs(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() fs := afero.NewOsFs() ctx := context.Background() storeDir := t.TempDir() cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) healthy, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID) require.NoError(t, err) require.Equal(t, "ok", healthy.Status) require.Len(t, keys, healthy.BlobCount) require.Equal(t, healthy.BlobCount, healthy.Verified) require.Zero(t, healthy.Missing) // Damage the last blob read, keeping its length, so every blob before // it is read and passes first. lastPath := filepath.Join(storeDir, keys[len(keys)-1]) blob, err := afero.ReadFile(fs, lastPath) require.NoError(t, err) blob[len(blob)/2] ^= 0xff require.NoError(t, afero.WriteFile(fs, lastPath, blob, 0o600)) got, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID) require.Error(t, err) require.Equal(t, "failed", got.Status) require.Equal(t, healthy.BlobCount-1, got.Verified) require.Zero(t, got.Missing) // Delete the first blob and the damaged last one: both must be // counted as missing, not only the first one found. var deletedSize int64 for _, key := range []string{keys[0], keys[len(keys)-1]} { info, statErr := storer.Stat(ctx, key) require.NoError(t, statErr) deletedSize += info.Size require.NoError(t, storer.Delete(ctx, key)) } got, err = deepVerifyJSON(ctx, t, cfg, storer, snapshotID) require.Error(t, err) require.Equal(t, "failed", got.Status) require.Equal(t, 2, got.Missing) require.Equal(t, deletedSize, got.MissingSize) require.Zero(t, got.Verified) } // TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a // blob of the wrong size is counted in mismatched and fails the run, and // that the check goes on past it to count a missing blob after it. The // store is on disk, so the message must not call it S3. func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() fs := afero.NewOsFs() ctx := context.Background() storeDir := t.TempDir() cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) // Make the first blob checked longer, and delete the last one. firstPath := filepath.Join(storeDir, keys[0]) blob, err := afero.ReadFile(fs, firstPath) require.NoError(t, err) blob = append(blob, []byte("extra")...) require.NoError(t, afero.WriteFile(fs, firstPath, blob, 0o600)) require.NoError(t, storer.Delete(ctx, keys[len(keys)-1])) got, err := deepVerifyJSON(ctx, t, cfg, storer, snapshotID) require.Error(t, err) require.Equal(t, "failed", got.Status) require.Equal(t, 1, got.Mismatched) require.Equal(t, 1, got.Missing) require.Zero(t, got.Verified) require.Contains(t, got.ErrorMessage, "with the wrong size") require.NotContains(t, got.ErrorMessage, "S3") } // TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is // present in storage but cannot be checked is not counted as missing: // deep verify stops and fails, saying it could not check the blob, with // the storage error. func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() storeDir := t.TempDir() cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}} got, err := deepVerifyJSON(ctx, t, cfg, failing, snapshotID) require.Error(t, err) require.Equal(t, "failed", got.Status) require.Zero(t, got.Missing) require.Contains(t, got.ErrorMessage, "could not check blob "+path.Base(keys[0])) require.Contains(t, got.ErrorMessage, errStatDenied.Error()) } // backUpSeveralBlobs backs up random data, which does not compress or // deduplicate, so it fills at least three blobs in a store on disk at // storeDir. It returns the blobs' storage keys sorted, which is hash // order, the order deep verify reads them in. func backUpSeveralBlobs( ctx context.Context, t *testing.T, storeDir string, ) (*config.Config, *storage.FileStorer, string, []string) { t.Helper() fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(32 * 1024) maxBlobSize := int64(128 * 1024) data := make([]byte, maxBlobSize*4) _, err := rand.Read(data) require.NoError(t, err) require.NoError(t, fs.MkdirAll(dataDir, 0o755)) require.NoError(t, afero.WriteFile(fs, filepath.Join(dataDir, "data.bin"), data, 0o644)) cfg, storer, snapshotID := runFileStorageBackup( ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize) keys := storedBlobKeys(t, storeDir) require.GreaterOrEqual(t, len(keys), 3) return cfg, storer, snapshotID, keys } // deepVerifyJSON runs deep verify with --json on snapshotID and returns // the decoded result and the error RunDeepVerify returned. func deepVerifyJSON( ctx context.Context, t *testing.T, cfg *config.Config, storer storage.Storer, snapshotID string, ) (vaultik.VerifyResult, error) { t.Helper() var out bytes.Buffer v := &vaultik.Vaultik{ Config: cfg, Storage: storer, Fs: afero.NewOsFs(), Stdout: &out, Stderr: io.Discard, UI: ui.NewWithColor(io.Discard, false), } v.SetContext(ctx) err := v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true, JSON: true}) return decodeVerifyResult(t, out.Bytes()), err } // corruptOneBlob flips a middle byte of the first blob file found under // blobsDir, leaving the file length unchanged. func corruptOneBlob(t *testing.T, fs afero.Fs, blobsDir string) { t.Helper() var blobPath string err := afero.Walk(fs, blobsDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if blobPath == "" && !info.IsDir() { blobPath = path } return nil }) require.NoError(t, err) require.NotEmpty(t, blobPath, "expected at least one blob on disk") data, err := afero.ReadFile(fs, blobPath) require.NoError(t, err) require.NotEmpty(t, data) data[len(data)/2] ^= 0xff require.NoError(t, afero.WriteFile(fs, blobPath, data, 0o644)) }