diff --git a/Makefile b/Makefile index 5ae635bf..023b6aab 100644 --- a/Makefile +++ b/Makefile @@ -377,6 +377,8 @@ mock: ## Generate all the mocks (for tests) @mockgen -source=x/vesting/types/expected_keepers.go -package testutil -destination x/vesting/testutil/expected_keepers_mocks.go @mockgen -source=x/logic/types/expected_keepers.go -package testutil -destination x/logic/testutil/expected_keepers_mocks.go @mockgen -destination x/logic/testutil/gas_mocks.go -package testutil cosmossdk.io/store/types GasMeter + @mockgen -destination x/logic/testutil/fs_mocks.go -package testutil io/fs FS + @mockgen -destination x/logic/testutil/read_file_fs_mocks.go -package testutil io/fs ReadFileFS ## Release: .PHONY: release-assets diff --git a/app/app.go b/app/app.go index 204276ad..7eaa6b8f 100644 --- a/app/app.go +++ b/app/app.go @@ -139,7 +139,8 @@ import ( axonewasm "github.com/axone-protocol/axoned/v8/app/wasm" "github.com/axone-protocol/axoned/v8/docs" logicmodule "github.com/axone-protocol/axoned/v8/x/logic" - logicfs "github.com/axone-protocol/axoned/v8/x/logic/fs" + "github.com/axone-protocol/axoned/v8/x/logic/fs/composite" + wasm2 "github.com/axone-protocol/axoned/v8/x/logic/fs/wasm" logicmodulekeeper "github.com/axone-protocol/axoned/v8/x/logic/keeper" logicmoduletypes "github.com/axone-protocol/axoned/v8/x/logic/types" "github.com/axone-protocol/axoned/v8/x/mint" @@ -1161,6 +1162,9 @@ func (app *App) SimulationManager() *module.SimulationManager { // provideFS is used to provide the virtual file system used for the logic module // to load external file. func (app *App) provideFS(ctx context.Context) fs.FS { - wasmHandler := logicfs.NewWasmHandler(app.WasmKeeper) - return logicfs.NewVirtualFS(ctx, []logicfs.URIHandler{wasmHandler}) + vfs := composite.NewFS() + + vfs.Mount(wasm2.Scheme, wasm2.NewFS(ctx, app.WasmKeeper)) + + return vfs } diff --git a/x/logic/fs/composite/fs.go b/x/logic/fs/composite/fs.go new file mode 100644 index 00000000..6846ba38 --- /dev/null +++ b/x/logic/fs/composite/fs.go @@ -0,0 +1,124 @@ +package composite + +import ( + "errors" + "fmt" + "io" + "io/fs" + "net/url" + "sort" + + "golang.org/x/exp/maps" +) + +type FS interface { + fs.ReadFileFS + + // Mount mounts a filesystem to the given mount point. + // The mount point is the scheme of the URI. + Mount(mountPoint string, fs fs.FS) + // ListMounts returns a list of all mount points in sorted order. + ListMounts() []string +} + +type vfs struct { + mounted map[string]fs.FS +} + +var ( + _ fs.FS = (*vfs)(nil) + _ fs.ReadFileFS = (*vfs)(nil) +) + +func NewFS() FS { + return &vfs{mounted: make(map[string]fs.FS)} +} + +func (f *vfs) Open(name string) (fs.File, error) { + uri, err := f.validatePath(name) + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + + vfs, err := f.resolve(uri) + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + return vfs.Open(name) +} + +func (f *vfs) ReadFile(name string) ([]byte, error) { + uri, err := f.validatePath(name) + if err != nil { + return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid} + } + + vfs, err := f.resolve(uri) + if err != nil { + return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist} + } + + if vfs, ok := vfs.(fs.ReadFileFS); ok { + content, err := vfs.ReadFile(name) + if err != nil { + return nil, &fs.PathError{Op: "readfile", Path: name, Err: getUnderlyingError(err)} + } + + return content, nil + } + + file, err := vfs.Open(name) + if err != nil { + return nil, &fs.PathError{Op: "readfile", Path: name, Err: getUnderlyingError(err)} + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return nil, &fs.PathError{Op: "readfile", Path: name, Err: getUnderlyingError(err)} + } + + return content, nil +} + +func (f *vfs) Mount(mountPoint string, fs fs.FS) { + f.mounted[mountPoint] = fs +} + +func (f *vfs) ListMounts() []string { + mounts := maps.Keys(f.mounted) + sort.Strings(mounts) + + return mounts +} + +// validatePath checks if the provided path is a valid URL and returns its URI. +func (f *vfs) validatePath(name string) (*url.URL, error) { + uri, err := url.Parse(name) + if err != nil { + return nil, err + } + if uri.Scheme == "" { + return nil, fmt.Errorf("missing scheme in path: %s", name) + } + return uri, nil +} + +// resolve returns the filesystem mounted at the given URI scheme. +func (f *vfs) resolve(uri *url.URL) (fs.FS, error) { + vfs, ok := f.mounted[uri.Scheme] + if !ok { + return nil, fmt.Errorf("no filesystem mounted at: %s", uri.Scheme) + } + return vfs, nil +} + +// getUnderlyingError returns the underlying error of a path error. +// If it's not a path error it returns the error itself. +func getUnderlyingError(err error) error { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + return pathErr.Err + } + return err +} diff --git a/x/logic/fs/composite/fs_test.go b/x/logic/fs/composite/fs_test.go new file mode 100644 index 00000000..7ec6a69a --- /dev/null +++ b/x/logic/fs/composite/fs_test.go @@ -0,0 +1,265 @@ +package composite + +import ( + "errors" + "fmt" + "io/fs" + "sort" + "testing" + "time" + + "github.com/golang/mock/gomock" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/axone-protocol/axoned/v8/x/logic/fs/wasm" + "github.com/axone-protocol/axoned/v8/x/logic/testutil" +) + +type fileSpec struct { + name string + content []byte + modTime time.Time + isCorrupted bool +} + +var ( + vfs1File1 = fileSpec{ + name: "vfs1:///file1", + content: []byte("vfs1-file1"), + modTime: time.Unix(1681389446, 0), + } + + vfs1File2 = fileSpec{ + name: "vfs1:///file2", + content: []byte("vfs1-file2"), + modTime: time.Unix(1681389446, 0), + } + + vfs2File1 = fileSpec{ + name: "vfs2:///file1", + content: []byte("vfs1-file1"), + modTime: time.Unix(1681389446, 0), + isCorrupted: true, + } + + vfs2File2 = fileSpec{ + name: "vfs2:///file2", + content: []byte("vfs1-file2"), + modTime: time.Unix(1681389446, 0), + } +) + +type fsType int + +const ( + fsTypeFile = iota + fsTypeReadFile +) + +//nolint:gocognit +func TestFilteredVFS(t *testing.T) { + Convey("Given test cases", t, func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cases := []struct { + files map[string][]fileSpec + file string + fsType fsType + wantError string + want *fileSpec + }{ + { + files: map[string][]fileSpec{"vfs1": {vfs1File1, vfs1File2}}, + file: "vfs1:///file1", + wantError: "", + want: &vfs1File1, + }, + { + files: map[string][]fileSpec{"vfs1": {vfs1File1, vfs1File2}}, + file: "vfs1:///file1", + fsType: fsTypeReadFile, + wantError: "", + want: &vfs1File1, + }, + { + files: map[string][]fileSpec{"vfs1": {vfs1File1, vfs1File2}, "vfs2": {vfs2File2}}, + file: "vfs2:///file2", + wantError: "", + want: &vfs2File2, + }, + { + files: map[string][]fileSpec{"vfs1": {vfs1File1, vfs1File2}}, + file: "vfs3:///file1", + wantError: "vfs3:///file1: file does not exist", + want: nil, + }, + { + files: map[string][]fileSpec{"vfs1": {vfs1File1, vfs1File2, vfs2File2}}, + file: "vfs3:///file1", + wantError: "vfs3:///file1: file does not exist", + want: nil, + }, + { + files: map[string][]fileSpec{}, + file: "% %", + wantError: "% %: invalid argument", + want: nil, + }, + { + files: map[string][]fileSpec{}, + file: "foo", + wantError: "foo: invalid argument", + want: nil, + }, + { + files: map[string][]fileSpec{"vfs2": {vfs2File1}}, + file: "vfs2:///file1", + fsType: fsTypeReadFile, + wantError: "vfs2:///file1: file is corrupted", + want: nil, + }, + { + files: map[string][]fileSpec{"vfs2": {vfs2File1}}, + file: "vfs2:///file1", + fsType: fsTypeFile, + wantError: "vfs2:///file1: file is corrupted", + want: nil, + }, + } + + for nc, tc := range cases { + Convey(fmt.Sprintf("Given the test case #%d - file %s", nc, tc.file), func() { + Convey("and a bunch of mocked file systems", func() { + vfss := make(map[string]fs.FS) + for k, files := range tc.files { + switch tc.fsType { + case fsTypeFile: + vfs := testutil.NewMockFS(ctrl) + for _, f := range files { + registerFileToFS(vfs, f.name, f.content, f.modTime, f.isCorrupted) + } + vfss[k] = vfs + case fsTypeReadFile: + vfs := testutil.NewMockReadFileFS(ctrl) + for _, f := range files { + registerFileToReadFileFS(vfs, f.name, f.content, f.modTime, f.isCorrupted) + } + vfss[k] = vfs + default: + t.Error("Unsupported fs type") + } + } + + Convey("and a composite file system under test", func() { + compositeFS := NewFS() + for mountPoint, vfs := range vfss { + compositeFS.Mount(mountPoint, vfs) + } + + Convey(fmt.Sprintf(`when the open("%s") is called`, tc.file), func() { + result, err := compositeFS.Open(tc.file) + + Convey("then the result should be as expected", func() { + if tc.wantError == "" { + So(err, ShouldBeNil) + + stat, _ := result.Stat() + So(stat.Name(), ShouldEqual, tc.file) + So(stat.Size(), ShouldEqual, int64(len(tc.want.content))) + So(stat.ModTime(), ShouldEqual, tc.want.modTime) + So(stat.IsDir(), ShouldBeFalse) + } else { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, fmt.Sprintf("open %s", tc.wantError)) + } + }) + }) + + Convey(fmt.Sprintf(`when the readFile("%s") is called`, tc.file), func() { + result, err := compositeFS.ReadFile(tc.file) + + Convey("Then the result should be as expected", func() { + if tc.wantError == "" { + So(err, ShouldBeNil) + So(result, ShouldResemble, tc.want.content) + } else { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, fmt.Sprintf("readfile %s", tc.wantError)) + } + }) + }) + + Convey(`when the ListMounts() is called`, func() { + result := compositeFS.ListMounts() + + Convey("Then the result should be as expected", func() { + vfssNames := make([]string, 0, len(vfss)) + for k := range vfss { + vfssNames = append(vfssNames, k) + } + sort.Strings(vfssNames) + So(result, ShouldResemble, vfssNames) + }) + }) + }) + }) + }) + } + }) +} + +func registerFileToFS(vfs *testutil.MockFS, name string, content []byte, modTime time.Time, corrupted bool) { + vfs.EXPECT().Open(name).AnyTimes(). + DoAndReturn(func(file string) (fs.File, error) { + if corrupted { + return nil, &fs.PathError{Op: "open", Path: name, Err: fmt.Errorf("file is corrupted")} + } + return wasm.NewVirtualFile( + file, + content, + modTime), nil + }) +} + +func registerFileToReadFileFS(vfs *testutil.MockReadFileFS, name string, content []byte, modTime time.Time, corrupted bool) { + vfs.EXPECT().Open(name).AnyTimes(). + DoAndReturn(func(file string) (fs.File, error) { + if corrupted { + return nil, &fs.PathError{Op: "open", Path: name, Err: fmt.Errorf("file is corrupted")} + } + return wasm.NewVirtualFile( + file, + content, + modTime), nil + }) + vfs.EXPECT().ReadFile(name).AnyTimes(). + DoAndReturn(func(_ string) ([]byte, error) { + if corrupted { + return nil, &fs.PathError{Op: "open", Path: name, Err: fmt.Errorf("file is corrupted")} + } + return content, nil + }) +} + +func TestGetUnderlyingError(t *testing.T) { + Convey("Given an error", t, func() { + Convey("when the error is fs.PathError", func() { + underlyingErr := errors.New("underlying error") + pathErr := &fs.PathError{Err: underlyingErr} + + Convey("then getUnderlyingError should return the underlying error", func() { + So(getUnderlyingError(pathErr), ShouldEqual, underlyingErr) + }) + }) + + Convey("when the error is not fs.PathError", func() { + genericErr := errors.New("generic error") + + Convey("then getUnderlyingError should return the error itself", func() { + So(getUnderlyingError(genericErr), ShouldEqual, genericErr) + }) + }) + }) +} diff --git a/x/logic/fs/file.go b/x/logic/fs/file.go deleted file mode 100644 index 1d21954d..00000000 --- a/x/logic/fs/file.go +++ /dev/null @@ -1,72 +0,0 @@ -package fs - -import ( - "bytes" - "io/fs" - "net/url" - "time" -) - -type VirtualFile struct { - reader *bytes.Reader - info *VirtualFileInfo -} - -type VirtualFileInfo struct { - name string - size int64 - modTime time.Time -} - -var ( - _ fs.File = (*VirtualFile)(nil) - _ fs.FileInfo = (*VirtualFileInfo)(nil) -) - -func NewVirtualFile(src []byte, uri *url.URL, modTime time.Time) VirtualFile { - reader := bytes.NewReader(src) - return VirtualFile{ - reader: reader, - info: &VirtualFileInfo{ - name: uri.String(), - size: reader.Size(), - modTime: modTime, - }, - } -} - -func (i VirtualFileInfo) Name() string { - return i.name -} - -func (i VirtualFileInfo) Size() int64 { - return i.size -} - -func (i VirtualFileInfo) Mode() fs.FileMode { - return fs.ModeIrregular -} - -func (i VirtualFileInfo) ModTime() time.Time { - return i.modTime -} - -func (i VirtualFileInfo) IsDir() bool { - return false -} - -func (i VirtualFileInfo) Sys() any { - return nil -} - -func (o VirtualFile) Stat() (fs.FileInfo, error) { - return o.info, nil -} - -func (o VirtualFile) Read(b []byte) (int, error) { - return o.reader.Read(b) -} - -func (o VirtualFile) Close() error { - return nil -} diff --git a/x/logic/fs/filtered/fs.go b/x/logic/fs/filtered/fs.go new file mode 100644 index 00000000..1da6f555 --- /dev/null +++ b/x/logic/fs/filtered/fs.go @@ -0,0 +1,73 @@ +package filtered + +import ( + "io/fs" + "net/url" + + "github.com/axone-protocol/axoned/v8/x/logic/util" +) + +type vfs struct { + fs fs.FS + whitelist []*url.URL + blacklist []*url.URL +} + +var ( + _ fs.FS = (*vfs)(nil) + _ fs.ReadFileFS = (*vfs)(nil) +) + +// NewFS creates a new filtered filesystem that wraps the provided filesystem. +// The whitelist and blacklist are used to filter the paths that can be accessed. +func NewFS(underlyingFS fs.FS, whitelist, blacklist []*url.URL) fs.ReadFileFS { + return &vfs{fs: underlyingFS, whitelist: whitelist, blacklist: blacklist} +} + +func (f *vfs) Open(name string) (fs.File, error) { + if err := f.accept("open", name); err != nil { + return nil, err + } + return f.fs.Open(name) +} + +func (f *vfs) ReadFile(name string) ([]byte, error) { + if err := f.accept("open", name); err != nil { + return nil, err + } + + if vfs, ok := f.fs.(fs.ReadFileFS); ok { + return vfs.ReadFile(name) + } + + return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid} +} + +// validatePath checks if the provided path is a valid URL. +func (f *vfs) validatePath(name string) (*url.URL, error) { + uri, err := url.Parse(name) + if err != nil { + return nil, err + } + + return uri, nil +} + +// accept checks if the provided path is allowed by the whitelist and blacklist. +// If the path is allowed, it returns nil; otherwise, it returns an error. +func (f *vfs) accept(op string, name string) error { + uri, err := f.validatePath(name) + if err != nil { + return &fs.PathError{Op: op, Path: name, Err: fs.ErrInvalid} + } + + if !util.WhitelistBlacklistMatches(f.whitelist, f.blacklist, util.URLMatches)(uri) { + return &fs.PathError{ + Op: op, + Path: name, + Err: fs.ErrPermission, + } + } + + return nil +} diff --git a/x/logic/fs/filtered_fs_test.go b/x/logic/fs/filtered/fs_test.go similarity index 58% rename from x/logic/fs/filtered_fs_test.go rename to x/logic/fs/filtered/fs_test.go index 395c9979..28bd4ffb 100644 --- a/x/logic/fs/filtered_fs_test.go +++ b/x/logic/fs/filtered/fs_test.go @@ -1,9 +1,8 @@ -package fs +package filtered import ( "fmt" "io/fs" - "net/url" "testing" "time" @@ -12,11 +11,12 @@ import ( . "github.com/smartystreets/goconvey/convey" + "github.com/axone-protocol/axoned/v8/x/logic/fs/wasm" "github.com/axone-protocol/axoned/v8/x/logic/testutil" "github.com/axone-protocol/axoned/v8/x/logic/util" ) -func TestSourceFile(t *testing.T) { +func TestFilteredVFS(t *testing.T) { Convey("Given test cases", t, func() { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -66,7 +66,7 @@ func TestSourceFile(t *testing.T) { wantError: &fs.PathError{ Op: "open", Path: "https://foo{bar/", - Err: &url.Error{Op: "parse", URL: "https://foo{bar/", Err: url.InvalidHostError("{")}, + Err: fmt.Errorf("invalid argument"), }, }, } @@ -74,24 +74,30 @@ func TestSourceFile(t *testing.T) { for nc, tc := range cases { Convey(fmt.Sprintf("Given the test case #%d - file %s", nc, tc.file), func() { Convey("and a mocked file system", func() { - mockedFS := testutil.NewMockFS(ctrl) - mockedFS.EXPECT().Open(tc.file).Times(lo.If(util.IsNil(tc.wantError), 1).Else(0)). - DoAndReturn(func(file string) (VirtualFile, error) { - return NewVirtualFile( - []byte("42"), - util.ParseURLMust(file), + content := []byte("42") + mockedFS := testutil.NewMockReadFileFS(ctrl) + mockedFS.EXPECT().Open(tc.file).AnyTimes(). + DoAndReturn(func(file string) (fs.File, error) { + return wasm.NewVirtualFile( + file, + content, time.Unix(1681389446, 0)), nil }) + mockedFS.EXPECT().ReadFile(tc.file).AnyTimes(). + DoAndReturn(func(_ string) ([]byte, error) { + return content, nil + }) Convey("and a filtered file system under test", func() { - filteredFS := NewFilteredFS( + filteredFS := NewFS( + mockedFS, lo.Map(tc.whitelist, util.Indexed(util.ParseURLMust)), lo.Map(tc.blacklist, util.Indexed(util.ParseURLMust)), - mockedFS) + ) - Convey(fmt.Sprintf(`When the open("%s") is called`, tc.file), func() { + Convey(fmt.Sprintf(`when the open("%s") is called`, tc.file), func() { result, err := filteredFS.Open(tc.file) - Convey("Then the result should be as expected", func() { + Convey("then the result should be as expected", func() { if util.IsNil(tc.wantError) { So(err, ShouldBeNil) @@ -106,9 +112,42 @@ func TestSourceFile(t *testing.T) { } }) }) + + Convey(fmt.Sprintf(`when the readFile("%s") is called`, tc.file), func() { + result, err := filteredFS.ReadFile(tc.file) + + Convey("Then the result should be as expected", func() { + if util.IsNil(tc.wantError) { + So(err, ShouldBeNil) + So(result, ShouldResemble, content) + } else { + So(err, ShouldNotBeNil) + So(err, ShouldResemble, tc.wantError) + } + }) + }) }) }) }) } }) + + Convey("Given a mocked fs that does not implement ReadFileFS", t, func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockedFS := testutil.NewMockFS(ctrl) + Convey("and a filtered file system under test", func() { + filteredFS := NewFS(mockedFS, nil, nil) + + Convey("when readFile is called", func() { + _, err := filteredFS.ReadFile("file") + + Convey("then an error should be returned", func() { + So(err, ShouldNotBeNil) + So(err, ShouldEqual, &fs.PathError{Op: "readfile", Path: "file", Err: fs.ErrInvalid}) + }) + }) + }) + }) } diff --git a/x/logic/fs/filtered_fs.go b/x/logic/fs/filtered_fs.go deleted file mode 100644 index c0a27400..00000000 --- a/x/logic/fs/filtered_fs.go +++ /dev/null @@ -1,53 +0,0 @@ -package fs - -import ( - "io/fs" - "net/url" - - "github.com/axone-protocol/axoned/v8/x/logic/util" -) - -// FilteredFS is a wrapper around a fs.FS that filters out files that are not allowed to be read. -// This is used to prevent the interpreter from reading files, using protocols that are not allowed to be used -// by the interpreter on the blockchain. -// The whitelist and blacklist are mutually exclusive. If both are set, the blacklist will be ignored. -type FilteredFS struct { - decorated fs.FS - whitelist []*url.URL - blacklist []*url.URL -} - -var _ fs.FS = (*FilteredFS)(nil) - -// NewFilteredFS returns a new FilteredFS object that will filter out files that are not allowed to be read -// according to the whitelist and blacklist parameters. -func NewFilteredFS(whitelist, blacklist []*url.URL, decorated fs.FS) *FilteredFS { - return &FilteredFS{ - decorated: decorated, - whitelist: whitelist, - blacklist: blacklist, - } -} - -// Open opens the named file. -// The name parameter is a URL that will be parsed and checked against the whitelist and blacklist configured. -func (f *FilteredFS) Open(name string) (fs.File, error) { - urlFile, err := url.Parse(name) - if err != nil { - return nil, &fs.PathError{ - Op: "open", - Path: name, - Err: err, - } - } - - if !util.WhitelistBlacklistMatches(f.whitelist, f.blacklist, util.URLMatches)(urlFile) { - return nil, &fs.PathError{ - Op: "open", - Path: name, - Err: fs.ErrPermission, - } - } - - return f.decorated.Open(name) -} diff --git a/x/logic/fs/provider.go b/x/logic/fs/provider.go new file mode 100644 index 00000000..fdf798fb --- /dev/null +++ b/x/logic/fs/provider.go @@ -0,0 +1,9 @@ +package fs + +import ( + goctx "context" + "io/fs" +) + +// Provider is a function that returns a filesystem. +type Provider = func(ctx goctx.Context) fs.FS diff --git a/x/logic/fs/router.go b/x/logic/fs/router.go deleted file mode 100644 index c40d315f..00000000 --- a/x/logic/fs/router.go +++ /dev/null @@ -1,42 +0,0 @@ -package fs - -import ( - "context" - "fmt" - "io/fs" - "net/url" -) - -type URIHandler interface { - Scheme() string - Open(ctx context.Context, uri *url.URL) (fs.File, error) -} - -type Router struct { - handlers map[string]URIHandler -} - -func NewRouter() Router { - return Router{ - handlers: make(map[string]URIHandler), - } -} - -func (r *Router) Open(ctx context.Context, name string) (fs.File, error) { - uri, err := url.Parse(name) - if err != nil { - return nil, err - } - - handler, ok := r.handlers[uri.Scheme] - - if !ok { - return nil, fmt.Errorf("could not find handler for load %s file", name) - } - - return handler.Open(ctx, uri) -} - -func (r *Router) RegisterHandler(handler URIHandler) { - r.handlers[handler.Scheme()] = handler -} diff --git a/x/logic/fs/virtual_fs.go b/x/logic/fs/virtual_fs.go deleted file mode 100644 index dba6a235..00000000 --- a/x/logic/fs/virtual_fs.go +++ /dev/null @@ -1,49 +0,0 @@ -package fs - -import ( - goctx "context" - "io/fs" -) - -// VirtualFS is the custom virtual file system used into the blockchain. -// It will hold a list of handler that can resolve file URI and return the corresponding binary file. -type VirtualFS struct { - ctx goctx.Context - router Router -} - -var _ fs.FS = (*VirtualFS)(nil) - -// NewVirtualFS return a new VirtualFS object that will handle all virtual file on the interpreter. -// File can be provided from different sources like CosmWasm cw-storage smart contract. -func NewVirtualFS(ctx goctx.Context, handlers []URIHandler) *VirtualFS { - router := NewRouter() - for _, handler := range handlers { - router.RegisterHandler(handler) - } - return &VirtualFS{ - ctx: ctx, - router: router, - } -} - -// Open opens the named file. -// -// When Open returns an error, it should be of type *PathError -// with the Op field set to "open", the Path field set to name, -// and the Err field describing the problem. -// -// Open should reject attempts to open names that do not satisfy -// ValidPath(name), returning a *PathError with Err set to -// ErrInvalid or ErrNotExist. -func (f *VirtualFS) Open(name string) (fs.File, error) { - data, err := f.router.Open(f.ctx, name) - if err != nil { - return nil, &fs.PathError{ - Op: "open", - Path: name, - Err: err, - } - } - return data, nil -} diff --git a/x/logic/fs/wasm.go b/x/logic/fs/wasm.go deleted file mode 100644 index bbab4efb..00000000 --- a/x/logic/fs/wasm.go +++ /dev/null @@ -1,90 +0,0 @@ -package fs - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io/fs" - "net/url" - "strconv" - "strings" - - sdk "github.com/cosmos/cosmos-sdk/types" - - "github.com/axone-protocol/axoned/v8/x/logic/types" -) - -const ( - queryKey = "query" - base64DecodeKey = "base64Decode" - scheme = "cosmwasm" -) - -type WasmHandler struct { - wasmKeeper types.WasmKeeper -} - -var _ URIHandler = (*WasmHandler)(nil) - -func NewWasmHandler(keeper types.WasmKeeper) WasmHandler { - return WasmHandler{wasmKeeper: keeper} -} - -func (w WasmHandler) Scheme() string { - return scheme -} - -func (w WasmHandler) Open(ctx context.Context, uri *url.URL) (fs.File, error) { - sdkCtx := sdk.UnwrapSDKContext(ctx) - - if uri.Scheme != scheme { - return nil, fmt.Errorf("invalid scheme") - } - - paths := strings.SplitAfter(uri.Opaque, ":") - pathsLen := len(paths) - if pathsLen < 1 || paths[pathsLen-1] == "" { - return nil, fmt.Errorf("emtpy path given, should be '%s:{contractName}:{contractAddr}?query={query}'", - scheme) - } - - contractAddr, err := sdk.AccAddressFromBech32(paths[pathsLen-1]) - if err != nil { - return nil, fmt.Errorf("failed convert path '%s' to contract address: %w", paths[pathsLen-1], err) - } - - if !uri.Query().Has(queryKey) { - return nil, fmt.Errorf("uri should contains `query` params") - } - query := uri.Query().Get(queryKey) - - base64Decode := true - if uri.Query().Has(base64DecodeKey) { - if base64Decode, err = strconv.ParseBool(uri.Query().Get(base64DecodeKey)); err != nil { - return nil, fmt.Errorf("failed convert 'base64Decode' query value to boolean: %w", err) - } - } - - data, err := w.wasmKeeper.QuerySmart(sdkCtx, contractAddr, []byte(query)) - if err != nil { - return nil, fmt.Errorf("failed query wasm keeper: %w", err) - } - - if base64Decode { - var program string - err = json.Unmarshal(data, &program) - if err != nil { - return nil, fmt.Errorf("failed unmarshal json wasm response to string: %w", err) - } - - decoded, err := base64.StdEncoding.DecodeString(program) - if err != nil { - return nil, fmt.Errorf("failed decode wasm base64 response: %w", err) - } - - return NewVirtualFile(decoded, uri, sdkCtx.BlockTime()), nil - } - - return NewVirtualFile(data, uri, sdkCtx.BlockTime()), nil -} diff --git a/x/logic/fs/wasm/file.go b/x/logic/fs/wasm/file.go new file mode 100644 index 00000000..cb08419d --- /dev/null +++ b/x/logic/fs/wasm/file.go @@ -0,0 +1,71 @@ +package wasm + +import ( + "bytes" + "io/fs" + "time" +) + +type file struct { + reader *bytes.Reader + info *fileInfo +} + +type fileInfo struct { + name string + size int64 + modTime time.Time +} + +var ( + _ fs.File = (*file)(nil) + _ fs.FileInfo = (*fileInfo)(nil) +) + +func NewVirtualFile(name string, content []byte, modTime time.Time) fs.File { + reader := bytes.NewReader(content) + return &file{ + reader: reader, + info: &fileInfo{ + name: name, + size: int64(len(content)), + modTime: modTime, + }, + } +} + +func (i fileInfo) Name() string { + return i.name +} + +func (i fileInfo) Size() int64 { + return i.size +} + +func (i fileInfo) Mode() fs.FileMode { + return fs.ModeIrregular +} + +func (i fileInfo) ModTime() time.Time { + return i.modTime +} + +func (i fileInfo) IsDir() bool { + return false +} + +func (i fileInfo) Sys() any { + return nil +} + +func (o file) Stat() (fs.FileInfo, error) { + return o.info, nil +} + +func (o file) Read(b []byte) (int, error) { + return o.reader.Read(b) +} + +func (o file) Close() error { + return nil +} diff --git a/x/logic/fs/wasm/fs.go b/x/logic/fs/wasm/fs.go new file mode 100644 index 00000000..b5304b56 --- /dev/null +++ b/x/logic/fs/wasm/fs.go @@ -0,0 +1,153 @@ +package wasm + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io/fs" + "net/url" + "strconv" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/axone-protocol/axoned/v8/x/logic/types" +) + +const ( + // scheme is the URI scheme for the WASM filesystem. + Scheme = "cosmwasm" +) + +const ( + queryKey = "query" + base64DecodeKey = "base64Decode" +) + +type vfs struct { + ctx context.Context + wasmKeeper types.WasmKeeper +} + +var ( + _ fs.FS = (*vfs)(nil) + _ fs.ReadFileFS = (*vfs)(nil) +) + +// NewFS creates a new filesystem that can read data from a WASM contract. +// The URI should be in the format `cosmwasm:{contractName}:{contractAddr}?query={query}`. +func NewFS(ctx context.Context, wasmKeeper types.WasmKeeper) fs.ReadFileFS { + return &vfs{ctx: ctx, wasmKeeper: wasmKeeper} +} + +func (f *vfs) Open(name string) (fs.File, error) { + data, err := f.readFile("open", name) + if err != nil { + return nil, err + } + + sdkCtx := sdk.UnwrapSDKContext(f.ctx) + return NewVirtualFile(name, data, sdkCtx.BlockTime()), nil +} + +func (f *vfs) ReadFile(name string) ([]byte, error) { + return f.readFile("readfile", name) +} + +func (f *vfs) readFile(op string, name string) ([]byte, error) { + contractAddr, query, base64Decode, err := f.parsePath(op, name) + if err != nil { + return nil, err + } + + sdkCtx := sdk.UnwrapSDKContext(f.ctx) + data, err := f.wasmKeeper.QuerySmart(sdkCtx, contractAddr, []byte(query)) + if err != nil { + return nil, &fs.PathError{ + Op: op, + Path: name, + Err: fmt.Errorf("failed to query WASM contract %s: %w", contractAddr, err), + } + } + + if base64Decode { + var program string + err = json.Unmarshal(data, &program) + if err != nil { + return nil, &fs.PathError{ + Op: op, + Path: name, + Err: fmt.Errorf("failed to unmarshal JSON WASM response to string: %w", err), + } + } + + data, err = base64.StdEncoding.DecodeString(program) + if err != nil { + return nil, &fs.PathError{ + Op: op, + Path: name, + Err: fmt.Errorf("failed to decode WASM base64 response: %w", err), + } + } + } + + return data, nil +} + +// parsePath parses the provided path and returns its component. +func (f *vfs) parsePath(op string, path string) (sdk.AccAddress, string, bool, error) { + uri, err := url.Parse(path) + if err != nil { + return nil, "", false, + &fs.PathError{Op: op, Path: path, Err: fs.ErrInvalid} + } + + if uri.Scheme != Scheme { + return nil, "", false, + &fs.PathError{Op: op, Path: path, Err: fmt.Errorf("invalid scheme, expected '%s', got '%s'", Scheme, uri.Scheme)} + } + + paths := strings.SplitAfter(uri.Opaque, ":") + pathsLen := len(paths) + if pathsLen < 1 || paths[pathsLen-1] == "" { + return nil, "", false, + &fs.PathError{Op: op, Path: path, Err: fmt.Errorf("emtpy path given, should be '%s:{contractName}:{contractAddr}?query={query}'", + Scheme)} + } + + lastPart := paths[len(paths)-1] + contractAddr, err := sdk.AccAddressFromBech32(lastPart) + if err != nil { + return nil, "", false, + &fs.PathError{ + Op: op, + Path: path, + Err: fmt.Errorf("failed to convert path '%s' to contract address: %w", lastPart, err), + } + } + + query := uri.Query().Get(queryKey) + if query == "" { + return nil, "", false, + &fs.PathError{ + Op: op, + Path: path, + Err: fmt.Errorf("uri should contains `query` params"), + } + } + + base64Decode := true + if uri.Query().Has(base64DecodeKey) { + if base64Decode, err = strconv.ParseBool(uri.Query().Get(base64DecodeKey)); err != nil { + return nil, "", false, + &fs.PathError{ + Op: op, + Path: path, + Err: fmt.Errorf("failed to convert 'base64Decode' query value to boolean: %w", err), + } + } + } + + return contractAddr, query, base64Decode, nil +} diff --git a/x/logic/fs/wasm_test.go b/x/logic/fs/wasm/fs_test.go similarity index 56% rename from x/logic/fs/wasm_test.go rename to x/logic/fs/wasm/fs_test.go index fd58c635..724106b7 100644 --- a/x/logic/fs/wasm_test.go +++ b/x/logic/fs/wasm/fs_test.go @@ -1,11 +1,11 @@ //nolint:lll -package fs +package wasm import ( "errors" "fmt" "io" - "net/url" + "io/fs" "testing" dbm "github.com/cosmos/cosmos-db" @@ -24,101 +24,120 @@ import ( "github.com/axone-protocol/axoned/v8/x/logic/testutil" ) -func TestWasmHandler(t *testing.T) { +//nolint:gocognit +func TestWasmVFS(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() Convey("Given a test cases", t, func() { + contractAddress := "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk" cases := []struct { contractAddress string query []byte data []byte uri string + fail bool wantResult []byte - wantError error + wantError string }{ { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"Y2FsYyhYKSA6LSAgWCBpcyAxMDAgKyAyMDAu\""), uri: `cosmwasm:cw-storage:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, wantResult: []byte("calc(X) :- X is 100 + 200."), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("Y2FsYyhYKSA6LSAgWCBpcyAxMDAgKyAyMDAu"), uri: `cosmwasm:cw-storage:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, wantResult: []byte("\"\""), - wantError: fmt.Errorf("failed unmarshal json wasm response to string: invalid character 'Y' looking for beginning of value"), + wantError: fmt.Sprintf("cosmwasm:cw-storage:%s?query=%%7B%%22object_data%%22%%3A%%7B%%22id%%22%%3A%%20%%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%%22%%7D%%7D: failed to unmarshal JSON WASM response to string: invalid character 'Y' looking for beginning of value", contractAddress), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"Y2FsYyhYKSA6LSAgWCBpcyAxMDAgKyAyMDAu\""), uri: `cosmwasm:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, wantResult: []byte("calc(X) :- X is 100 + 200."), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"Y2FsYyhYKSA6LSAgWCBpcyAxMDAgKyAyMDAu\""), uri: `axone:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, - wantError: fmt.Errorf("invalid scheme"), + wantError: fmt.Sprintf("axone:%s?query=%%7B%%22object_data%%22%%3A%%7B%%22id%%22%%3A%%20%%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%%22%%7D%%7D: invalid scheme, expected 'cosmwasm', got 'axone'", contractAddress), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"hey\""), uri: `cosmwasm:cw-storage:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, wantResult: []byte("\"\""), - wantError: fmt.Errorf("failed decode wasm base64 response: illegal base64 data at input byte 0"), + wantError: fmt.Sprintf("cosmwasm:cw-storage:%s?query=%%7B%%22object_data%%22%%3A%%7B%%22id%%22%%3A%%20%%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%%22%%7D%%7D: failed to decode WASM base64 response: illegal base64 data at input byte 0", contractAddress), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"hey\""), uri: `cosmwasm:cw-storage?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, wantResult: []byte("\"\""), - wantError: fmt.Errorf("failed convert path 'cw-storage' to contract address: decoding bech32 failed: invalid separator index -1"), + wantError: fmt.Sprintf("cosmwasm:cw-storage?query=%%7B%%22object_data%%22%%3A%%7B%%22id%%22%%3A%%20%%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%%22%%7D%%7D: failed to convert path 'cw-storage' to contract address: decoding bech32 failed: invalid separator index -1"), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"hey\""), uri: `cosmwasm:cw-storage:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?wasm=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, wantResult: []byte("\"\""), - wantError: fmt.Errorf("uri should contains `query` params"), + wantError: fmt.Sprintf("cosmwasm:cw-storage:%s?wasm=%%7B%%22object_data%%22%%3A%%7B%%22id%%22%%3A%%20%%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%%22%%7D%%7D: uri should contains `query` params", contractAddress), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"hey\""), uri: `cosmwasm:?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, wantResult: []byte("\"\""), - wantError: fmt.Errorf("emtpy path given, should be 'cosmwasm:{contractName}:{contractAddr}?query={query}'"), + wantError: fmt.Sprintf("cosmwasm:?query=%%7B%%22object_data%%22%%3A%%7B%%22id%%22%%3A%%20%%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%%22%%7D%%7D: emtpy path given, should be 'cosmwasm:{contractName}:{contractAddr}?query={query}'"), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("foo-bar"), uri: `cosmwasm:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D&base64Decode=false`, wantResult: []byte("foo-bar"), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"Y2FsYyhYKSA6LSAgWCBpcyAxMDAgKyAyMDAu\""), uri: `cosmwasm:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D&base64Decode=true`, wantResult: []byte("calc(X) :- X is 100 + 200."), }, { - contractAddress: "axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk", + contractAddress: contractAddress, query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), data: []byte("\"hey\""), uri: `cosmwasm:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D&base64Decode=foo`, wantResult: []byte("\"\""), - wantError: fmt.Errorf("failed convert 'base64Decode' query value to boolean: strconv.ParseBool: parsing \"foo\": invalid syntax"), + wantError: fmt.Sprintf(`cosmwasm:%s?query=%%7B%%22object_data%%22%%3A%%7B%%22id%%22%%3A%%20%%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%%22%%7D%%7D&base64Decode=foo: failed to convert 'base64Decode' query value to boolean: strconv.ParseBool: parsing "foo": invalid syntax`, contractAddress), + }, + { + contractAddress: contractAddress, + query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), + data: []byte("\"hey\""), + uri: `% %`, + wantResult: []byte("\"\""), + wantError: "% %: invalid argument", + }, + { + contractAddress: contractAddress, + query: []byte("{\"object_data\":{\"id\": \"4cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05\"}}"), + data: []byte("\"Y2FsYyhYKSA6LSAgWCBpcyAxMDAgKyAyMDAu\""), + uri: `cosmwasm:cw-storage:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D`, + fail: true, + wantError: "cosmwasm:cw-storage:axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk?query=%7B%22object_data%22%3A%7B%22id%22%3A%20%224cbe36399aabfcc7158ee7a66cbfffa525bb0ceab33d1ff2cff08759fe0a9b05%22%7D%7D: failed to query WASM contract axone15ekvz3qdter33mdnk98v8whv5qdr53yusksnfgc08xd26fpdn3tsrhsdrk: failed to query smart contract", }, } for nc, tc := range cases { @@ -134,27 +153,38 @@ func TestWasmHandler(t *testing.T) { wasmKeeper.EXPECT(). QuerySmart(ctx, sdk.MustAccAddressFromBech32(tc.contractAddress), tc.query). AnyTimes(). - Return(tc.data, nil) + DoAndReturn(func(_, _, _ interface{}) ([]byte, error) { + if tc.fail { + return nil, errors.New("failed to query smart contract") + } - Convey("and wasm handler", func() { - handler := NewWasmHandler(wasmKeeper) - - Convey("When ask handler if it can open uri", func() { - uri, err := url.Parse(tc.uri) + return tc.data, nil + }) - So(err, ShouldBeNil) + Convey("and a wasm file system under test", func() { + vfs := NewFS(ctx, wasmKeeper) - Convey("Then handler response should be as expected", func() { - file, err := handler.Open(ctx, uri) + Convey(fmt.Sprintf(`when the open("%s") is called`, tc.uri), func() { + file, err := vfs.Open(tc.uri) - if tc.wantError != nil { + Convey("then the result should be as expected", func() { + if tc.wantError != "" { So(err, ShouldNotBeNil) - So(err.Error(), ShouldEqual, tc.wantError.Error()) + So(err.Error(), ShouldEqual, fmt.Sprintf("open %s", tc.wantError)) } else { So(err, ShouldBeNil) defer file.Close() - info, _ := file.Stat() + info, err := file.Stat() + So(err, ShouldBeNil) + + So(info.Name(), ShouldEqual, tc.uri) + So(info.Size(), ShouldEqual, int64(len(tc.wantResult))) + So(info.ModTime(), ShouldEqual, ctx.BlockTime()) + So(info.IsDir(), ShouldBeFalse) + So(info.Mode(), ShouldEqual, fs.ModeIrregular) + So(info.Sys(), ShouldBeNil) + data := make([]byte, info.Size()) for { _, err := file.Read(data) @@ -167,6 +197,20 @@ func TestWasmHandler(t *testing.T) { So(data, ShouldResemble, tc.wantResult) } }) + + Convey(fmt.Sprintf(`when the readFile("%s") is called`, tc.uri), func() { + result, err := vfs.ReadFile(tc.uri) + + Convey("then the result should be as expected", func() { + if tc.wantError != "" { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, fmt.Sprintf("readfile %s", tc.wantError)) + } else { + So(err, ShouldBeNil) + So(result, ShouldResemble, tc.wantResult) + } + }) + }) }) }) }) diff --git a/x/logic/keeper/features_test.go b/x/logic/keeper/features_test.go index 29eeaebc..c9106285 100644 --- a/x/logic/keeper/features_test.go +++ b/x/logic/keeper/features_test.go @@ -32,7 +32,8 @@ import ( govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" "github.com/axone-protocol/axoned/v8/x/logic" - logicfs "github.com/axone-protocol/axoned/v8/x/logic/fs" + "github.com/axone-protocol/axoned/v8/x/logic/fs/composite" + "github.com/axone-protocol/axoned/v8/x/logic/fs/wasm" "github.com/axone-protocol/axoned/v8/x/logic/keeper" logictestutil "github.com/axone-protocol/axoned/v8/x/logic/testutil" "github.com/axone-protocol/axoned/v8/x/logic/types" @@ -296,8 +297,10 @@ func newQueryClient(ctx context.Context) (types.QueryServiceClient, error) { tc.accountKeeper, tc.bankKeeper, func(ctx context.Context) fs.FS { - wasmHandler := logicfs.NewWasmHandler(tc.wasmKeeper) - return logicfs.NewVirtualFS(ctx, []logicfs.URIHandler{wasmHandler}) + vfs := composite.NewFS() + vfs.Mount(wasm.Scheme, wasm.NewFS(ctx, tc.wasmKeeper)) + + return vfs }, ) diff --git a/x/logic/keeper/interpreter.go b/x/logic/keeper/interpreter.go index 901d7bc5..9917b347 100644 --- a/x/logic/keeper/interpreter.go +++ b/x/logic/keeper/interpreter.go @@ -17,7 +17,7 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/axone-protocol/axoned/v8/x/logic/fs" + "github.com/axone-protocol/axoned/v8/x/logic/fs/filtered" "github.com/axone-protocol/axoned/v8/x/logic/interpreter" "github.com/axone-protocol/axoned/v8/x/logic/interpreter/bootstrap" "github.com/axone-protocol/axoned/v8/x/logic/meter" @@ -139,7 +139,7 @@ func (k Keeper) newInterpreter(ctx context.Context) (*prolog.Interpreter, fmt.St options := []interpreter.Option{ interpreter.WithPredicates(ctx, interpreter.RegistryNames, hook), interpreter.WithBootstrap(ctx, util.NonZeroOrDefault(interpreterParams.GetBootstrap(), bootstrap.Bootstrap())), - interpreter.WithFS(fs.NewFilteredFS(whitelistUrls, blacklistUrls, k.fsProvider(ctx))), + interpreter.WithFS(filtered.NewFS(k.fsProvider(ctx), whitelistUrls, blacklistUrls)), interpreter.WithUserOutputWriter(userOutputBuffer), } diff --git a/x/logic/keeper/keeper.go b/x/logic/keeper/keeper.go index 8e268602..c4718e92 100644 --- a/x/logic/keeper/keeper.go +++ b/x/logic/keeper/keeper.go @@ -1,9 +1,7 @@ package keeper import ( - goctx "context" "fmt" - "io/fs" "cosmossdk.io/log" storetypes "cosmossdk.io/store/types" @@ -11,11 +9,10 @@ import ( "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/axone-protocol/axoned/v8/x/logic/fs" "github.com/axone-protocol/axoned/v8/x/logic/types" ) -type FSProvider = func(ctx goctx.Context) fs.FS - type ( Keeper struct { cdc codec.BinaryCodec @@ -26,7 +23,7 @@ type ( authKeeper types.AccountKeeper bankKeeper types.BankKeeper - fsProvider FSProvider + fsProvider fs.Provider } ) @@ -37,7 +34,7 @@ func NewKeeper( authority sdk.AccAddress, authKeeper types.AccountKeeper, bankKeeper types.BankKeeper, - fsProvider FSProvider, + fsProvider fs.Provider, ) *Keeper { // ensure gov module account is set and is not nil if err := sdk.VerifyAddressFormat(authority); err != nil { diff --git a/x/logic/testutil/fs_mocks.go b/x/logic/testutil/fs_mocks.go index 89c9f8ef..5fb390d0 100644 --- a/x/logic/testutil/fs_mocks.go +++ b/x/logic/testutil/fs_mocks.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: x/logic/fs/fs.go +// Source: io/fs (interfaces: FS) // Package testutil is a generated GoMock package. package testutil @@ -35,16 +35,16 @@ func (m *MockFS) EXPECT() *MockFSMockRecorder { } // Open mocks base method. -func (m *MockFS) Open(name string) (fs.File, error) { +func (m *MockFS) Open(arg0 string) (fs.File, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Open", name) + ret := m.ctrl.Call(m, "Open", arg0) ret0, _ := ret[0].(fs.File) ret1, _ := ret[1].(error) return ret0, ret1 } // Open indicates an expected call of Open. -func (mr *MockFSMockRecorder) Open(name interface{}) *gomock.Call { +func (mr *MockFSMockRecorder) Open(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFS)(nil).Open), name) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFS)(nil).Open), arg0) } diff --git a/x/logic/testutil/read_file_fs_mocks.go b/x/logic/testutil/read_file_fs_mocks.go new file mode 100644 index 00000000..3ba3269d --- /dev/null +++ b/x/logic/testutil/read_file_fs_mocks.go @@ -0,0 +1,65 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: io/fs (interfaces: ReadFileFS) + +// Package testutil is a generated GoMock package. +package testutil + +import ( + fs "io/fs" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockReadFileFS is a mock of ReadFileFS interface. +type MockReadFileFS struct { + ctrl *gomock.Controller + recorder *MockReadFileFSMockRecorder +} + +// MockReadFileFSMockRecorder is the mock recorder for MockReadFileFS. +type MockReadFileFSMockRecorder struct { + mock *MockReadFileFS +} + +// NewMockReadFileFS creates a new mock instance. +func NewMockReadFileFS(ctrl *gomock.Controller) *MockReadFileFS { + mock := &MockReadFileFS{ctrl: ctrl} + mock.recorder = &MockReadFileFSMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReadFileFS) EXPECT() *MockReadFileFSMockRecorder { + return m.recorder +} + +// Open mocks base method. +func (m *MockReadFileFS) Open(arg0 string) (fs.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Open", arg0) + ret0, _ := ret[0].(fs.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Open indicates an expected call of Open. +func (mr *MockReadFileFSMockRecorder) Open(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockReadFileFS)(nil).Open), arg0) +} + +// ReadFile mocks base method. +func (m *MockReadFileFS) ReadFile(arg0 string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadFile", arg0) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadFile indicates an expected call of ReadFile. +func (mr *MockReadFileFSMockRecorder) ReadFile(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockReadFileFS)(nil).ReadFile), arg0) +}