diff --git a/internal/filer_test.go b/internal/filer_test.go index 3042693ae..f69ee5475 100644 --- a/internal/filer_test.go +++ b/internal/filer_test.go @@ -31,6 +31,8 @@ func (f filerTest) assertContents(ctx context.Context, name string, contents str return } + defer reader.Close() + var body bytes.Buffer _, err = io.Copy(&body, reader) if !assert.NoError(f, err) { @@ -309,3 +311,20 @@ func TestAccFilerDbfsReadDir(t *testing.T) { ctx, f := setupFilerDbfsTest(t) runFilerReadDirTest(t, ctx, f) } + +func setupFilerLocalTest(t *testing.T) (context.Context, filer.Filer) { + ctx := context.Background() + f, err := filer.NewLocalClient(t.TempDir()) + require.NoError(t, err) + return ctx, f +} + +func TestAccFilerLocalReadWrite(t *testing.T) { + ctx, f := setupFilerLocalTest(t) + runFilerReadWriteTest(t, ctx, f) +} + +func TestAccFilerLocalReadDir(t *testing.T) { + ctx, f := setupFilerLocalTest(t) + runFilerReadDirTest(t, ctx, f) +} diff --git a/libs/filer/dbfs_client.go b/libs/filer/dbfs_client.go index dbf3cf60b..85f87ff5e 100644 --- a/libs/filer/dbfs_client.go +++ b/libs/filer/dbfs_client.go @@ -139,7 +139,7 @@ func (w *DbfsClient) Write(ctx context.Context, name string, reader io.Reader, m return err } -func (w *DbfsClient) Read(ctx context.Context, name string) (io.Reader, error) { +func (w *DbfsClient) Read(ctx context.Context, name string) (io.ReadCloser, error) { absPath, err := w.root.Join(name) if err != nil { return nil, err @@ -159,7 +159,13 @@ func (w *DbfsClient) Read(ctx context.Context, name string) (io.Reader, error) { return nil, NotAFile{absPath} } - return w.workspaceClient.Dbfs.Open(ctx, absPath, files.FileModeRead) + handle, err := w.workspaceClient.Dbfs.Open(ctx, absPath, files.FileModeRead) + if err != nil { + return nil, err + } + + // A DBFS handle open for reading does not need to be closed. + return io.NopCloser(handle), nil } func (w *DbfsClient) Delete(ctx context.Context, name string, mode ...DeleteMode) error { diff --git a/libs/filer/filer.go b/libs/filer/filer.go index 1c5ec2b9d..8267dc343 100644 --- a/libs/filer/filer.go +++ b/libs/filer/filer.go @@ -111,7 +111,7 @@ type Filer interface { Write(ctx context.Context, path string, reader io.Reader, mode ...WriteMode) error // Read file at `path`. - Read(ctx context.Context, path string) (io.Reader, error) + Read(ctx context.Context, path string) (io.ReadCloser, error) // Delete file or directory at `path`. Delete(ctx context.Context, path string, mode ...DeleteMode) error diff --git a/libs/filer/fs_test.go b/libs/filer/fs_test.go index 10099f4a4..03ed312b4 100644 --- a/libs/filer/fs_test.go +++ b/libs/filer/fs_test.go @@ -70,13 +70,13 @@ func (f *fakeFiler) Write(ctx context.Context, p string, reader io.Reader, mode return fmt.Errorf("not implemented") } -func (f *fakeFiler) Read(ctx context.Context, p string) (io.Reader, error) { +func (f *fakeFiler) Read(ctx context.Context, p string) (io.ReadCloser, error) { _, ok := f.entries[p] if !ok { return nil, fs.ErrNotExist } - return strings.NewReader("foo"), nil + return io.NopCloser(strings.NewReader("foo")), nil } func (f *fakeFiler) Delete(ctx context.Context, p string, mode ...DeleteMode) error { diff --git a/libs/filer/local_client.go b/libs/filer/local_client.go new file mode 100644 index 000000000..c777b13e0 --- /dev/null +++ b/libs/filer/local_client.go @@ -0,0 +1,174 @@ +package filer + +import ( + "context" + "io" + "io/fs" + "os" + "path/filepath" + + "golang.org/x/exp/slices" +) + +// LocalClient implements the [Filer] interface for the local filesystem. +type LocalClient struct { + // File operations will be relative to this path. + root RootPath +} + +func NewLocalClient(root string) (Filer, error) { + return &LocalClient{ + root: NewRootPath(root), + }, nil +} + +func (w *LocalClient) Write(ctx context.Context, name string, reader io.Reader, mode ...WriteMode) error { + absPath, err := w.root.Join(name) + if err != nil { + return err + } + + flags := os.O_WRONLY | os.O_CREATE + if slices.Contains(mode, OverwriteIfExists) { + flags |= os.O_TRUNC + } else { + flags |= os.O_EXCL + } + + absPath = filepath.FromSlash(absPath) + f, err := os.OpenFile(absPath, flags, 0644) + if os.IsNotExist(err) && slices.Contains(mode, CreateParentDirectories) { + // Create parent directories if they don't exist. + err = os.MkdirAll(filepath.Dir(absPath), 0755) + if err != nil { + return err + } + // Try again. + f, err = os.OpenFile(absPath, flags, 0644) + } + + if err != nil { + switch { + case os.IsNotExist(err): + return NoSuchDirectoryError{path: absPath} + case os.IsExist(err): + return FileAlreadyExistsError{path: absPath} + default: + return err + } + } + + _, err = io.Copy(f, reader) + cerr := f.Close() + if err == nil { + err = cerr + } + + return err + +} + +func (w *LocalClient) Read(ctx context.Context, name string) (io.ReadCloser, error) { + absPath, err := w.root.Join(name) + if err != nil { + return nil, err + } + + // This stat call serves two purposes: + // 1. Checks file at path exists, and throws an error if it does not + // 2. Allows us to error out if the path is a directory + absPath = filepath.FromSlash(absPath) + stat, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return nil, FileDoesNotExistError{path: absPath} + } + return nil, err + } + + if stat.IsDir() { + return nil, NotAFile{path: absPath} + } + + return os.Open(absPath) +} + +func (w *LocalClient) Delete(ctx context.Context, name string, mode ...DeleteMode) error { + absPath, err := w.root.Join(name) + if err != nil { + return err + } + + // Illegal to delete the root path. + if absPath == w.root.rootPath { + return CannotDeleteRootError{} + } + + absPath = filepath.FromSlash(absPath) + err = os.Remove(absPath) + + // Return early on success. + if err == nil { + return nil + } + + if os.IsNotExist(err) { + return FileDoesNotExistError{path: absPath} + } + + if os.IsExist(err) { + if slices.Contains(mode, DeleteRecursively) { + return os.RemoveAll(absPath) + } + return DirectoryNotEmptyError{path: absPath} + } + + return err +} + +func (w *LocalClient) ReadDir(ctx context.Context, name string) ([]fs.DirEntry, error) { + absPath, err := w.root.Join(name) + if err != nil { + return nil, err + } + + absPath = filepath.FromSlash(absPath) + stat, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return nil, NoSuchDirectoryError{path: absPath} + } + return nil, err + } + + if !stat.IsDir() { + return nil, NotADirectory{path: absPath} + } + + return os.ReadDir(absPath) +} + +func (w *LocalClient) Mkdir(ctx context.Context, name string) error { + dirPath, err := w.root.Join(name) + if err != nil { + return err + } + + dirPath = filepath.FromSlash(dirPath) + return os.MkdirAll(dirPath, 0755) +} + +func (w *LocalClient) Stat(ctx context.Context, name string) (fs.FileInfo, error) { + absPath, err := w.root.Join(name) + if err != nil { + return nil, err + } + + absPath = filepath.FromSlash(absPath) + stat, err := os.Stat(absPath) + if os.IsNotExist(err) { + return nil, FileDoesNotExistError{path: absPath} + } + + return stat, err +} diff --git a/libs/filer/workspace_files_client.go b/libs/filer/workspace_files_client.go index 4d310b1a8..427590705 100644 --- a/libs/filer/workspace_files_client.go +++ b/libs/filer/workspace_files_client.go @@ -152,7 +152,7 @@ func (w *WorkspaceFilesClient) Write(ctx context.Context, name string, reader io return err } -func (w *WorkspaceFilesClient) Read(ctx context.Context, name string) (io.Reader, error) { +func (w *WorkspaceFilesClient) Read(ctx context.Context, name string) (io.ReadCloser, error) { absPath, err := w.root.Join(name) if err != nil { return nil, err @@ -184,7 +184,7 @@ func (w *WorkspaceFilesClient) Read(ctx context.Context, name string) (io.Reader if err != nil { return nil, err } - return bytes.NewReader(b), nil + return io.NopCloser(bytes.NewReader(b)), nil } func (w *WorkspaceFilesClient) Delete(ctx context.Context, name string, mode ...DeleteMode) error {