package storage
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A moved store must still be a store: MoveStore relocates the directory whole,
// so the same chunks open at the new path.
func TestMoveStoreMovesAServableStore(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
src := RepoDiskPath(root, "alice", "widgets")
dst := RepoDiskPath(root, "alice", "gadgets")
require.NoError(t, InitStore(ctx, src, "alice", "a@b.test"))
require.NoError(t, MoveStore(ctx, root, src, dst))
_, err := os.Stat(src)
assert.True(t, os.IsNotExist(err), "the old path must be gone, stat err = %v", err)
cache := NewCache(func(context.Context, string, string) (string, error) { return dst, nil })
defer cache.Close()
cs, err := cache.Get(ctx, "~alice/gadgets", nbfVer)
require.NoError(t, err, "the moved store must open at its new path")
assert.NotNil(t, cs)
}
// The web rename evicts the memoized handle *after* the directory has moved,
// so eviction has to survive closing a store whose files are no longer where it
// opened them. If it did not, every rename of a database that had been served
// once would end in the eviction-failure branch — a 500 on the routine path
// instead of on the exceptional one.
func TestEvictAfterMoveClosesCleanly(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
src := RepoDiskPath(root, "alice", "widgets")
dst := RepoDiskPath(root, "alice", "gadgets")
require.NoError(t, InitStore(ctx, src, "alice", "a@b.test"))
cache := NewCache(func(_ context.Context, _, name string) (string, error) {
if name == "widgets" {
return src, nil
}
return dst, nil
})
defer cache.Close()
_, err := cache.Get(ctx, "~alice/widgets", nbfVer) // memoize a handle on the old path
require.NoError(t, err)
require.NoError(t, MoveStore(ctx, root, src, dst))
require.NoError(t, cache.Evict(src), "evicting a handle whose store has moved must not fail")
// And the moved store is servable again through its new path.
_, err = cache.Get(ctx, "~alice/gadgets", nbfVer)
require.NoError(t, err)
}
// os.Rename onto an existing empty directory succeeds and swallows it, so the
// destination is checked before anything is touched.
func TestMoveStoreRefusesAnExistingDestination(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
src := RepoDiskPath(root, "alice", "widgets")
dst := RepoDiskPath(root, "alice", "gadgets")
require.NoError(t, InitStore(ctx, src, "alice", "a@b.test"))
for _, tc := range []struct {
name string
setup func(t *testing.T)
}{
{"empty directory", func(t *testing.T) { require.NoError(t, os.MkdirAll(dst, 0o755)) }},
{"another store", func(t *testing.T) { require.NoError(t, InitStore(ctx, dst, "alice", "a@b.test")) }},
} {
t.Run(tc.name, func(t *testing.T) {
require.NoError(t, os.RemoveAll(dst))
tc.setup(t)
require.Error(t, MoveStore(ctx, root, src, dst), "MoveStore must refuse an occupied destination")
assert.DirExists(t, src, "a refused move must leave the source where it was")
assert.DirExists(t, dst, "a refused move must leave the destination alone")
})
}
}
// A source that is not there means the rename would leave the metadata row
// pointing at nothing, so it is an error rather than a silent success.
func TestMoveStoreRefusesAMissingSource(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
err := MoveStore(ctx, root, RepoDiskPath(root, "alice", "ghost"), RepoDiskPath(root, "alice", "gadgets"))
require.Error(t, err)
assert.NoDirExists(t, RepoDiskPath(root, "alice", "gadgets"))
}
func TestMoveStoreRefusesTheSamePath(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
src := RepoDiskPath(root, "alice", "widgets")
require.NoError(t, InitStore(ctx, src, "alice", "a@b.test"))
require.Error(t, MoveStore(ctx, root, src, src))
assert.DirExists(t, src)
}
// Both ends of the move are contained: neither a source dragged in from outside
// the repos root nor a destination pointed outside it may be acted on.
func TestMoveStoreRootEscapeGuard(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
outside := t.TempDir()
canary := filepath.Join(outside, "keep")
require.NoError(t, os.WriteFile(canary, []byte("x"), 0o644))
inside := RepoDiskPath(root, "alice", "widgets")
require.NoError(t, InitStore(ctx, inside, "alice", "a@b.test"))
for name, tc := range map[string]struct{ src, dst string }{
"source outside the root": {outside, RepoDiskPath(root, "alice", "gadgets")},
"destination outside": {inside, filepath.Join(outside, "stolen")},
"traversal destination": {inside, filepath.Join(root, "..", filepath.Base(outside), "stolen")},
"the root as a source": {root, RepoDiskPath(root, "alice", "gadgets")},
"the root as a destination": {inside, root},
"prefix-not-subdir dest": {inside, root + "-evil"},
"relative source": {"rel/path", RepoDiskPath(root, "alice", "gadgets")},
"relative destination": {inside, "rel/path"},
"relative root with abs ends": {inside, RepoDiskPath(root, "alice", "gadgets")},
} {
t.Run(name, func(t *testing.T) {
useRoot := root
if name == "relative root with abs ends" {
useRoot = "rel/root"
}
require.Error(t, MoveStore(ctx, useRoot, tc.src, tc.dst),
"MoveStore(%q, %q) should have been refused", tc.src, tc.dst)
})
}
assert.FileExists(t, canary, "a refused move disturbed something outside the repos root")
assert.DirExists(t, inside, "a refused move disturbed the store it named")
assert.NoDirExists(t, RepoDiskPath(root, "alice", "gadgets"))
}