313 lines
8.3 KiB
Go
313 lines
8.3 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// mockObjectStore is a test double that counts Get calls and adds a small
|
|
// delay to simulate real I/O. This lets us verify singleflight deduplication.
|
|
type mockObjectStore struct {
|
|
getCalls atomic.Int64
|
|
delay time.Duration
|
|
data map[string][]byte
|
|
}
|
|
|
|
func newMockObjectStore(delay time.Duration) *mockObjectStore {
|
|
return &mockObjectStore{
|
|
delay: delay,
|
|
data: make(map[string][]byte),
|
|
}
|
|
}
|
|
|
|
func (m *mockObjectStore) Put(_ context.Context, key string, r io.Reader) error {
|
|
b, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.data[key] = b
|
|
return nil
|
|
}
|
|
|
|
func (m *mockObjectStore) Get(_ context.Context, key string) (io.ReadCloser, error) {
|
|
m.getCalls.Add(1)
|
|
time.Sleep(m.delay)
|
|
b, ok := m.data[key]
|
|
if !ok {
|
|
return nil, io.EOF
|
|
}
|
|
return io.NopCloser(bytes.NewReader(b)), nil
|
|
}
|
|
|
|
func (m *mockObjectStore) Head(_ context.Context, key string) (*ObjectMeta, error) {
|
|
b, ok := m.data[key]
|
|
if !ok {
|
|
return nil, io.EOF
|
|
}
|
|
return &ObjectMeta{
|
|
Key: key,
|
|
Size: int64(len(b)),
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockObjectStore) Exists(_ context.Context, key string) (bool, error) {
|
|
_, ok := m.data[key]
|
|
return ok, nil
|
|
}
|
|
|
|
func (m *mockObjectStore) Delete(_ context.Context, key string) error {
|
|
delete(m.data, key)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockObjectStore) List(_ context.Context, prefix string) ([]string, error) {
|
|
var keys []string
|
|
for k := range m.data {
|
|
if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
var _ = Describe("FileManager", func() {
|
|
Describe("Upload", func() {
|
|
It("sends file data to the object store", func() {
|
|
store := newMockObjectStore(0)
|
|
cacheDir := GinkgoT().TempDir()
|
|
fm, err := NewFileManager(store, cacheDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Write a temp file to upload
|
|
tmpFile := filepath.Join(GinkgoT().TempDir(), "upload-test.txt")
|
|
Expect(os.WriteFile(tmpFile, []byte("upload-content"), 0644)).To(Succeed())
|
|
|
|
Expect(fm.Upload(context.Background(), "my/key", tmpFile)).To(Succeed())
|
|
|
|
// Verify the mock store received the data
|
|
Expect(store.data).To(HaveKey("my/key"))
|
|
Expect(store.data["my/key"]).To(Equal([]byte("upload-content")))
|
|
})
|
|
|
|
It("returns error when local file does not exist", func() {
|
|
store := newMockObjectStore(0)
|
|
cacheDir := GinkgoT().TempDir()
|
|
fm, err := NewFileManager(store, cacheDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
err = fm.Upload(context.Background(), "key", "/nonexistent/path")
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Describe("Download cache hit", func() {
|
|
It("serves from cache on second download without calling store again", func() {
|
|
store := newMockObjectStore(0)
|
|
store.data["cached-key"] = []byte("cached-content")
|
|
|
|
cacheDir := GinkgoT().TempDir()
|
|
fm, err := NewFileManager(store, cacheDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// First download populates cache
|
|
path1, err := fm.Download(context.Background(), "cached-key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(path1).ToNot(BeEmpty())
|
|
|
|
content, err := os.ReadFile(path1)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(content).To(Equal([]byte("cached-content")))
|
|
|
|
Expect(store.getCalls.Load()).To(BeNumerically("==", 1))
|
|
|
|
// Second download should hit cache
|
|
path2, err := fm.Download(context.Background(), "cached-key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(path2).To(Equal(path1))
|
|
|
|
// store.Get should NOT have been called again
|
|
Expect(store.getCalls.Load()).To(BeNumerically("==", 1))
|
|
})
|
|
})
|
|
|
|
Describe("Delete", func() {
|
|
It("removes from both local cache and remote store", func() {
|
|
store := newMockObjectStore(0)
|
|
store.data["del-key"] = []byte("delete-me")
|
|
|
|
cacheDir := GinkgoT().TempDir()
|
|
fm, err := NewFileManager(store, cacheDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Download to populate cache
|
|
localPath, err := fm.Download(context.Background(), "del-key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(localPath).ToNot(BeEmpty())
|
|
|
|
// Verify cache file exists
|
|
_, err = os.Stat(localPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Delete
|
|
Expect(fm.Delete(context.Background(), "del-key")).To(Succeed())
|
|
|
|
// Local cache should be gone
|
|
_, err = os.Stat(localPath)
|
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
|
|
|
// Remote store should be gone
|
|
Expect(store.data).ToNot(HaveKey("del-key"))
|
|
})
|
|
})
|
|
|
|
Describe("nil store (single-node mode)", func() {
|
|
var fm *FileManager
|
|
|
|
BeforeEach(func() {
|
|
cacheDir := GinkgoT().TempDir()
|
|
var err error
|
|
fm, err = NewFileManager(nil, cacheDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("Upload is a no-op", func() {
|
|
tmpFile := filepath.Join(GinkgoT().TempDir(), "nil-upload.txt")
|
|
Expect(os.WriteFile(tmpFile, []byte("data"), 0644)).To(Succeed())
|
|
|
|
err := fm.Upload(context.Background(), "key", tmpFile)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("Download returns an error", func() {
|
|
_, err := fm.Download(context.Background(), "key")
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("Delete is a no-op", func() {
|
|
err := fm.Delete(context.Background(), "key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("IsConfigured returns false", func() {
|
|
Expect(fm.IsConfigured()).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("singleflight deduplication", func() {
|
|
It("deduplicates concurrent downloads for the same key", func() {
|
|
store := newMockObjectStore(50 * time.Millisecond)
|
|
store.data["same-key"] = []byte("test-content")
|
|
|
|
cacheDir := GinkgoT().TempDir()
|
|
fm, err := NewFileManager(store, cacheDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
const numGoroutines = 10
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, numGoroutines)
|
|
paths := make([]string, numGoroutines)
|
|
|
|
for i := range numGoroutines {
|
|
wg.Go(func() {
|
|
defer GinkgoRecover()
|
|
p, e := fm.Download(context.Background(), "same-key")
|
|
paths[i] = p
|
|
errs[i] = e
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
for i, e := range errs {
|
|
Expect(e).ToNot(HaveOccurred(), "goroutine %d", i)
|
|
}
|
|
for i, p := range paths {
|
|
Expect(p).To(Equal(paths[0]), "goroutine %d path differs", i)
|
|
}
|
|
Expect(store.getCalls.Load()).To(BeNumerically("==", 1))
|
|
})
|
|
})
|
|
|
|
Describe("cachePath directory traversal prevention", func() {
|
|
var fm *FileManager
|
|
var cacheDir string
|
|
|
|
BeforeEach(func() {
|
|
cacheDir = GinkgoT().TempDir()
|
|
var err error
|
|
fm, err = NewFileManager(nil, cacheDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("rejects keys with .. that escape the cache directory", func() {
|
|
_, err := fm.CachePath("../escape")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("escapes cache directory"))
|
|
})
|
|
|
|
It("rejects keys with nested .. traversal", func() {
|
|
_, err := fm.CachePath("foo/../../bar")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("escapes cache directory"))
|
|
})
|
|
|
|
It("allows normal keys", func() {
|
|
p, err := fm.CachePath("models/llama/weights.bin")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(p).To(HavePrefix(cacheDir))
|
|
})
|
|
|
|
It("allows simple keys without slashes", func() {
|
|
p, err := fm.CachePath("myfile.txt")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(p).To(Equal(filepath.Join(cacheDir, "myfile.txt")))
|
|
})
|
|
|
|
It("CacheExists returns false for traversal keys", func() {
|
|
Expect(fm.CacheExists("../etc/passwd")).To(BeFalse())
|
|
})
|
|
|
|
It("EvictCache returns error for traversal keys", func() {
|
|
err := fm.EvictCache("../../etc/shadow")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("escapes cache directory"))
|
|
})
|
|
})
|
|
|
|
Describe("different keys", func() {
|
|
It("does not serialize downloads for different keys", func() {
|
|
store := newMockObjectStore(50 * time.Millisecond)
|
|
store.data["key-a"] = []byte("content-a")
|
|
store.data["key-b"] = []byte("content-b")
|
|
|
|
cacheDir := GinkgoT().TempDir()
|
|
fm, err := NewFileManager(store, cacheDir)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var wg sync.WaitGroup
|
|
var errA, errB error
|
|
wg.Go(func() {
|
|
defer GinkgoRecover()
|
|
_, errA = fm.Download(context.Background(), "key-a")
|
|
})
|
|
wg.Go(func() {
|
|
defer GinkgoRecover()
|
|
_, errB = fm.Download(context.Background(), "key-b")
|
|
})
|
|
wg.Wait()
|
|
|
|
Expect(errA).ToNot(HaveOccurred())
|
|
Expect(errB).ToNot(HaveOccurred())
|
|
Expect(store.getCalls.Load()).To(BeNumerically("==", 2))
|
|
})
|
|
})
|
|
})
|