diff --git a/backends/fs/fs_test.go b/backends/fs/fs_test.go index 67ae80e..e608a7a 100644 --- a/backends/fs/fs_test.go +++ b/backends/fs/fs_test.go @@ -10,10 +10,8 @@ import ( "github.com/PowerDNS/simpleblob/tester" ) -func TestBackend(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "simpleblob-test-") - assert.NoError(t, err) - t.Cleanup(func() { +func cleanup(t *testing.T, tmpDir string) func() { + return func() { // Don't want to use the recursive os.RemoveAll() for safety if tmpDir == "" { return @@ -28,7 +26,13 @@ func TestBackend(t *testing.T) { } err = os.Remove(tmpDir) assert.NoError(t, err) - }) + } +} + +func TestBackend(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "simpleblob-test-") + assert.NoError(t, err) + t.Cleanup(cleanup(t, tmpDir)) b, err := New(Options{RootPath: tmpDir}) assert.NoError(t, err) diff --git a/backends/memory/memory_test.go b/backends/memory/memory_test.go index a6e6a7d..c5ebe98 100644 --- a/backends/memory/memory_test.go +++ b/backends/memory/memory_test.go @@ -7,6 +7,5 @@ import ( ) func TestBackend(t *testing.T) { - b := New() - tester.DoBackendTests(t, b) + tester.DoBackendTests(t, New()) } diff --git a/fs.go b/fs.go new file mode 100644 index 0000000..ccc469f --- /dev/null +++ b/fs.go @@ -0,0 +1,95 @@ +package simpleblob + +import ( + "bytes" + "context" + "io/fs" + "time" +) + +// fsInterfaceWrapper wraps an Interface and implements fs.FS. +type fsInterfaceWrapper struct { + Interface + ctx context.Context +} + +// fsBlobWrapper represents data upstream and implements both fs.File +// and fs.FileInfo for convenience. +type fsBlobWrapper struct { + b *Blob + parent *fsInterfaceWrapper + r *bytes.Reader +} + +// AsFS casts the provided interface to a fs.FS interface if supported, +// else it wraps it to replicate its functionalities. +func AsFS(ctx context.Context, st Interface) fs.FS { + if fsys, ok := st.(fs.FS); ok { + return fsys + } + return &fsInterfaceWrapper{st, ctx} +} + +// Open retrieves a Blob, wrapped as a fs.File, from the underlying Interface. +func (stw *fsInterfaceWrapper) Open(name string) (fs.File, error) { + b, err := stw.Load(stw.ctx, name) + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + return &fsBlobWrapper{&Blob{name, int64(len(b))}, stw, nil}, nil +} + +// ReadDir satisfies fs.ReadDirFS +func (stw *fsInterfaceWrapper) ReadDir(name string) ([]fs.DirEntry, error) { + ls, err := stw.List(stw.ctx, "") + if err != nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} + } + ret := make([]fs.DirEntry, len(ls)) + for i, entry := range ls { + blob := entry + ret[i] = &fsBlobWrapper{&blob, stw, nil} + } + return ret, nil +} + +// ReadFile implements fs.ReadFileFS on top of an Interface wrapped as a fs.FS. +func (stw *fsInterfaceWrapper) ReadFile(name string) ([]byte, error) { + return stw.Load(stw.ctx, name) +} + +// fs.FileInfo implementation + +func (*fsBlobWrapper) IsDir() bool { return false } +func (*fsBlobWrapper) ModTime() time.Time { return time.Time{} } +func (*fsBlobWrapper) Mode() fs.FileMode { return 0777 } +func (bw *fsBlobWrapper) Name() string { return bw.b.Name } +func (bw *fsBlobWrapper) Sys() interface{} { return bw.parent } +func (bw *fsBlobWrapper) Size() int64 { return bw.b.Size } + +// fs.File implementation + +func (bw *fsBlobWrapper) Stat() (fs.FileInfo, error) { + return bw, nil +} +func (bw *fsBlobWrapper) Read(p []byte) (int, error) { + if bw.r == nil { + b, err := bw.parent.Interface.Load(bw.parent.ctx, bw.b.Name) + if err != nil { + return 0, err + } + bw.r = bytes.NewReader(b) + } + return bw.r.Read(p) +} +func (bw *fsBlobWrapper) Close() error { + if bw.r != nil { + bw.r = nil + } + return nil +} + +// fs.DirEntry implementation + +func (bw *fsBlobWrapper) Type() fs.FileMode { return bw.Mode() } +func (bw *fsBlobWrapper) Info() (fs.FileInfo, error) { return bw, nil } diff --git a/tester/tester.go b/tester/tester.go index 8ab9976..49b2f1c 100644 --- a/tester/tester.go +++ b/tester/tester.go @@ -2,6 +2,8 @@ package tester import ( "context" + "io" + "io/fs" "os" "testing" @@ -14,10 +16,19 @@ func DoBackendTests(t *testing.T, b simpleblob.Interface) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Wrap provided interface into a filesystem + // and use the backend to check operations on the filesystem. + // Such operations will be performed along the ones on the backend. + fsys := simpleblob.AsFS(context.Background(), b) + // Starts empty ls, err := b.List(ctx, "") assert.NoError(t, err) assert.Len(t, ls, 0) + // With FS + dirls, err := fs.ReadDir(fsys, ".") + assert.NoError(t, err) + assert.Len(t, dirls, 0) // Add items foo := []byte("foo") // will be modified later @@ -33,9 +44,17 @@ func DoBackendTests(t *testing.T, b simpleblob.Interface) { assert.NoError(t, err) // List all + expectedNames := []string{"bar-1", "bar-2", "foo-1"} // sorted ls, err = b.List(ctx, "") assert.NoError(t, err) - assert.Equal(t, ls.Names(), []string{"bar-1", "bar-2", "foo-1"}) // sorted + assert.Equal(t, ls.Names(), expectedNames) + // With FS + dirls, err = fs.ReadDir(fsys, ".") + assert.NoError(t, err) + assert.Len(t, dirls, len(expectedNames)) + for i, entry := range dirls { + assert.Equal(t, expectedNames[i], entry.Name()) + } // List with prefix ls, err = b.List(ctx, "foo-") @@ -50,27 +69,79 @@ func DoBackendTests(t *testing.T, b simpleblob.Interface) { data, err := b.Load(ctx, "foo-1") assert.NoError(t, err) assert.Equal(t, data, []byte("foo")) + // With FS + f, err := fsys.Open("foo-1") + assert.NoError(t, err) + dataf, err := io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, data, dataf) + assert.NoError(t, f.Close()) + // With ReadFileFS + datar, err := fs.ReadFile(fsys, "foo-1") + assert.NoError(t, err) + assert.Equal(t, data, datar) // Check overwritten data data, err = b.Load(ctx, "bar-1") assert.NoError(t, err) assert.Equal(t, data, []byte("bar1")) + // With FS + f, err = fsys.Open("bar-1") + assert.NoError(t, err) + dataf, err = io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, data, dataf) + assert.NoError(t, f.Close()) + // With ReadFileFS + datar, err = fs.ReadFile(fsys, "bar-1") + assert.NoError(t, err) + assert.Equal(t, data, datar) // Verify that Load makes a copy data[0] = '!' data, err = b.Load(ctx, "bar-1") assert.NoError(t, err) assert.Equal(t, data, []byte("bar1")) + // With FS + f, err = fsys.Open("bar-1") + assert.NoError(t, err) + dataf, err = io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, data, dataf) + assert.NoError(t, f.Close()) + // With ReadFileFS + datar, err = fs.ReadFile(fsys, "bar-1") + assert.NoError(t, err) + assert.Equal(t, data, datar) // Change foo buffer to verify that Store made a copy foo[0] = '!' data, err = b.Load(ctx, "foo-1") assert.NoError(t, err) assert.Equal(t, data, []byte("foo")) + // With FS + f, err = fsys.Open("foo-1") + assert.NoError(t, err) + dataf, err = io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, data, dataf) + assert.NoError(t, f.Close()) + // With ReadFileFS + datar, err = fs.ReadFile(fsys, "foo-1") + assert.NoError(t, err) + assert.Equal(t, data, datar) // Load non-existing _, err = b.Load(ctx, "does-not-exist") assert.ErrorIs(t, err, os.ErrNotExist) + // With FS + f, err = fsys.Open("something") + assert.ErrorIs(t, err, os.ErrNotExist) + assert.Nil(t, f) + // With ReadFileFS + datar, err = fs.ReadFile(fsys, "does-not-exist-either") + assert.Error(t, err) + assert.Empty(t, datar) // Delete existing err = b.Delete(ctx, "foo-1")