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")) }