diff --git a/embedfs/embed.go b/embedfs/embed.go new file mode 100644 index 0000000..d0b7c8c --- /dev/null +++ b/embedfs/embed.go @@ -0,0 +1,251 @@ +// 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/v6" + "github.com/go-git/go-billy/v6/memfs" +) + +type Embed struct { + underlying *embed.FS +} + +func New(efs *embed.FS) billy.Filesystem { + fs := &Embed{ + underlying: efs, + } + + if efs == nil { + fs.underlying = &embed.FS{} + } + + return fs +} + +func (fs *Embed) Root() string { + return "" +} + +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, _ 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 +} + +// Join return a path with all elements joined by forward slashes. +// +// This behaviour is OS-agnostic. +func (fs *Embed) Join(elem ...string) string { + for i, el := range elem { + if el != "" { + clean := filepath.Clean(strings.Join(elem[i:], "/")) + return filepath.ToSlash(clean) + } + } + return "" +} + +func (fs *Embed) ReadDir(path string) ([]os.FileInfo, error) { + e, err := fs.underlying.ReadDir(path) + if err != nil { + return nil, err + } + + entries := make([]os.FileInfo, 0, len(e)) + for _, f := range e { + fi, _ := f.Info() + entries = append(entries, fi) + } + + sort.Sort(memfs.ByName(entries)) + + return entries, nil +} + +// Chroot is not supported. +// +// Calls will always return billy.ErrNotSupported. +func (fs *Embed) Chroot(_ string) (billy.Filesystem, error) { + return nil, billy.ErrNotSupported +} + +// Lstat is not supported. +// +// Calls will always return billy.ErrNotSupported. +func (fs *Embed) Lstat(_ string) (os.FileInfo, error) { + return nil, billy.ErrNotSupported +} + +// Readlink is not supported. +// +// Calls will always return billy.ErrNotSupported. +func (fs *Embed) Readlink(_ string) (string, error) { + return "", billy.ErrNotSupported +} + +// TempFile is not supported. +// +// Calls will always return billy.ErrNotSupported. +func (fs *Embed) TempFile(_, _ string) (billy.File, error) { + return nil, billy.ErrNotSupported +} + +// Symlink is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) Symlink(_, _ string) error { + return billy.ErrReadOnly +} + +// Create is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) Create(_ string) (billy.File, error) { + return nil, billy.ErrReadOnly +} + +// Rename is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) Rename(_, _ string) error { + return billy.ErrReadOnly +} + +// Remove is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) Remove(_ string) error { + return billy.ErrReadOnly +} + +// MkdirAll is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (fs *Embed) MkdirAll(_ string, _ os.FileMode) error { + return billy.ErrReadOnly +} + +func toFile(lazy func() *bytes.Reader, fi fs.FileInfo) billy.File { + return &file{ + lazy: lazy, + fi: fi, + } +} + +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(_ int64) error { + return billy.ErrReadOnly +} + +// Write is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (f *file) Write(_ []byte) (int, error) { + return 0, billy.ErrReadOnly +} + +// WriteAt is not supported. +// +// Calls will always return billy.ErrReadOnly. +func (f *file) WriteAt([]byte, int64) (int, error) { + return 0, billy.ErrReadOnly +} diff --git a/embedfs/embed_test.go b/embedfs/embed_test.go new file mode 100644 index 0000000..2501afe --- /dev/null +++ b/embedfs/embed_test.go @@ -0,0 +1,347 @@ +package embedfs + +import ( + "embed" + "fmt" + "io" + "os" + "testing" + + "github.com/go-git/go-billy/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//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) { + t.Parallel() + + tests := []struct { + name string + want []byte + wantErr bool + }{ + { + name: "testdata/empty.txt", + want: []byte(""), + }, + { + name: "testdata/empty2.txt", + want: []byte("test"), + }, + { + name: "non-existent", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := New(&testdataDir) + + var got []byte + f, err := fs.Open(tc.name) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.NotNil(t, f) + + got, err = io.ReadAll(f) + require.NoError(t, err) + } + + assert.Equal(t, tc.want, got) + }) + } +} + +func TestOpenFileFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + flag int + wantErr string + }{ + { + name: "O_CREATE", + file: "testdata/empty.txt", + flag: os.O_CREATE, + wantErr: "read-only filesystem", + }, + { + name: "O_WRONLY", + file: "testdata/empty.txt", + flag: os.O_WRONLY, + wantErr: "read-only filesystem", + }, + { + name: "O_TRUNC", + file: "testdata/empty.txt", + flag: os.O_TRUNC, + wantErr: "read-only filesystem", + }, + { + name: "O_RDWR", + file: "testdata/empty.txt", + flag: os.O_RDWR, + wantErr: "read-only filesystem", + }, + { + name: "O_EXCL", + file: "testdata/empty.txt", + flag: os.O_EXCL, + wantErr: "read-only filesystem", + }, + { + name: "O_RDONLY", + file: "testdata/empty.txt", + flag: os.O_RDONLY, + }, + { + name: "no flags", + file: "testdata/empty.txt", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := New(&testdataDir) + + _, err := fs.OpenFile(tc.file, tc.flag, 0o700) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestStat(t *testing.T) { + t.Parallel() + + 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 { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.NotNil(t, fi) + + assert.Equal(t, tc.want, fi.Name()) + assert.Equal(t, tc.isDir, fi.IsDir()) + } + }) + } +} + +func TestReadDir(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + fs *embed.FS + want []string + wantErr bool + }{ + { + name: "singleFile", + path: "testdata", + fs: &singleFile, + want: []string{"empty.txt"}, + }, + { + name: "empty", + path: "", + fs: &empty, + want: []string{}, + wantErr: true, + }, + { + name: "testdataDir w/ path", + path: "testdata", + fs: &testdataDir, + want: []string{"empty.txt", "empty2.txt"}, + }, + { + name: "testdataDir return no dir names", + path: "", + fs: &testdataDir, + want: []string{}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := New(tc.fs) + + fis, err := fs.ReadDir(tc.path) + if tc.wantErr { + require.Error(t, err) + } else { + require.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) { + t.Parallel() + + fs := New(&testdataDir) + + _, err := fs.Create("test") + require.ErrorIs(t, err, billy.ErrReadOnly) + + err = fs.Remove("test") + require.ErrorIs(t, err, billy.ErrReadOnly) + + err = fs.Rename("test", "test") + require.ErrorIs(t, err, billy.ErrReadOnly) + + err = fs.MkdirAll("test", 0o700) + require.ErrorIs(t, err, billy.ErrReadOnly) +} + +func TestFileUnsupported(t *testing.T) { + t.Parallel() + + fs := New(&testdataDir) + + f, err := fs.Open("testdata/empty.txt") + require.NoError(t, err) + assert.NotNil(t, f) + + _, err = f.Write([]byte("foo")) + require.ErrorIs(t, err, billy.ErrReadOnly) + + err = f.Truncate(0) + require.ErrorIs(t, err, billy.ErrReadOnly) +} + +func TestFileSeek(t *testing.T) { + fs := New(&testdataDir) + + f, err := fs.Open("testdata/empty2.txt") + require.NoError(t, err) + assert.NotNil(t, f) + + tests := []struct { + seekOff int64 + seekWhence int + want string + }{ + {seekOff: 3, seekWhence: io.SeekStart, want: ""}, + {seekOff: 3, seekWhence: io.SeekStart, want: "t"}, + {seekOff: 2, seekWhence: io.SeekStart, want: "st"}, + {seekOff: 1, seekWhence: io.SeekStart, want: "est"}, + {seekOff: 0, seekWhence: io.SeekStart, want: "test"}, + {seekOff: 0, seekWhence: io.SeekStart, want: "t"}, + {seekOff: 1, seekWhence: io.SeekCurrent, want: "s"}, + {seekOff: -2, seekWhence: io.SeekEnd, want: "st"}, + } + + for i, tc := range tests { + t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) { + _, err = f.Seek(tc.seekOff, tc.seekWhence) + require.NoError(t, err) + + data := make([]byte, len(tc.want)) + n, err := f.Read(data) + require.NoError(t, err) + assert.Equal(t, len(tc.want), n) + assert.Equal(t, []byte(tc.want), data) + }) + } +} + +func TestJoin(t *testing.T) { + tests := []struct { + name string + path []string + want string + }{ + { + name: "no leading slash", + path: []string{"data", "foo/bar"}, + want: "data/foo/bar", + }, + { + name: "w/ leading slash", + path: []string{"/data", "foo/bar"}, + want: "/data/foo/bar", + }, + { + name: "..", + path: []string{"/data", "../bar"}, + want: "/bar", + }, + { + name: ".", + path: []string{"/data", "./bar"}, + want: "/data/bar", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fs := New(&empty) + + got := fs.Join(tc.path...) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/embedfs/testdata/empty.txt b/embedfs/testdata/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/embedfs/testdata/empty2.txt b/embedfs/testdata/empty2.txt new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/embedfs/testdata/empty2.txt @@ -0,0 +1 @@ +test \ No newline at end of file