Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0642d2763 | ||
|
|
5d8c515192 | ||
|
|
42eb50b151 |
@@ -578,3 +578,54 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
||||
require.Equal(t, 1, result.FilesScanned,
|
||||
"**/ file pattern should match at the top level and nested")
|
||||
}
|
||||
|
||||
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As in gitignore, /**/ also matches directly under the source dir
|
||||
fs := setupAnchoredTestFS(t)
|
||||
|
||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||
|
||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||
require.NoError(t, err)
|
||||
|
||||
// /**/projectname should exclude BOTH:
|
||||
// - /backup/projectname/file.txt
|
||||
// - /backup/otherproject/projectname/file.txt
|
||||
// Total files: 4, excluded: 2
|
||||
require.Equal(t, 2, result.FilesScanned,
|
||||
"/**/ pattern should match at the top level and nested")
|
||||
}
|
||||
|
||||
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
|
||||
// is a glob: with a plain name the glob library already matched a/b.
|
||||
fs := &afero.MemMapFs{}
|
||||
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
|
||||
|
||||
for _, path := range files {
|
||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
|
||||
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
|
||||
}
|
||||
|
||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||
|
||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||
require.NoError(t, err)
|
||||
|
||||
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
|
||||
// Total files: 3, excluded: 2
|
||||
require.Equal(t, 1, result.FilesScanned,
|
||||
"a/**/*.log should match a/x.log as well as a/c/y.log")
|
||||
}
|
||||
|
||||
@@ -2035,33 +2035,60 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
||||
pattern = strings.TrimSuffix(pattern, "/")
|
||||
|
||||
// A leading "**/" demands a "/" before the name and so misses an
|
||||
// entry directly under the source path. An unanchored pattern
|
||||
// already matches at every depth, so drop the prefix: as in
|
||||
// gitignore, "**/x" then also matches a top-level "x".
|
||||
if !anchored {
|
||||
pattern = strings.TrimPrefix(pattern, "**/")
|
||||
// entry directly under the source path. It means "at every depth",
|
||||
// which is what an unanchored pattern already does, so drop the
|
||||
// prefix and match unanchored: as in gitignore, "**/x" and
|
||||
// "/**/x" then also match a top-level "x".
|
||||
if rest, found := strings.CutPrefix(pattern, "**/"); found {
|
||||
pattern = rest
|
||||
anchored = false
|
||||
}
|
||||
|
||||
// Compile the glob pattern
|
||||
// For patterns without path separators, we need to match them as components
|
||||
// e.g., ".git" should match ".git" anywhere in the path
|
||||
g, err := glob.Compile(pattern, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||
// Likewise "/**/" inside a pattern demands two slashes, so
|
||||
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
||||
// zero or more directories, so also compile the pattern with it
|
||||
// made a single "/". The glob library's {a,b} is no help: with a
|
||||
// "**" inside it in mid-pattern, it misses paths it should match.
|
||||
for _, variant := range doubleStarVariants(pattern) {
|
||||
// For patterns without path separators, we need to match them as components
|
||||
// e.g., ".git" should match ".git" anywhere in the path
|
||||
g, err := glob.Compile(variant, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||
|
||||
continue
|
||||
break
|
||||
}
|
||||
|
||||
compiled = append(compiled, compiledPattern{
|
||||
pattern: g,
|
||||
anchored: anchored,
|
||||
original: p,
|
||||
})
|
||||
}
|
||||
|
||||
compiled = append(compiled, compiledPattern{
|
||||
pattern: g,
|
||||
anchored: anchored,
|
||||
original: p,
|
||||
})
|
||||
}
|
||||
|
||||
return compiled
|
||||
}
|
||||
|
||||
// doubleStarVariants returns pattern once for every way of keeping each
|
||||
// "/**/" in it or making it a single "/".
|
||||
func doubleStarVariants(pattern string) []string {
|
||||
const doubleStar = "/**/"
|
||||
|
||||
before, after, found := strings.Cut(pattern, doubleStar)
|
||||
if !found {
|
||||
return []string{pattern}
|
||||
}
|
||||
|
||||
var variants []string
|
||||
|
||||
for _, rest := range doubleStarVariants(after) {
|
||||
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||
// filePath is the full path to the file
|
||||
// rootPath is the root of the backup source directory
|
||||
|
||||
+63
-13
@@ -120,9 +120,10 @@ func (f *FileStorer) Delete(_ context.Context, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all keys with the given prefix. It fails when the
|
||||
// destination directory is missing; a missing prefix under it is an
|
||||
// empty listing.
|
||||
// List returns all keys with the given prefix, reading through symlinked
|
||||
// directories. It fails when the destination directory is missing, or
|
||||
// when the prefix or a directory above it is a symlink whose target is
|
||||
// missing; a prefix that is truly missing is an empty listing.
|
||||
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
var keys []string
|
||||
|
||||
@@ -133,8 +134,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
||||
|
||||
basePath := f.fullPath(prefix)
|
||||
|
||||
// Check if the prefix exists
|
||||
exists, err := afero.Exists(f.fs, basePath)
|
||||
exists, err := f.prefixExists(prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checking path: %w", err)
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
||||
return keys, nil // Empty list for non-existent prefix
|
||||
}
|
||||
|
||||
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
||||
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,7 +176,9 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
||||
}
|
||||
|
||||
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
||||
// List, it sends an error when the destination directory is missing.
|
||||
// List, it reads through symlinked directories and sends an error when
|
||||
// the destination directory is missing, or when the prefix or a directory
|
||||
// above it is a symlink whose target is missing.
|
||||
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
||||
ch := make(chan ObjectInfo)
|
||||
|
||||
@@ -192,8 +194,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
||||
|
||||
basePath := f.fullPath(prefix)
|
||||
|
||||
// Check if the prefix exists
|
||||
exists, err := afero.Exists(f.fs, basePath)
|
||||
exists, err := f.prefixExists(prefix)
|
||||
if err != nil {
|
||||
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
||||
|
||||
@@ -204,7 +205,7 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
||||
return // Empty channel for non-existent prefix
|
||||
}
|
||||
|
||||
_ = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
||||
_ = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||
// Check context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -243,11 +244,12 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
||||
}
|
||||
|
||||
// DeletePartialUploads removes every file under prefix whose name ends in
|
||||
// tempSuffix. A missing prefix has none to remove.
|
||||
// tempSuffix. Like List, it reads through symlinked directories and fails
|
||||
// on one whose target is missing. A missing prefix has none to remove.
|
||||
func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) error {
|
||||
basePath := f.fullPath(prefix)
|
||||
|
||||
exists, err := afero.Exists(f.fs, basePath)
|
||||
exists, err := f.prefixExists(prefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking path: %w", err)
|
||||
}
|
||||
@@ -256,7 +258,7 @@ func (f *FileStorer) DeletePartialUploads(ctx context.Context, prefix string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
err = afero.Walk(f.fs, basePath, func(path string, info os.FileInfo, err error) error {
|
||||
err = f.walk(basePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -352,6 +354,54 @@ func (f *FileStorer) writeAtomic(
|
||||
return nil
|
||||
}
|
||||
|
||||
// walk is afero.Walk, except that it reads through a symlinked directory
|
||||
// the way Get and Stat do, where afero.Walk reports the link as one file.
|
||||
// afero.Walk uses Lstat, which does not follow a link, whenever the
|
||||
// filesystem has one; the struct below carries only the afero.Fs methods,
|
||||
// so the walk uses Stat instead. A link back up the tree is followed until
|
||||
// the operating system refuses the path for too many levels of links, and
|
||||
// the walk then fails.
|
||||
func (f *FileStorer) walk(root string, fn filepath.WalkFunc) error {
|
||||
return afero.Walk(struct{ afero.Fs }{f.fs}, root, fn)
|
||||
}
|
||||
|
||||
// prefixExists reports whether prefix exists under the destination
|
||||
// directory. It checks each directory on the way down to prefix in turn,
|
||||
// and the first one Stat cannot find decides: if it is not there at all,
|
||||
// prefix is missing; if it is a symlink whose target is missing, such as
|
||||
// a link to a volume that is not mounted, prefixExists fails, since
|
||||
// listing it as empty would let prune delete every blob.
|
||||
func (f *FileStorer) prefixExists(prefix string) (bool, error) {
|
||||
lstater, canLstat := f.fs.(afero.Lstater)
|
||||
path := f.basePath
|
||||
|
||||
for name := range strings.SplitSeq(prefix, "/") {
|
||||
path = filepath.Join(path, name)
|
||||
|
||||
_, err := f.fs.Stat(path)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Lstat does not follow a symlink at path, so it finds the link
|
||||
// that Stat could not follow.
|
||||
if canLstat {
|
||||
_, _, lstatErr := lstater.LstatIfPossible(path)
|
||||
if lstatErr == nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// fullPath returns the full filesystem path for a key.
|
||||
func (f *FileStorer) fullPath(key string) string {
|
||||
return filepath.Join(f.basePath, key)
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
@@ -60,3 +63,232 @@ func TestFileStorerListMissingDestination(t *testing.T) {
|
||||
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
||||
}
|
||||
}
|
||||
|
||||
// The two directories at the top of a destination, either of which the
|
||||
// tests below replace with a symlink.
|
||||
const (
|
||||
metadataDir = "metadata"
|
||||
blobsDir = "blobs"
|
||||
)
|
||||
|
||||
// moveBehindSymlink moves base/dir to a directory elsewhere and puts a
|
||||
// symlink to it in its place.
|
||||
func moveBehindSymlink(t *testing.T, base, dir string) {
|
||||
t.Helper()
|
||||
|
||||
linkPath := filepath.Join(base, dir)
|
||||
movedPath := filepath.Join(t.TempDir(), dir)
|
||||
|
||||
err := os.Rename(linkPath, movedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("moving %s away: %v", dir, err)
|
||||
}
|
||||
|
||||
err = os.Symlink(movedPath, linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("linking %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileStorerListsThroughSymlinkedDirectory checks that List and
|
||||
// ListStream read through a symlinked metadata/ or blobs/ directory, as Get
|
||||
// and Stat do, instead of listing the link as one key. Listing the whole
|
||||
// destination meets the link part-way through the walk; listing its prefix
|
||||
// starts the walk at the link. A prefix missing behind the link lists as
|
||||
// empty.
|
||||
func TestFileStorerListsThroughSymlinkedDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, dir := range []string{metadataDir, blobsDir} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := t.TempDir()
|
||||
key := dir + "/aa/bb/object"
|
||||
|
||||
s, err := storage.NewFileStorer(base)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStorer: %v", err)
|
||||
}
|
||||
|
||||
err = s.Put(context.Background(), key, strings.NewReader("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
moveBehindSymlink(t, base, dir)
|
||||
|
||||
want := []string{key}
|
||||
|
||||
for _, prefix := range []string{"", dir + "/"} {
|
||||
got := listSorted(t, s, prefix)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("List(%q) = %v, want %v", prefix, got, want)
|
||||
}
|
||||
|
||||
got = listStreamKeys(t, s, prefix)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ListStream(%q) = %v, want %v", prefix, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
absent := dir + "/absent/"
|
||||
|
||||
if got := listSorted(t, s, absent); len(got) != 0 {
|
||||
t.Errorf("List(%q) = %v, want empty", absent, got)
|
||||
}
|
||||
|
||||
if got := listStreamKeys(t, s, absent); len(got) != 0 {
|
||||
t.Errorf("ListStream(%q) = %v, want empty", absent, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileStorerListFailsOnSymlinkWithMissingTarget checks that List and
|
||||
// ListStream fail on a symlinked metadata/ or blobs/ directory whose target
|
||||
// is missing, such as a link to a volume that is not mounted, as they do on
|
||||
// a missing destination directory, instead of listing it as empty. That
|
||||
// holds for a prefix below the link too.
|
||||
func TestFileStorerListFailsOnSymlinkWithMissingTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for _, dir := range []string{metadataDir, blobsDir} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := t.TempDir()
|
||||
|
||||
s, err := storage.NewFileStorer(base)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStorer: %v", err)
|
||||
}
|
||||
|
||||
unmounted := filepath.Join(t.TempDir(), "unmounted")
|
||||
|
||||
err = os.Symlink(unmounted, filepath.Join(base, dir))
|
||||
if err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
for _, prefix := range []string{"", dir + "/", dir + "/aa/"} {
|
||||
keys, err := s.List(ctx, prefix)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("List(%q) = %v, %v; want a not-exist error",
|
||||
prefix, keys, err)
|
||||
}
|
||||
|
||||
var streamErr error
|
||||
|
||||
for object := range s.ListStream(ctx, prefix) {
|
||||
if object.Err != nil {
|
||||
streamErr = object.Err
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.Is(streamErr, fs.ErrNotExist) {
|
||||
t.Errorf("ListStream(%q) error = %v, want a not-exist error",
|
||||
prefix, streamErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileStorerListFailsOnSymlinkLoop checks that a symlink pointing back
|
||||
// at the destination makes List and ListStream fail instead of walking it
|
||||
// forever.
|
||||
func TestFileStorerListFailsOnSymlinkLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
base := t.TempDir()
|
||||
|
||||
s, err := storage.NewFileStorer(base)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStorer: %v", err)
|
||||
}
|
||||
|
||||
err = s.Put(ctx, testBlobKey, strings.NewReader("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
err = os.Symlink(base, filepath.Join(base, "blobs", "loop"))
|
||||
if err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
keys, err := s.List(ctx, "blobs/")
|
||||
if err == nil {
|
||||
t.Errorf("List = %d keys and no error, want an error", len(keys))
|
||||
}
|
||||
|
||||
var streamErr error
|
||||
|
||||
for object := range s.ListStream(ctx, "blobs/") {
|
||||
if object.Err != nil {
|
||||
streamErr = object.Err
|
||||
}
|
||||
}
|
||||
|
||||
if streamErr == nil {
|
||||
t.Error("ListStream sent no error, want one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory checks that
|
||||
// DeletePartialUploads, which remote nuke uses, removes a leftover temp
|
||||
// file behind a symlinked metadata/ or blobs/ directory and keeps the
|
||||
// object at the real key.
|
||||
func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for _, dir := range []string{metadataDir, blobsDir} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := t.TempDir()
|
||||
key := dir + "/aa/bb/object"
|
||||
|
||||
s, err := storage.NewFileStorer(base)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStorer: %v", err)
|
||||
}
|
||||
|
||||
err = s.Put(ctx, key, strings.NewReader("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
leftover := filepath.Join(base, key+"-123456.partial")
|
||||
|
||||
err = os.WriteFile(leftover, []byte("half"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing leftover temp file: %v", err)
|
||||
}
|
||||
|
||||
moveBehindSymlink(t, base, dir)
|
||||
|
||||
err = s.DeletePartialUploads(ctx, dir+"/")
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePartialUploads: %v", err)
|
||||
}
|
||||
|
||||
// leftover runs through the link.
|
||||
_, err = os.Stat(leftover)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("leftover temp file was not removed: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Stat(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Stat of the real key: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,7 +11,9 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -77,6 +81,174 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
||||
"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 := filepath.Join(t.TempDir(), "remote")
|
||||
|
||||
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.
|
||||
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
|
||||
// present in storage but cannot be checked is not counted as missing:
|
||||
// deep verify stops and fails with the storage error.
|
||||
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||
|
||||
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, 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) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestPruneDeletesNothingThroughSymlinkedDirectory checks that prune keeps
|
||||
// every blob and the local snapshot record when the metadata/ or blobs/
|
||||
// directory of a file:// destination is a symlink to a directory elsewhere
|
||||
// (https://git.eeqj.de/sneak/vaultik/issues/296). A backup writes through
|
||||
// such a link, so prune has to list through it too.
|
||||
func TestPruneDeletesNothingThroughSymlinkedDirectory(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
for _, dir := range []string{"metadata", "blobs"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "store")
|
||||
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||
blobFiles := blobFilesIn(t, storeDir)
|
||||
moveBehindSymlink(t, storeDir, dir)
|
||||
|
||||
require.NoError(t, v.Prune(&vaultik.PruneOptions{Force: true}))
|
||||
|
||||
// When blobs/ is the link, these paths run through it.
|
||||
for _, blobFile := range blobFiles {
|
||||
assert.FileExists(t, blobFile, "prune must delete no blob")
|
||||
}
|
||||
|
||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneFailsOnSymlinkedDirectoryWithMissingTarget checks that prune
|
||||
// fails, deleting no blob and keeping the local snapshot record, when the
|
||||
// metadata/ or blobs/ directory of a file:// destination is a symlink
|
||||
// whose target is missing, as a link to a volume that is not mounted is.
|
||||
func TestPruneFailsOnSymlinkedDirectoryWithMissingTarget(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
for _, dir := range []string{"metadata", "blobs"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "store")
|
||||
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||
blobFiles := blobFilesIn(t, storeDir)
|
||||
targetPath := moveBehindSymlink(t, storeDir, dir)
|
||||
|
||||
// Unmount the volume the link points to, and mount it again
|
||||
// once prune has run.
|
||||
unmountedPath := targetPath + "-unmounted"
|
||||
require.NoError(t, os.Rename(targetPath, unmountedPath))
|
||||
|
||||
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
|
||||
require.NoError(t, os.Rename(unmountedPath, targetPath))
|
||||
|
||||
// When blobs/ is the link, these paths run through it.
|
||||
for _, blobFile := range blobFiles {
|
||||
assert.FileExists(t, blobFile, "prune must delete no blob")
|
||||
}
|
||||
|
||||
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveSnapshotWarnsOnSymlinkedMetadataWithMissingTarget checks that
|
||||
// snapshot remove warns that the metadata could not be removed from the
|
||||
// destination when metadata/ is a symlink whose target is missing, as it
|
||||
// does when the destination directory is missing, instead of finding no
|
||||
// metadata to remove.
|
||||
func TestRemoveSnapshotWarnsOnSymlinkedMetadataWithMissingTarget(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "store")
|
||||
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
||||
targetPath := moveBehindSymlink(t, storeDir, "metadata")
|
||||
require.NoError(t, os.Rename(targetPath, targetPath+"-unmounted"))
|
||||
|
||||
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
|
||||
&vaultik.RemoveOptions{Force: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, result.RemoteRemoved)
|
||||
assert.Contains(t, out.String(),
|
||||
"Could not remove snapshot metadata from remote")
|
||||
assert.NotContains(t, out.String(),
|
||||
"Removed snapshot metadata from remote storage")
|
||||
}
|
||||
|
||||
// blobFilesIn returns the paths of the blob files under storeDir/blobs, and
|
||||
// fails the test when there are none.
|
||||
func blobFilesIn(t *testing.T, storeDir string) []string {
|
||||
t.Helper()
|
||||
|
||||
var blobFiles []string
|
||||
|
||||
err := filepath.WalkDir(filepath.Join(storeDir, "blobs"),
|
||||
func(path string, entry fs.DirEntry, err error) error {
|
||||
if err == nil && !entry.IsDir() {
|
||||
blobFiles = append(blobFiles, path)
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blobFiles)
|
||||
|
||||
return blobFiles
|
||||
}
|
||||
|
||||
// moveBehindSymlink moves storeDir/dir to a directory elsewhere, puts a
|
||||
// symlink to it in its place, and returns where it went.
|
||||
func moveBehindSymlink(t *testing.T, storeDir, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
linkPath := filepath.Join(storeDir, dir)
|
||||
targetPath := filepath.Join(t.TempDir(), dir)
|
||||
require.NoError(t, os.Rename(linkPath, targetPath))
|
||||
require.NoError(t, os.Symlink(targetPath, linkPath))
|
||||
|
||||
return targetPath
|
||||
}
|
||||
+39
-17
@@ -17,6 +17,7 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// Sentinel errors for snapshot verification failures.
|
||||
@@ -32,7 +33,9 @@ var (
|
||||
errManifestExtraBlob = errors.New("manifest contains blob not in database")
|
||||
errManifestMissingBlob = errors.New(
|
||||
"manifest omits blob present in database")
|
||||
errBlobSizeMismatch = errors.New("blob size mismatch")
|
||||
errBlobSizeMismatch = errors.New("blob size mismatch")
|
||||
errBlobsNotInStorage = errors.New(
|
||||
"blobs missing from storage or stored with the wrong size")
|
||||
)
|
||||
|
||||
// verifyStatusFailed is the JSON status value for a failed verification.
|
||||
@@ -149,7 +152,6 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
}
|
||||
|
||||
result.Status = "ok"
|
||||
result.Verified = len(dbBlobs)
|
||||
|
||||
if opts.JSON {
|
||||
return v.outputVerifyJSON(result)
|
||||
@@ -306,7 +308,7 @@ func (v *Vaultik) runVerificationSteps(
|
||||
v.stdoutf("Checking blob existence in remote storage...\n")
|
||||
}
|
||||
|
||||
err = v.verifyBlobExistenceFromDB(dbBlobs)
|
||||
err = v.verifyBlobExistenceFromDB(dbBlobs, result)
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
@@ -317,7 +319,8 @@ func (v *Vaultik) runVerificationSteps(
|
||||
len(dbBlobs), ubytes(totalSize))
|
||||
}
|
||||
|
||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts, identities)
|
||||
err = v.performDeepVerificationFromDB(
|
||||
dbBlobs, tdb.db.Conn(), opts, result, identities)
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
@@ -669,8 +672,14 @@ func (v *Vaultik) verifyManifestAgainstDatabase(
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyBlobExistenceFromDB checks that all blobs from database exist in S3
|
||||
func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
// verifyBlobExistenceFromDB checks that every blob from the database is
|
||||
// in storage with the size the database records. It checks them all,
|
||||
// counting the missing and wrong-size blobs in result, and fails if it
|
||||
// found any. Any storage error other than not found stops the check and
|
||||
// fails with that error.
|
||||
func (v *Vaultik) verifyBlobExistenceFromDB(
|
||||
blobs []snapshot.BlobInfo, result *VerifyResult,
|
||||
) error {
|
||||
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
|
||||
|
||||
for i, blob := range blobs {
|
||||
@@ -683,17 +692,23 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
// Construct blob path
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
||||
|
||||
// Check blob exists
|
||||
stat, err := v.Storage.Stat(v.ctx, blobPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("blob %s missing from storage: %w", blob.Hash, err)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
log.Warn("Blob missing from storage", "hash", blob.Hash)
|
||||
|
||||
// Verify size matches
|
||||
if stat.Size != blob.CompressedSize {
|
||||
return fmt.Errorf(
|
||||
"%w: blob %s: S3 has %d bytes, database has %d bytes",
|
||||
errBlobSizeMismatch, blob.Hash, stat.Size, blob.CompressedSize)
|
||||
result.Missing++
|
||||
result.MissingSize += blob.CompressedSize
|
||||
case err != nil:
|
||||
return fmt.Errorf("checking blob %s in storage: %w",
|
||||
blob.Hash, err)
|
||||
case stat.Size != blob.CompressedSize:
|
||||
log.Warn("Blob has the wrong size in storage",
|
||||
"hash", blob.Hash,
|
||||
"storage_size", stat.Size,
|
||||
"database_size", blob.CompressedSize)
|
||||
|
||||
result.Mismatched++
|
||||
}
|
||||
|
||||
// Progress update every 100 blobs
|
||||
@@ -707,16 +722,22 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
}
|
||||
}
|
||||
|
||||
if result.Missing > 0 || result.Mismatched > 0 {
|
||||
return fmt.Errorf("%w: %d missing, %d with the wrong size",
|
||||
errBlobsNotInStorage, result.Missing, result.Mismatched)
|
||||
}
|
||||
|
||||
log.Info("✓ All blobs exist in storage")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// performDeepVerificationFromDB downloads and verifies the content of
|
||||
// each blob using the database as source.
|
||||
// each blob using the database as source, stopping at the first blob that
|
||||
// fails. Each blob that passes is counted in result.Verified.
|
||||
func (v *Vaultik) performDeepVerificationFromDB(
|
||||
blobs []snapshot.BlobInfo, db *sql.DB, opts *VerifyOptions,
|
||||
identities []age.Identity,
|
||||
result *VerifyResult, identities []age.Identity,
|
||||
) error {
|
||||
// Calculate total bytes for ETA
|
||||
var totalBytesExpected int64
|
||||
@@ -739,6 +760,7 @@ func (v *Vaultik) performDeepVerificationFromDB(
|
||||
return fmt.Errorf("blob %s verification failed: %w", blobInfo.Hash, err)
|
||||
}
|
||||
|
||||
result.Verified++
|
||||
bytesProcessed += blobInfo.CompressedSize
|
||||
elapsed := time.Since(startTime)
|
||||
remaining := len(blobs) - (i + 1)
|
||||
|
||||
Reference in New Issue
Block a user