From c0d4342154535ba4578ebf5004dc060bdc38cf30 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Sun, 7 Apr 2024 23:30:55 +0100 Subject: [PATCH] Add embedfs to represent embed.FS as billy.Filesystem This representation is mostly useful within the context of go-git-fixtures. go-billy Filesystem interface is too generic, which makes this implementation violate the Liskov Principle. When more narrow representations of the filesystem are available in go-billy, this could be moved upstream. Signed-off-by: Paulo Gomes --- internal/embedfs/embed.go | 208 +++++++++++++++++ internal/embedfs/embed_test.go | 322 +++++++++++++++++++++++++++ internal/embedfs/testdata/empty.txt | 0 internal/embedfs/testdata/empty2.txt | 1 + 4 files changed, 531 insertions(+) create mode 100644 internal/embedfs/embed.go create mode 100644 internal/embedfs/embed_test.go create mode 100644 internal/embedfs/testdata/empty.txt create mode 100644 internal/embedfs/testdata/empty2.txt diff --git a/internal/embedfs/embed.go b/internal/embedfs/embed.go new file mode 100644 index 0000000..1482d4f --- /dev/null +++ b/internal/embedfs/embed.go @@ -0,0 +1,208 @@ +// embedfs exposes an embed.FS as a read-only billy.Filesystem. +package embedfs + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/helper/chroot" + "github.com/go-git/go-billy/v5/memfs" +) + +type Embed struct { + underlying *embed.FS +} + +func New(efs *embed.FS, path string) billy.Filesystem { + fs := &Embed{ + underlying: efs, + } + + if efs == nil { + fs.underlying = &embed.FS{} + } + + return chroot.New(fs, path) +} + +func (fs *Embed) Stat(filename string) (os.FileInfo, error) { + f, err := fs.underlying.Open(filename) + if err != nil { + return nil, err + } + return f.Stat() +} + +func (fs *Embed) Open(filename string) (billy.File, error) { + return fs.OpenFile(filename, os.O_RDONLY, 0) +} + +func (fs *Embed) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) { + if flag&(os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_RDWR|os.O_EXCL|os.O_TRUNC) != 0 { + return nil, billy.ErrReadOnly + } + + f, err := fs.underlying.Open(filename) + if err != nil { + return nil, err + } + + fi, err := f.Stat() + if err != nil { + return nil, err + } + + if fi.IsDir() { + return nil, fmt.Errorf("cannot open directory: %s", filename) + } + + data, err := fs.underlying.ReadFile(filename) + if err != nil { + return nil, err + } + + // Only load the bytes to memory if the files is needed. + lazyFunc := func() *bytes.Reader { return bytes.NewReader(data) } + return toFile(lazyFunc, fi), nil +} + +// TODO: use memfs instead +func (fs *Embed) Join(elem ...string) string { + // Function adapted from Go's filepath.Join for unix: + // https://github.com/golang/go/blob/1ed85ee228023d766b37db056311929c00091c9f/src/path/filepath/path_unix.go#L45 + for i, el := range elem { + if el != "" { + // reuses filepath.Clean, as it is OS agnostic. + return filepath.Clean(strings.Join(elem[i:], "/")) + } + } + return "" +} + +func (fs *Embed) ReadDir(path string) ([]os.FileInfo, error) { + e, err := fs.underlying.ReadDir(path) + if err != nil { + return nil, err + } + + var entries []os.FileInfo + for _, f := range e { + fi, _ := f.Info() + entries = append(entries, fi) + } + + sort.Sort(memfs.ByName(entries)) + + return entries, nil +} + +// Create is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) Create(filename string) (billy.File, error) { + return nil, billy.ErrReadOnly +} + +// Rename is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) Rename(from, to string) error { + return billy.ErrReadOnly +} + +// Remove is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) Remove(filename string) error { + return billy.ErrReadOnly +} + +// MkdirAll is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) MkdirAll(filename string, perm os.FileMode) error { + return billy.ErrReadOnly +} + +func toFile(lazy func() *bytes.Reader, fi fs.FileInfo) billy.File { + new := &file{ + lazy: lazy, + fi: fi, + } + + return new +} + +type file struct { + lazy func() *bytes.Reader + reader *bytes.Reader + fi fs.FileInfo + once sync.Once +} + +func (f *file) loadReader() { + f.reader = f.lazy() +} + +func (f *file) Name() string { + return f.fi.Name() +} + +func (f *file) Read(b []byte) (int, error) { + f.once.Do(f.loadReader) + + return f.reader.Read(b) +} + +func (f *file) ReadAt(b []byte, off int64) (int, error) { + f.once.Do(f.loadReader) + + return f.reader.ReadAt(b, off) +} + +func (f *file) Seek(offset int64, whence int) (int64, error) { + f.once.Do(f.loadReader) + + return f.reader.Seek(offset, whence) +} + +func (f *file) Stat() (os.FileInfo, error) { + return f.fi, nil +} + +// Close for embedfs file is a no-op. +func (f *file) Close() error { + return nil +} + +// Lock for embedfs file is a no-op. +func (f *file) Lock() error { + return nil +} + +// Unlock for embedfs file is a no-op. +func (f *file) Unlock() error { + return nil +} + +// Truncate is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (f *file) Truncate(size int64) error { + return billy.ErrReadOnly +} + +// Write is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (f *file) Write(p []byte) (int, error) { + return 0, billy.ErrReadOnly +} diff --git a/internal/embedfs/embed_test.go b/internal/embedfs/embed_test.go new file mode 100644 index 0000000..7e1dec5 --- /dev/null +++ b/internal/embedfs/embed_test.go @@ -0,0 +1,322 @@ +package embedfs + +import ( + "embed" + "io" + "os" + "testing" + + "github.com/go-git/go-billy/v5" + "github.com/stretchr/testify/assert" +) + +//go:embed testdata/empty.txt +var singleFile embed.FS + +//go:embed testdata +var testdataDir embed.FS + +var empty embed.FS + +func TestOpen(t *testing.T) { + tests := []struct { + name string + want []byte + wantErr bool + }{ + { + name: "empty.txt", + want: []byte(""), + }, + { + name: "empty2.txt", + want: []byte("test\n"), + }, + { + name: "non-existent", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := New(&testdataDir, "testdata") + + var got []byte + f, err := fs.Open(tc.name) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + got, err = io.ReadAll(f) + assert.NoError(t, err) + } + + assert.Equal(t, got, tc.want) + }) + } +} + +func TestOpenFileFlags(t *testing.T) { + tests := []struct { + name string + file string + flag int + wantErr string + }{ + { + name: "O_CREATE", + file: "empty.txt", + flag: os.O_CREATE, + wantErr: "read-only filesystem", + }, + { + name: "O_WRONLY", + file: "empty.txt", + flag: os.O_WRONLY, + wantErr: "read-only filesystem", + }, + { + name: "O_TRUNC", + file: "empty.txt", + flag: os.O_TRUNC, + wantErr: "read-only filesystem", + }, + { + name: "O_RDWR", + file: "empty.txt", + flag: os.O_RDWR, + wantErr: "read-only filesystem", + }, + { + name: "O_EXCL", + file: "empty.txt", + flag: os.O_EXCL, + wantErr: "read-only filesystem", + }, + { + name: "O_RDONLY", + file: "empty.txt", + flag: os.O_RDONLY, + }, + { + name: "no flags", + file: "empty.txt", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := New(&testdataDir, "testdata") + + _, err := fs.OpenFile(tc.file, tc.flag, 0o700) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestStat(t *testing.T) { + tests := []struct { + name string + want string + isDir bool + wantErr bool + }{ + { + name: "testdata/empty.txt", + want: "empty.txt", + }, + { + name: "testdata/empty2.txt", + want: "empty2.txt", + }, + { + name: "non-existent", + wantErr: true, + }, + { + name: "testdata/", + want: "testdata", + isDir: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := New(&testdataDir, "") + + fi, err := fs.Stat(tc.name) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + assert.Equal(t, tc.want, fi.Name()) + assert.Equal(t, tc.isDir, fi.IsDir()) + } + }) + } +} + +func TestReadDir(t *testing.T) { + tests := []struct { + name string + chroot string + path string + fs embed.FS + want []string + wantErr bool + }{ + { + name: "singleFile w/ chroot", + chroot: "testdata/", + path: "", + fs: singleFile, + want: []string{"empty.txt"}, + }, + { + name: "singleFile w/o chroot", + chroot: "", + path: "testdata", + fs: singleFile, + want: []string{"empty.txt"}, + }, + { + name: "singleFile return no dir names", + chroot: "", + path: "", + fs: singleFile, + want: []string{}, + wantErr: true, + }, + { + name: "empty", + chroot: "", + path: "", + fs: empty, + want: []string{}, + wantErr: true, + }, + + { + name: "testdataDir w/ chroot", + chroot: "testdata", + path: "", + fs: testdataDir, + want: []string{"empty.txt", "empty2.txt"}, + }, + { + name: "testdataDir w/o chroot", + chroot: "", + path: "testdata", + fs: testdataDir, + want: []string{"empty.txt", "empty2.txt"}, + }, + { + name: "testdataDir return no dir names", + chroot: "", + path: "", + fs: testdataDir, + want: []string{}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := New(&tc.fs, tc.chroot) + + fis, err := fs.ReadDir(tc.path) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Len(t, fis, len(tc.want)) + matched := 0 + + for _, n := range fis { + for _, w := range tc.want { + if n.Name() == w { + matched++ + } + } + } + + assert.Equal(t, len(tc.want), matched, "not all files matched") + }) + } +} + +func TestUnsupported(t *testing.T) { + fs := New(&testdataDir, "") + + _, err := fs.Create("test") + assert.ErrorIs(t, err, billy.ErrReadOnly) + + err = fs.Remove("test") + assert.ErrorIs(t, err, billy.ErrReadOnly) + + err = fs.Rename("test", "test") + assert.ErrorIs(t, err, billy.ErrReadOnly) + + err = fs.MkdirAll("test", 0o700) + assert.ErrorIs(t, err, billy.ErrReadOnly) +} + +func TestFileUnsupported(t *testing.T) { + fs := New(&testdataDir, "testdata") + + f, err := fs.Open("empty.txt") + assert.NoError(t, err) + assert.NotNil(t, f) + + _, err = f.Write([]byte("foo")) + assert.ErrorIs(t, err, billy.ErrReadOnly) + + err = f.Truncate(0) + assert.ErrorIs(t, err, billy.ErrReadOnly) +} + +func TestFileSeek(t *testing.T) { + fs := New(&testdataDir, "testdata") + + f, err := fs.Open("empty2.txt") + assert.NoError(t, err) + assert.NotNil(t, f) + + tests := []struct { + seekOff int64 + seekWhence int + want string + }{ + {seekOff: 4, seekWhence: io.SeekStart, want: "\n"}, + {seekOff: 3, seekWhence: io.SeekStart, want: "t\n"}, + {seekOff: 2, seekWhence: io.SeekStart, want: "st\n"}, + {seekOff: 1, seekWhence: io.SeekStart, want: "est\n"}, + {seekOff: 0, seekWhence: io.SeekStart, want: "test\n"}, + {seekOff: 0, seekWhence: io.SeekStart, want: "t"}, + {seekOff: 1, seekWhence: io.SeekCurrent, want: "st\n"}, + {seekOff: -3, seekWhence: io.SeekEnd, want: "st\n"}, + } + + for _, tc := range tests { + t.Run("", func(t *testing.T) { + + _, err = f.Seek(tc.seekOff, tc.seekWhence) + assert.NoError(t, err) + + data := make([]byte, len(tc.want)) + n, err := f.Read(data) + assert.NoError(t, err) + assert.Equal(t, len(tc.want), n) + assert.Equal(t, []byte(tc.want), data) + }) + } +} diff --git a/internal/embedfs/testdata/empty.txt b/internal/embedfs/testdata/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/internal/embedfs/testdata/empty2.txt b/internal/embedfs/testdata/empty2.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/internal/embedfs/testdata/empty2.txt @@ -0,0 +1 @@ +test