check / check (push) Successful in 13m39s
Restore cut chunk hashes from the snapshot database to 16 characters for its error messages, so a shorter hash panicked. Those messages now use shortHash. Under --verify, a file_chunks row with no chunks row was dereferenced, and the chunk size from the database was allocated in one piece, so a negative or huge size panicked. A missing row is now an error, a negative size is rejected, and each chunk is hashed by streaming it from the restored file. A restored file shorter than its chunks now fails verify as a short read instead of an unexpected EOF. Model: opus-5-5
222 lines
6.0 KiB
Go
222 lines
6.0 KiB
Go
package vaultik //nolint:testpackage // drives unexported restore and verify steps
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// These tests feed restore and --verify a snapshot database written by
|
|
// hand, as a damaged or hostile store could serve one. Each malformed row
|
|
// must end in an error, not a panic.
|
|
|
|
// shortChunkHash is shorter than the hash prefix that error messages print.
|
|
const shortChunkHash = "abc"
|
|
|
|
// restoredFileContent is the content of the restored file under verify.
|
|
const restoredFileContent = "xyz"
|
|
|
|
// craftedSnapshotDB opens an empty snapshot database in a temp directory.
|
|
func craftedSnapshotDB(t *testing.T) (*database.DB, *database.Repositories) {
|
|
t.Helper()
|
|
|
|
db, err := database.New(context.Background(),
|
|
filepath.Join(t.TempDir(), "snapshot.db"))
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db, database.NewRepositories(db)
|
|
}
|
|
|
|
// craftedFile adds a regular file whose only chunk has the given hash.
|
|
// Adding the chunks row, if any, is left to the caller.
|
|
func craftedFile(
|
|
t *testing.T, repos *database.Repositories, chunkHash string,
|
|
) *database.File {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
file := &database.File{
|
|
Path: "/src/f",
|
|
MTime: time.Now().UTC(),
|
|
Size: int64(len(restoredFileContent)),
|
|
Mode: 0o644,
|
|
}
|
|
require.NoError(t, repos.Files.Create(ctx, nil, file))
|
|
require.NoError(t, repos.FileChunks.Create(ctx, nil, &database.FileChunk{
|
|
FileID: file.ID,
|
|
ChunkHash: types.ChunkHash(chunkHash),
|
|
}))
|
|
|
|
return file
|
|
}
|
|
|
|
// TestRestoreShortChunkHashInNoBlob proves a file whose short chunk hash
|
|
// has no blob_chunks row fails restore planning and the chunk write with
|
|
// an error.
|
|
func TestRestoreShortChunkHashInNoBlob(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
_, repos := craftedSnapshotDB(t)
|
|
require.NoError(t, repos.Chunks.Create(ctx, nil,
|
|
&database.Chunk{ChunkHash: shortChunkHash, Size: 3}))
|
|
file := craftedFile(t, repos, shortChunkHash)
|
|
|
|
v := NewForTesting(nil)
|
|
|
|
chunkToBlobMap, err := v.buildChunkToBlobMap(ctx, repos)
|
|
require.NoError(t, err)
|
|
|
|
_, err = newRestorePlan(ctx, repos, []*database.File{file},
|
|
chunkToBlobMap, map[string]string{})
|
|
require.ErrorIs(t, err, errPlanChunkMissing)
|
|
|
|
fileChunks, err := repos.FileChunks.GetByFileID(ctx, file.ID)
|
|
require.NoError(t, err)
|
|
|
|
out, err := afero.NewMemMapFs().Create("out")
|
|
require.NoError(t, err)
|
|
|
|
session := &restoreSession{
|
|
v: v.Vaultik, ctx: ctx, chunkToBlobMap: chunkToBlobMap,
|
|
}
|
|
|
|
_, _, err = session.writeFileChunks(out, fileChunks)
|
|
require.ErrorIs(t, err, errChunkNotInAnyBlob)
|
|
}
|
|
|
|
// TestRestoreShortChunkHashReadPastBlobEnd proves a short chunk hash
|
|
// whose blob_chunks row reads past the end of its blob fails the chunk
|
|
// write with an error.
|
|
func TestRestoreShortChunkHashReadPastBlobEnd(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
_, repos := craftedSnapshotDB(t)
|
|
|
|
blobHash := strings.Repeat("b", blobHashHexLen)
|
|
blob := &database.Blob{
|
|
ID: types.NewBlobID(),
|
|
Hash: types.BlobHash(blobHash),
|
|
CreatedTS: time.Now().UTC(),
|
|
}
|
|
require.NoError(t, repos.Blobs.Create(ctx, nil, blob))
|
|
require.NoError(t, repos.Chunks.Create(ctx, nil,
|
|
&database.Chunk{ChunkHash: shortChunkHash, Size: 3}))
|
|
require.NoError(t, repos.BlobChunks.Create(ctx, nil, &database.BlobChunk{
|
|
BlobID: blob.ID,
|
|
ChunkHash: shortChunkHash,
|
|
Length: 100,
|
|
}))
|
|
file := craftedFile(t, repos, shortChunkHash)
|
|
|
|
cache, err := newBlobDiskCache(1 << 20)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = cache.Close() })
|
|
|
|
require.NoError(t, cache.Put(blobHash, []byte("abc")))
|
|
|
|
v := NewForTesting(nil)
|
|
|
|
chunkToBlobMap, err := v.buildChunkToBlobMap(ctx, repos)
|
|
require.NoError(t, err)
|
|
|
|
_, blobIDToHash, err := v.buildBlobIndexes(repos)
|
|
require.NoError(t, err)
|
|
|
|
fileChunks, err := repos.FileChunks.GetByFileID(ctx, file.ID)
|
|
require.NoError(t, err)
|
|
|
|
out, err := afero.NewMemMapFs().Create("out")
|
|
require.NoError(t, err)
|
|
|
|
session := &restoreSession{
|
|
v: v.Vaultik,
|
|
ctx: ctx,
|
|
chunkToBlobMap: chunkToBlobMap,
|
|
blobIDToHash: blobIDToHash,
|
|
blobCache: cache,
|
|
}
|
|
|
|
_, _, err = session.writeFileChunks(out, fileChunks)
|
|
require.ErrorIs(t, err, errCacheReadBeyondBlob)
|
|
}
|
|
|
|
// TestVerifyFileMalformedChunkRow proves --verify returns an error for a
|
|
// chunk with no chunks row, a short chunk hash, and a chunk size from the
|
|
// database that is negative or larger than the restored file.
|
|
func TestVerifyFileMalformedChunkRow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fullHash := types.ChunkHash(strings.Repeat("c", blobHashHexLen))
|
|
|
|
tests := []struct {
|
|
name string
|
|
hash types.ChunkHash
|
|
chunk *database.Chunk // nil adds no chunks row
|
|
want error
|
|
}{
|
|
{
|
|
name: "missing chunk row",
|
|
hash: fullHash,
|
|
want: errChunkRowMissing,
|
|
},
|
|
{
|
|
name: "short hash",
|
|
hash: shortChunkHash,
|
|
chunk: &database.Chunk{ChunkHash: shortChunkHash, Size: 3},
|
|
want: errChunkHashMismatch,
|
|
},
|
|
{
|
|
name: "size larger than the file",
|
|
hash: fullHash,
|
|
chunk: &database.Chunk{ChunkHash: fullHash, Size: math.MaxInt64},
|
|
want: errShortChunkRead,
|
|
},
|
|
{
|
|
name: "negative size",
|
|
hash: fullHash,
|
|
chunk: &database.Chunk{ChunkHash: fullHash, Size: -1},
|
|
want: errNegativeChunkLength,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
db, repos := craftedSnapshotDB(t)
|
|
|
|
if tt.chunk == nil {
|
|
// A crafted database need not satisfy its foreign keys.
|
|
_, err := db.Conn().ExecContext(ctx, "PRAGMA foreign_keys = OFF")
|
|
require.NoError(t, err)
|
|
} else {
|
|
require.NoError(t, repos.Chunks.Create(ctx, nil, tt.chunk))
|
|
}
|
|
|
|
file := craftedFile(t, repos, tt.hash.String())
|
|
|
|
v := NewForTesting(nil)
|
|
v.Fs = afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(v.Fs, "/restore/f",
|
|
[]byte(restoredFileContent), 0o600))
|
|
|
|
_, err := v.verifyFile(ctx, repos, file, "/restore/f")
|
|
require.ErrorIs(t, err, tt.want)
|
|
})
|
|
}
|
|
}
|