check / check (push) Waiting to run
List and ListStream walked the destination with afero.Walk, which uses Lstat and so saw a symlinked metadata/ or blobs/ as one file, while Put, Get and Stat follow the link. The store then listed as empty, and prune dropped the local snapshot records and deleted every blob. The listings, and DeletePartialUploads (used by remote nuke), now walk a view without Lstat, so afero falls back to Stat and enters the link. A prefix lists as empty only when truly missing; a missing symlink target on it or above it, such as an unmounted volume, fails the listing. Judgement call: a symlink loop is not detected; it is followed until the system refuses the path, and the listing fails. Model: opus-5-5
148 lines
4.8 KiB
Go
148 lines
4.8 KiB
Go
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
|
|
}
|