diff --git a/internal/bsr/internal/fstest/fs.go b/internal/bsr/internal/fstest/fs.go index 552653feb..31b203113 100644 --- a/internal/bsr/internal/fstest/fs.go +++ b/internal/bsr/internal/fstest/fs.go @@ -7,6 +7,7 @@ package fstest import ( "bytes" "context" + "errors" "fmt" "os" "sync" @@ -17,6 +18,14 @@ import ( "github.com/hashicorp/boundary/internal/storage" ) +// Common erorrs +var ( + ErrClosed = errors.New("closed") + ErrAlreadyExists = errors.New("already exists") + ErrDoesNotExist = errors.New("does not exist") + ErrReadOnly = errors.New("read-only") +) + const ( defaultContainerPerm = 0o775 defaultFilePerm = 0o644 @@ -48,14 +57,14 @@ func (m *MemFS) New(ctx context.Context, n string) (storage.Container, error) { } if m.readOnly { - return nil, fmt.Errorf("cannot create new container from read-only fs") + return nil, fmt.Errorf("cannot create new container from read-only fs: %w", ErrReadOnly) } if m.Containers == nil { m.Containers = make(map[string]*MemContainer) } if _, exists := m.Containers[n]; exists { - return nil, fmt.Errorf("container %s already exists", n) + return nil, fmt.Errorf("container %s already exists: %w", n, ErrAlreadyExists) } c := &MemContainer{ @@ -72,12 +81,12 @@ func (m *MemFS) New(ctx context.Context, n string) (storage.Container, error) { // Open opens an existing a storage.Container from the MemFS. func (m *MemFS) Open(_ context.Context, n string) (storage.Container, error) { if m.Containers == nil { - return nil, fmt.Errorf("container %s not found", n) + return nil, fmt.Errorf("container %s not found: %w", n, ErrDoesNotExist) } c, ok := m.Containers[n] if !ok { - return nil, fmt.Errorf("container %s not found", n) + return nil, fmt.Errorf("container %s not found: %w", n, ErrDoesNotExist) } c.Closed = false return c, nil @@ -109,7 +118,7 @@ func (m *MemContainer) Close() error { // Create makes a new storage.File in the container. func (m *MemContainer) Create(ctx context.Context, n string) (storage.File, error) { if m.Closed { - return nil, fmt.Errorf("create on closed container") + return nil, fmt.Errorf("create on closed container: %w", ErrClosed) } return m.OpenFile(ctx, n, storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) } @@ -121,16 +130,16 @@ func (m *MemContainer) OpenFile(_ context.Context, n string, option ...storage.O defer m.Unlock() if m.Closed { - return nil, fmt.Errorf("create on closed container") + return nil, fmt.Errorf("create on closed container: %w", ErrClosed) } opts := storage.GetOpts(option...) if m.accessMode == storage.ReadOnly && opts.WithFileAccessMode != storage.ReadOnly { - return nil, fmt.Errorf("cannot create writeable file in readonly container") + return nil, fmt.Errorf("cannot create writeable file in readonly container: %w", ErrReadOnly) } if opts.WithCreateFile && opts.WithFileAccessMode == storage.ReadOnly { - return nil, fmt.Errorf("cannot create file in read-only mode") + return nil, fmt.Errorf("cannot create file in read-only mode: %w", ErrReadOnly) } var f *MemFile @@ -144,7 +153,7 @@ func (m *MemContainer) OpenFile(_ context.Context, n string, option ...storage.O var ok bool f, ok = m.Files[n] if !ok { - return nil, fmt.Errorf("file %s does not exist", n) + return nil, fmt.Errorf("file %s does not exist: %w", n, ErrDoesNotExist) } } @@ -162,19 +171,19 @@ func (m *MemContainer) SubContainer(_ context.Context, n string, option ...stora m.Lock() defer m.Unlock() if m.Closed { - return nil, fmt.Errorf("subcontainer on closed container") + return nil, fmt.Errorf("subcontainer on closed container: %w", ErrClosed) } opts := storage.GetOpts(option...) c, exists := m.Sub[n] if opts.WithCreateFile && opts.WithFileAccessMode == storage.ReadOnly { - return nil, fmt.Errorf("cannot create container in read-only mode") + return nil, fmt.Errorf("cannot create container in read-only mode: %w", ErrReadOnly) } if opts.WithCreateFile { if exists { - return nil, fmt.Errorf("container %s already exists", n) + return nil, fmt.Errorf("container %s already exists: %w", n, ErrAlreadyExists) } c = &MemContainer{ Name: n, @@ -183,7 +192,7 @@ func (m *MemContainer) SubContainer(_ context.Context, n string, option ...stora } } else { if !exists { - return nil, fmt.Errorf("container %s does not exist", n) + return nil, fmt.Errorf("container %s does not exist: %w", n, ErrDoesNotExist) } } c.accessMode = opts.WithFileAccessMode diff --git a/internal/bsr/internal/fstest/fs_test.go b/internal/bsr/internal/fstest/fs_test.go index f980b5651..fa276ec78 100644 --- a/internal/bsr/internal/fstest/fs_test.go +++ b/internal/bsr/internal/fstest/fs_test.go @@ -6,7 +6,7 @@ package fstest_test import ( "context" "errors" - "fmt" + "os" "testing" "github.com/hashicorp/boundary/internal/bsr/internal/fstest" @@ -14,45 +14,31 @@ import ( "github.com/stretchr/testify/require" ) +// Test options that only MemFS supports func TestMemFSNew(t *testing.T) { ctx := context.Background() + errCustom := errors.New("custom error from new") + cases := []struct { name string m *fstest.MemFS n string wantErr error }{ - { - "default", - fstest.NewMemFS(), - "test", - nil, - }, { "read-only", fstest.NewMemFS(fstest.WithReadOnly(true)), "test", - errors.New("cannot create new container from read-only fs"), + fstest.ErrReadOnly, }, { "custom-new-func", fstest.NewMemFS(fstest.WithNewFunc(func(_ context.Context, _ string) (storage.Container, error) { - return nil, fmt.Errorf("custom error from new") + return nil, errCustom })), "test", - errors.New("custom error from new"), - }, - { - "already-exists", - func() *fstest.MemFS { - f := fstest.NewMemFS() - _, err := f.New(ctx, "test") - require.NoError(t, err) - return f - }(), - "test", - errors.New("container test already exists"), + errCustom, }, } @@ -60,7 +46,7 @@ func TestMemFSNew(t *testing.T) { t.Run(tc.name, func(t *testing.T) { c, err := tc.m.New(ctx, tc.n) if tc.wantErr != nil { - require.EqualError(t, err, tc.wantErr.Error()) + require.ErrorIs(t, err, tc.wantErr) return } require.NoError(t, err) @@ -69,184 +55,317 @@ func TestMemFSNew(t *testing.T) { } } -func TestMemFSOpen(t *testing.T) { +func TestFSNew(t *testing.T) { ctx := context.Background() + fsCases := []struct { + name string + newFs func(t *testing.T) storage.FS + }{ + { + "MemFS", + func(t *testing.T) storage.FS { return fstest.NewMemFS() }, + }, + { + "LocalFS", + func(t *testing.T) storage.FS { + d, err := os.MkdirTemp("", "localfs") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(d) + }) + return fstest.NewLocalFS(ctx, d) + }, + }, + } cases := []struct { name string - m *fstest.MemFS + n string + setupFn func(t *testing.T, fs storage.FS) + wantErr error + }{ + { + "default", + "test", + nil, + nil, + }, + { + "already-exists", + "test", + func(t *testing.T, fs storage.FS) { + _, err := fs.New(ctx, "test") + require.NoError(t, err) + }, + fstest.ErrAlreadyExists, + }, + } + + for _, tfs := range fsCases { + t.Run(tfs.name, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := tfs.newFs(t) + if tc.setupFn != nil { + tc.setupFn(t, f) + } + c, err := f.New(ctx, tc.n) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, c) + }) + } + }) + } +} + +func TestFSOpen(t *testing.T) { + ctx := context.Background() + + fsCases := []struct { + name string + newFs func(t *testing.T) storage.FS + }{ + { + "MemFS", + func(t *testing.T) storage.FS { return fstest.NewMemFS() }, + }, + { + "LocalFS", + func(t *testing.T) storage.FS { + d, err := os.MkdirTemp("", "localfs") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(d) + }) + return fstest.NewLocalFS(ctx, d) + }, + }, + } + cases := []struct { + name string + setupFn func(t *testing.T, fs storage.FS) n string wantErr error }{ { "exists", - func() *fstest.MemFS { - f := fstest.NewMemFS() - _, err := f.New(ctx, "test") + func(t *testing.T, fs storage.FS) { + _, err := fs.New(ctx, "test") require.NoError(t, err) - return f - }(), + }, "test", nil, }, { "does-not-exist", - fstest.NewMemFS(), + nil, "test", - errors.New("container test not found"), + fstest.ErrDoesNotExist, }, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - c, err := tc.m.Open(ctx, tc.n) - if tc.wantErr != nil { - require.EqualError(t, err, tc.wantErr.Error()) - return + for _, tfs := range fsCases { + t.Run(tfs.name, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := tfs.newFs(t) + if tc.setupFn != nil { + tc.setupFn(t, f) + } + c, err := f.Open(ctx, tc.n) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, c) + }) } - require.NoError(t, err) - require.NotNil(t, c) }) } } -func TestMemContainerOpenFile(t *testing.T) { +func TestContainerOpenFile(t *testing.T) { ctx := context.Background() + fsCases := []struct { + name string + newFs func(t *testing.T) storage.FS + }{ + { + "MemFS", + func(t *testing.T) storage.FS { return fstest.NewMemFS() }, + }, + { + "LocalFS", + func(t *testing.T) storage.FS { + d, err := os.MkdirTemp("", "localfs") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(d) + }) + return fstest.NewLocalFS(ctx, d) + }, + }, + } + cases := []struct { name string - c *fstest.MemContainer + setupFn func(t *testing.T, f storage.FS) storage.Container n string opts []storage.Option wantErr error }{ { "create", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)}, nil, }, { "create-already-exists", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - _, err = cc.OpenFile(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) + _, err = c.OpenFile(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) require.NoError(t, err) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)}, nil, }, { "create-read-only", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadOnly)}, - errors.New("cannot create file in read-only mode"), + fstest.ErrReadOnly, }, { "read-only-does-not-exist", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithFileAccessMode(storage.ReadOnly)}, - errors.New("file test does not exist"), + fstest.ErrDoesNotExist, }, { "read-only-exist", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - _, err = cc.OpenFile(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) + _, err = c.OpenFile(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) require.NoError(t, err) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithFileAccessMode(storage.ReadOnly)}, nil, }, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - c, err := tc.c.OpenFile(ctx, tc.n, tc.opts...) - if tc.wantErr != nil { - require.EqualError(t, err, tc.wantErr.Error()) - return + for _, tfs := range fsCases { + t.Run(tfs.name, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := tfs.newFs(t) + c := tc.setupFn(t, fs) + f, err := c.OpenFile(ctx, tc.n, tc.opts...) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, f) + }) } - require.NoError(t, err) - require.NotNil(t, c) }) } } -func TestMemContainerCreate(t *testing.T) { +func TestContainerCreate(t *testing.T) { ctx := context.Background() + fsCases := []struct { + name string + newFs func(t *testing.T) storage.FS + }{ + { + "MemFS", + func(t *testing.T) storage.FS { return fstest.NewMemFS() }, + }, + { + "LocalFS", + func(t *testing.T) storage.FS { + d, err := os.MkdirTemp("", "localfs") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(d) + }) + return fstest.NewLocalFS(ctx, d) + }, + }, + } + cases := []struct { name string - c *fstest.MemContainer + setupFn func(t *testing.T, f storage.FS) storage.Container n string wantErr error }{ { "create", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - return cc - }(), + return c + }, "test", nil, }, { "create-already-exists", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - _, err = cc.OpenFile(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) + _, err = c.OpenFile(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) require.NoError(t, err) - return cc - }(), + return c + }, "test", nil, }, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - c, err := tc.c.Create(ctx, tc.n) - if tc.wantErr != nil { - require.EqualError(t, err, tc.wantErr.Error()) - return + for _, tfs := range fsCases { + t.Run(tfs.name, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := tfs.newFs(t) + c := tc.setupFn(t, fs) + f, err := c.Create(ctx, tc.n) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, f) + }) } - require.NoError(t, err) - require.NotNil(t, c) }) } } @@ -254,92 +373,108 @@ func TestMemContainerCreate(t *testing.T) { func TestMemContainerSubContainer(t *testing.T) { ctx := context.Background() + fsCases := []struct { + name string + newFs func(t *testing.T) storage.FS + }{ + { + "MemFS", + func(t *testing.T) storage.FS { return fstest.NewMemFS() }, + }, + { + "LocalFS", + func(t *testing.T) storage.FS { + d, err := os.MkdirTemp("", "localfs") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(d) + }) + return fstest.NewLocalFS(ctx, d) + }, + }, + } cases := []struct { name string - c *fstest.MemContainer + setupFn func(t *testing.T, f storage.FS) storage.Container n string opts []storage.Option wantErr error }{ { "create", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)}, nil, }, { "create-already-exists", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - _, err = cc.SubContainer(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) + _, err = c.SubContainer(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) require.NoError(t, err) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)}, - errors.New("container test already exists"), + fstest.ErrAlreadyExists, }, { "create-read-only", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadOnly)}, - errors.New("cannot create container in read-only mode"), + fstest.ErrReadOnly, }, { "read-only-does-not-exist", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithFileAccessMode(storage.ReadOnly)}, - errors.New("container test does not exist"), + fstest.ErrDoesNotExist, }, { "read-only-exist", - func() *fstest.MemContainer { - f := fstest.NewMemFS() + func(t *testing.T, f storage.FS) storage.Container { c, err := f.New(ctx, "test") require.NoError(t, err) - cc := c.(*fstest.MemContainer) - _, err = cc.SubContainer(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) + _, err = c.SubContainer(ctx, "test", storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) require.NoError(t, err) - return cc - }(), + return c + }, "test", []storage.Option{storage.WithFileAccessMode(storage.ReadOnly)}, nil, }, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - c, err := tc.c.SubContainer(ctx, tc.n, tc.opts...) - if tc.wantErr != nil { - require.EqualError(t, err, tc.wantErr.Error()) - return + for _, tfs := range fsCases { + t.Run(tfs.name, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := tfs.newFs(t) + c := tc.setupFn(t, fs) + sc, err := c.SubContainer(ctx, tc.n, tc.opts...) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, sc) + }) } - require.NoError(t, err) - require.NotNil(t, c) }) } } diff --git a/internal/bsr/internal/fstest/local.go b/internal/bsr/internal/fstest/local.go new file mode 100644 index 000000000..baf103082 --- /dev/null +++ b/internal/bsr/internal/fstest/local.go @@ -0,0 +1,354 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fstest + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/hashicorp/boundary/internal/storage" +) + +// LocalFS is a test filesystem that creates files in the provided directory. +type LocalFS struct { + Path string +} + +// NewLocalFS creates a LocalFS. +func NewLocalFS(_ context.Context, p string) *LocalFS { + return &LocalFS{ + Path: p, + } +} + +// New creates the named root container within the local FS. +// Calls to New make use of the os.Mkdir locking and error handling +// to ensure that the directory being made is a valid directory and does not +// exist. +func (fs *LocalFS) New(_ context.Context, name string) (storage.Container, error) { + const op = "fstest.(LocalFS).New" + if name == "" { + return nil, fmt.Errorf("%s: missing name", op) + } + path, err := joinPath(fs.Path, name) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + if err := os.Mkdir(path, defaultContainerPerm); err != nil { + return nil, fmt.Errorf("%s: %w: %w", op, err, ErrAlreadyExists) + } + + return &LocalContainer{ + Path: path, + IsRoot: true, + }, nil +} + +// Open opens an existing root container. +func (fs *LocalFS) Open(_ context.Context, name string) (storage.Container, error) { + const op = "fstest.(LocalFS).Open" + path, err := joinPath(fs.Path, name) + if err != nil { + return nil, fmt.Errorf("%s: %w: %w", op, err, ErrDoesNotExist) + } + stat, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("%s: %w: %w", op, err, ErrDoesNotExist) + } + + if !stat.IsDir() { + return nil, fmt.Errorf("%s not a dir: %w", name, ErrDoesNotExist) + } + + return &LocalContainer{ + Path: path, + IsRoot: true, + }, nil +} + +// joinPath joins a parent path with the single name child container or file. +// The name cannot contain any path seperators. +// The name cannot contain `..` +// The subpath returned will be a child of the parent. +func joinPath(parentPath, name string) (string, error) { + // Check if name has any OS path separator and fail if it does + if strings.Contains(name, "/") || strings.Contains(name, "\\") { + return "", fmt.Errorf("name contains path separator") + } + + subPath := filepath.Join(parentPath, name) + if len(parentPath) >= len(subPath) { + return "", fmt.Errorf("new path must be longer than parent") + } + + rel, err := filepath.Rel(parentPath, subPath) + if err != nil { + return "", err + } + if strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("subpath must be within current container") + } + + return subPath, nil +} + +// LocalContainer is a storage.Container backed by a os.File that is a directory. +type LocalContainer struct { + Path string + closed bool + IsRoot bool + + sync.RWMutex +} + +// Sub returns a map of subcontainers in this container where the key is the name +// of the container. +func (c *LocalContainer) Sub(ctx context.Context) (map[string]*LocalContainer, error) { + sub := make(map[string]*LocalContainer, 0) + + err := filepath.WalkDir(c.Path, func(path string, d fs.DirEntry, _ error) error { + if !d.IsDir() { + // continue to next file if not a directory + return nil + } + + // If the root, continue the walk + if path == c.Path { + return nil + } + bn := filepath.Base(path) + cc, err := c.SubContainer(ctx, bn, storage.WithFileAccessMode(storage.ReadWrite)) + if err != nil { + return err + } + sub[bn] = cc.(*LocalContainer) + // Only do one level, don't walk down into each directory. + return filepath.SkipDir + }) + return sub, err +} + +// Close closes the container. +func (c *LocalContainer) Close() error { + const op = "fstest.(LocalContainer).Close" + c.Lock() + defer c.Unlock() + c.closed = true + + return nil +} + +// Create creates a new storage.File in the container. +func (c *LocalContainer) Create(ctx context.Context, name string) (storage.File, error) { + const op = "fstest.(LocalContainer).Create" + file, err := c.OpenFile(ctx, name, storage.WithCreateFile(), storage.WithFileAccessMode(storage.ReadWrite)) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + return file, nil +} + +// OpenFile opens a file in the container. +// Supports the following options: +// - WithCloseSyncMode: This is ignored by stored on the LocalContainer for test assertions. +// - WithFileAccessMode: Sets the access mode for the file. If opened with Write, it will also be append. +// - WithCreateFile: Creates the file if it does not exist. If the file does exists, it is truncated. +func (c *LocalContainer) OpenFile(ctx context.Context, name string, options ...storage.Option) (storage.File, error) { + const op = "fstest.(LocalContainer).OpenFile" + c.Lock() + defer c.Unlock() + if c.closed { + return nil, fmt.Errorf("%s: container closed: %w", op, ErrClosed) + } + if name == "" { + return nil, fmt.Errorf("%s: missing name", op) + } + path, err := joinPath(c.Path, name) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + opts := storage.GetOpts(options...) + + if opts.WithCreateFile && opts.WithFileAccessMode == storage.ReadOnly { + return nil, fmt.Errorf("cannot create file in read-only mode: %w", ErrReadOnly) + } + + var fileFlag int + switch opts.WithFileAccessMode { + case storage.WriteOnly: + fileFlag = os.O_WRONLY + case storage.ReadWrite: + fileFlag = os.O_RDWR + default: + fileFlag = os.O_RDONLY + } + + switch { + case opts.WithCreateFile: + fileFlag |= os.O_CREATE | os.O_TRUNC + default: + fileFlag |= os.O_APPEND + } + + file, err := os.OpenFile(path, fileFlag, defaultFilePerm) + if err != nil { + return nil, fmt.Errorf("%s: %w: %w", op, err, ErrDoesNotExist) + } + + fi, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + if fi.IsDir() { + return nil, fmt.Errorf("%s: file is a directory", op) + } + + return &LocalFile{ + ctx: ctx, + File: file, + Path: path, + SyncMode: opts.WithCloseSyncMode, + AccessMode: opts.WithFileAccessMode, + }, nil +} + +// SubContainer creates or opens a container in this container. +func (c *LocalContainer) SubContainer(_ context.Context, name string, options ...storage.Option) (storage.Container, error) { + const op = "fstest.(LocalContainer).SubContainer" + c.Lock() + defer c.Unlock() + if c.closed { + return nil, fmt.Errorf("%s: container closed: %w", op, ErrClosed) + } + if name == "" { + return nil, fmt.Errorf("%s: missing name", op) + } + opts := storage.GetOpts(options...) + if opts.WithCreateFile && opts.WithFileAccessMode == storage.ReadOnly { + return nil, fmt.Errorf("%s: cannot create container in read-only mode: %w", op, ErrReadOnly) + } + + path, err := joinPath(c.Path, name) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + if opts.WithCreateFile { + if err := os.Mkdir(path, defaultContainerPerm); err != nil { + return nil, fmt.Errorf("%s: %w: %w", op, err, ErrAlreadyExists) + } + } else { + stat, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("%s: %w: %w", op, err, ErrDoesNotExist) + } + + if !stat.IsDir() { + return nil, fmt.Errorf("%s: %w: %w", op, err, ErrDoesNotExist) + } + } + + return &LocalContainer{ + Path: path, + }, nil +} + +// LocalFile is a storage.File backed by a os.File. +type LocalFile struct { + ctx context.Context + + Path string + File *os.File + SyncMode storage.SyncMode + AccessMode storage.AccessMode + closed bool + + sync.RWMutex +} + +// Stat returns the FileInfo structure describing file. +func (f *LocalFile) Stat() (fs.FileInfo, error) { + const op = "fstest.(LocalFile).Stat" + f.RLock() + defer f.RUnlock() + if f.closed { + return nil, fmt.Errorf("%s: file is closed", op) + } + + fi, err := f.File.Stat() + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + return fi, nil +} + +func (f *LocalFile) Read(b []byte) (int, error) { + const op = "fstest.(localFile).Read" + f.RLock() + defer f.RUnlock() + if f.closed { + return 0, fmt.Errorf("%s: file is closed", op) + } + if f.AccessMode == storage.WriteOnly { + return 0, fmt.Errorf("%s: file is write-only", op) + } + + n, err := f.File.Read(b) + if err != nil { + if err == io.EOF { + return n, err + } + return n, fmt.Errorf("%s: %w", op, err) + } + return n, nil +} + +// Close closes the file preventing reads or writes. +func (f *LocalFile) Close() error { + const op = "fstest.(LocalFile).Stat" + f.Lock() + defer f.Unlock() + if f.closed { + return nil + } + + if err := f.File.Close(); err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + f.closed = true + return nil +} + +func (f *LocalFile) Write(b []byte) (int, error) { + const op = "fstest.(localFile).Write" + f.Lock() + defer f.Unlock() + if f.closed { + return 0, fmt.Errorf("%s: file is closed", op) + } + if f.AccessMode == storage.ReadOnly { + return 0, fmt.Errorf("%s: file is read-only", op) + } + + n, err := f.File.Write(b) + if err != nil { + return 0, fmt.Errorf("%s: %w", op, err) + } + return n, nil +} + +// WriteString writes a string to the file. +func (f *LocalFile) WriteString(s string) (int, error) { + const op = "storage.(localFile).WriteString" + return f.Write([]byte(s)) +}