Files
vaultik/internal/storage/file_backend_test.go
T
sneak 64f7625c63
check / check (push) Waiting to run
Refuse a backup destination an unplugged volume left empty (closes #239)
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
2026-10-13 21:32:48 +00:00

335 lines
8.2 KiB
Go

package storage_test
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"sneak.berlin/go/vaultik/internal/storage"
)
// newFileStorer builds a file:// backend rooted at a fresh temp directory.
//
//nolint:ireturn // conformance runs against the Storer interface by design
func newFileStorer(t *testing.T) storage.Storer {
t.Helper()
s, err := storage.NewFileStorer(t.TempDir())
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
return s
}
// TestFileStorer runs the shared Storer contract against the file:// backend.
func TestFileStorer(t *testing.T) {
t.Parallel()
runStorerConformance(t, newFileStorer)
}
// TestFileStorerListMissingDestination checks that List and ListStream
// fail when the destination directory does not exist, instead of
// reporting an empty store.
func TestFileStorerListMissingDestination(t *testing.T) {
t.Parallel()
ctx := context.Background()
s, err := storage.NewFileStorer(filepath.Join(t.TempDir(), "unmounted"))
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
keys, err := s.List(ctx, "metadata/")
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("List = %v, %v; want a not-exist error", keys, err)
}
var streamErr error
for object := range s.ListStream(ctx, "metadata/") {
if object.Err != nil {
streamErr = object.Err
}
}
if !errors.Is(streamErr, fs.ErrNotExist) {
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)
}
})
}
}
// TestFileStorerPutMissingDestination checks that a write fails when the
// destination directory has gone, even after earlier writes to it
// succeeded, and that it does not create the directory again.
func TestFileStorerPutMissingDestination(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := filepath.Join(t.TempDir(), "usbstick")
err := os.Mkdir(base, 0o750)
if err != nil {
t.Fatalf("Mkdir: %v", err)
}
s, err := storage.NewFileStorer(base)
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
err = s.Put(ctx, "blobs/aa/bb/first", strings.NewReader("first"))
if err != nil {
t.Fatalf("first Put: %v", err)
}
err = os.Rename(base, base+"-unplugged")
if err != nil {
t.Fatalf("Rename: %v", err)
}
err = s.Put(ctx, "blobs/aa/bb/second", strings.NewReader("second"))
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Put = %v, want a not-exist error", err)
}
_, err = os.Stat(base)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Put created the destination directory again (Stat = %v)", err)
}
}