Repository: tetratelabs/wazero
Pull request #1015 (2023-01-07 09:09:04)

fs: Adds tests to prove we allow fs.FS to create new files
This ensures those who cannot use `writefs.DirFS` can use a `fs.FS` implementation that creates or opens files as read/write. This does so by making a hacked fs.FS and testing it.

```go
type hackFS string

func (dir hackFS) Open(name string) (fs.File, error) {
	path := ensureTrailingPathSeparator(string(dir)) + name

	if f, err := os.OpenFile(path, os.O_RDWR, 0); err == nil {
		return f, nil
	} else if errors.Is(err, syscall.EISDIR) {
		return os.OpenFile(path, os.O_RDONLY, 0)
	} else {
		return nil, err
	}
}
```

This also removes type filtering to ensure hacked filesystems aren't accidentally masked. Instead we get more specific on error codes returned by functions such as `os.File.Write`.

---

Note: This is meant to extend the life of fs.FS for custom implementations, though we expect eventually to have our own API. However, such an API will remain internal until at least the end of the month due to a lot of prerequisite work. See #1013 cc @wjam

codefromthecrypt: windows is every day :p

Patch:
diff --git a/internal/engine/compiler/engine.go b/internal/engine/compiler/engine.go
index b73704dc..f3bc1e17 100644
--- a/internal/engine/compiler/engine.go
+++ b/internal/engine/compiler/engine.go
@@ -282,6 +282,7 @@ type (
 		sourceModule *wasm.Module
 		// listener holds a listener to notify when this function is called.
 		listener experimental.FunctionListener
+		goFunc   interface{}
 
 		sourceOffsetMap *sourceOffsetMap
 	}
@@ -350,7 +351,7 @@ const (
 	tableInstanceTableLenOffset = 8
 
 	// Offsets for wasm.FunctionInstance.
-	functionInstanceTypeIDOffset = 40
+	functionInstanceTypeIDOffset = 16
 
 	// Offsets for wasm.MemoryInstance.
 	memoryInstanceBufferOffset    = 0
@@ -494,6 +495,7 @@ func (e *engine) CompileModule(ctx context.Context, module *wasm.Module, listene
 		return err
 	}
 
+	var withGoFunc bool
 	importedFuncs := module.ImportFuncCount()
 	funcs := make([]*code, len(module.FunctionSection))
 	ln := len(listeners)
@@ -507,10 +509,12 @@ func (e *engine) CompileModule(ctx context.Context, module *wasm.Module, listene
 		funcIndex := wasm.Index(i)
 		var compiled *code
 		if ir.GoFunc != nil {
+			withGoFunc = true
 			if compiled, err = compileGoDefinedHostFunction(cmp); err != nil {
 				def := module.FunctionDefinitionSection[funcIndex+importedFuncs]
 				return fmt.Errorf("error compiling host go func[%s]: %w", def.DebugName(), err)
 			}
+			compiled.goFunc = ir.GoFunc
 		} else if compiled, err = compileWasmFunction(cmp, ir); err != nil {
 			def := module.FunctionDefinitionSection[funcIndex+importedFuncs]
 			return fmt.Errorf("error compiling wasm func[%s]: %w", def.DebugName(), err)
@@ -524,7 +528,7 @@ func (e *engine) CompileModule(ctx context.Context, module *wasm.Module, listene
 		compiled.sourceModule = module
 		funcs[funcIndex] = compiled
 	}
-	return e.addCodes(module, funcs)
+	return e.addCodes(module, funcs, withGoFunc)
 }
 
 // NewModuleEngine implements the same method as documented on wasm.Engine.
@@ -905,7 +909,7 @@ entry:
 			}
 			stack := ce.stack[base : base+stackLen]
 
-			fn := calleeHostFunction.source.GoFunc
+			fn := calleeHostFunction.parent.goFunc
 			switch fn := fn.(type) {
 			case api.GoModuleFunction:
 				fn.Call(ce.ctx, callCtx.WithMemory(ce.memoryInstance), stack)
diff --git a/internal/engine/compiler/engine_cache.go b/internal/engine/compiler/engine_cache.go
index 18f14279..309010fe 100644
--- a/internal/engine/compiler/engine_cache.go
+++ b/internal/engine/compiler/engine_cache.go
@@ -21,9 +21,11 @@ func (e *engine) deleteCodes(module *wasm.Module) {
 	// the content is up to the implementation of extencache.Cache interface.
 }
 
-func (e *engine) addCodes(module *wasm.Module, codes []*code) (err error) {
+func (e *engine) addCodes(module *wasm.Module, codes []*code, withGoFunc bool) (err error) {
 	e.addCodesToMemory(module, codes)
-	err = e.addCodesToCache(module, codes)
+	if !withGoFunc {
+		err = e.addCodesToCache(module, codes)
+	}
 	return
 }
 
diff --git a/internal/engine/interpreter/interpreter.go b/internal/engine/interpreter/interpreter.go
index ce2200f4..63da3a2d 100644
--- a/internal/engine/interpreter/interpreter.go
+++ b/internal/engine/interpreter/interpreter.go
@@ -169,10 +169,11 @@ type callFrame struct {
 }
 
 type code struct {
-	source   *wasm.Module
-	body     []*interpreterOp
-	listener experimental.FunctionListener
-	hostFn   interface{}
+	source         *wasm.Module
+	body           []*interpreterOp
+	listener       experimental.FunctionListener
+	hostFn         interface{}
+	isHostFunction bool
 }
 
 type function struct {
@@ -244,6 +245,7 @@ func (e *engine) CompileModule(ctx context.Context, module *wasm.Module, listene
 			compiled.listener = lsn
 		}
 		compiled.source = module
+		compiled.isHostFunction = ir.IsHostFunction
 		funcs[i] = compiled
 	}
 	e.addCodes(module, funcs)
@@ -855,7 +857,7 @@ func (ce *callEngine) callGoFunc(ctx context.Context, callCtx *wasm.CallContext,
 	frame := &callFrame{f: f}
 	ce.pushFrame(frame)
 
-	fn := f.source.GoFunc
+	fn := f.parent.hostFn
 	switch fn := fn.(type) {
 	case api.GoModuleFunction:
 		fn.Call(ctx, callCtx, stack)
@@ -876,7 +878,7 @@ func (ce *callEngine) callNativeFunc(ctx context.Context, callCtx *wasm.CallCont
 	moduleInst := f.source.Module
 	functions := moduleInst.Engine.(*moduleEngine).functions
 	var memoryInst *wasm.MemoryInstance
-	if f.source.IsHostFunction {
+	if f.parent.isHostFunction {
 		memoryInst = ce.callerMemory()
 	} else {
 		memoryInst = moduleInst.Memory
@@ -4146,9 +4148,9 @@ func (ce *callEngine) callNativeFunc(ctx context.Context, callCtx *wasm.CallCont
 func (ce *callEngine) callerMemory() *wasm.MemoryInstance {
 	// Search through the call frame stack from the top until we find a non host function.
 	for i := len(ce.frames) - 1; i >= 0; i-- {
-		frame := ce.frames[i].f.source
-		if !frame.IsHostFunction {
-			return frame.Module.Memory
+		f := ce.frames[i].f
+		if !f.parent.isHostFunction {
+			return f.source.Module.Memory
 		}
 	}
 	return nil
@@ -4351,7 +4353,7 @@ func i32Abs(v uint32) uint32 {
 }
 
 func (ce *callEngine) callNativeFuncWithListener(ctx context.Context, callCtx *wasm.CallContext, f *function, fnl experimental.FunctionListener) context.Context {
-	if f.source.IsHostFunction {
+	if f.parent.isHostFunction {
 		callCtx = callCtx.WithMemory(ce.callerMemory())
 	}
 	ctx = fnl.Before(ctx, callCtx, f.source.Definition, ce.peekValues(len(f.source.Type.Params)))
diff --git a/internal/syscallfs/adapter.go b/internal/syscallfs/adapter.go
index 6ebd95d9..a2039375 100644
--- a/internal/syscallfs/adapter.go
+++ b/internal/syscallfs/adapter.go
@@ -8,7 +8,13 @@ import (
 	"syscall"
 )
 
-// Adapt returns a read-only FS unless the input is already one.
+// Adapt adapts the input to FS unless it is already one. NewDirFS should be
+// used instead, if the input is os.DirFS.
+//
+// Note: This performs no flag verification on FS.OpenFile. fs.FS cannot read
+// flags as there is no parameter to pass them through with. Moreover, fs.FS
+// documentation does not require the file to be present. In summary, we can't
+// enforce flag behavior.
 func Adapt(fs fs.FS) FS {
 	if sys, ok := fs.(FS); ok {
 		return sys
@@ -32,17 +38,16 @@ func (ro *adapter) Path() string {
 
 // OpenFile implements FS.OpenFile
 func (ro *adapter) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
-	if flag != 0 && flag != os.O_RDONLY {
-		return nil, syscall.ENOSYS
-	}
-
 	path = cleanPath(path)
 	f, err := ro.fs.Open(path)
+
 	if err != nil {
-		// wrapped is fine while FS.OpenFile emulates os.OpenFile vs syscall.OpenFile.
 		return nil, err
+	} else if osF, ok := f.(*os.File); ok {
+		// If this is an OS file, it has same portability issues as dirFS.
+		return maybeWrapFile(osF), nil
 	}
-	return maskForReads(f), nil
+	return f, nil
 }
 
 func cleanPath(name string) string {
diff --git a/internal/syscallfs/dirfs.go b/internal/syscallfs/dirfs.go
index 1aa63189..51ac0f5d 100644
--- a/internal/syscallfs/dirfs.go
+++ b/internal/syscallfs/dirfs.go
@@ -4,11 +4,15 @@ import (
 	"fmt"
 	"io/fs"
 	"os"
-	"path"
 	"syscall"
 )
 
 func NewDirFS(dir string) (FS, error) {
+	if dir == "" {
+		panic("empty dir")
+	}
+	// For easier OS-specific concatenation later, append the path separator.
+	dir = ensureTrailingPathSeparator(dir)
 	if stat, err := os.Stat(dir); err != nil {
 		return nil, syscall.ENOENT
 	} else if !stat.IsDir() {
@@ -17,6 +21,13 @@ func NewDirFS(dir string) (FS, error) {
 	return dirFS(dir), nil
 }
 
+func ensureTrailingPathSeparator(dir string) string {
+	if dir[len(dir)-1] != os.PathSeparator {
+		return dir + string(os.PathSeparator)
+	}
+	return dir
+}
+
 // dirFS currently validates each path, which means that input paths cannot
 // escape the directory, except via symlink. We may want to relax this in the
 // future, especially as we decoupled from fs.FS which has this requirement.
@@ -34,20 +45,16 @@ func (dir dirFS) Path() string {
 
 // OpenFile implements FS.OpenFile
 func (dir dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
-	f, err := os.OpenFile(path.Join(string(dir), name), flag, perm)
+	f, err := os.OpenFile(dir.join(name), flag, perm)
 	if err != nil {
 		return nil, err
 	}
-
-	if flag == 0 || flag == os.O_RDONLY {
-		return maskForReads(f), nil
-	}
-	return f, nil
+	return maybeWrapFile(f), nil
 }
 
 // Mkdir implements FS.Mkdir
 func (dir dirFS) Mkdir(name string, perm fs.FileMode) error {
-	err := os.Mkdir(path.Join(string(dir), name), perm)
+	err := os.Mkdir(dir.join(name), perm)
 	return adjustMkdirError(err)
 }
 
@@ -56,25 +63,31 @@ func (dir dirFS) Rename(from, to string) error {
 	if from == to {
 		return nil
 	}
-	return rename(path.Join(string(dir), from), path.Join(string(dir), to))
+	return rename(dir.join(from), dir.join(to))
 }
 
 // Rmdir implements FS.Rmdir
 func (dir dirFS) Rmdir(name string) error {
-	err := syscall.Rmdir(path.Join(string(dir), name))
+	err := syscall.Rmdir(dir.join(name))
 	return adjustRmdirError(err)
 }
 
 // Unlink implements FS.Unlink
 func (dir dirFS) Unlink(name string) error {
-	err := syscall.Unlink(path.Join(string(dir), name))
+	err := syscall.Unlink(dir.join(name))
 	return adjustUnlinkError(err)
 }
 
 // Utimes implements FS.Utimes
 func (dir dirFS) Utimes(name string, atimeNsec, mtimeNsec int64) error {
-	return syscall.UtimesNano(path.Join(string(dir), name), []syscall.Timespec{
+	return syscall.UtimesNano(dir.join(name), []syscall.Timespec{
 		syscall.NsecToTimespec(atimeNsec),
 		syscall.NsecToTimespec(mtimeNsec),
 	})
 }
+
+func (dir dirFS) join(name string) string {
+	// TODO: Enforce similar to safefilepath.FromFS(name), but be careful as
+	// relative path inputs are allowed. e.g. dir or name == ../
+	return string(dir) + name
+}
diff --git a/internal/syscallfs/readfs.go b/internal/syscallfs/readfs.go
index cffffc48..8533e7ab 100644
--- a/internal/syscallfs/readfs.go
+++ b/internal/syscallfs/readfs.go
@@ -2,11 +2,16 @@ package syscallfs
 
 import (
 	"fmt"
+	"io"
 	"io/fs"
 	"os"
 	"syscall"
 )
 
+// NewReadFS is used to mask an existing FS for reads. Notably, this allows
+// the CLI to do read-only mounts of directories the host user can write, but
+// doesn't want the guest wasm to. For example, Python libraries shouldn't be
+// written to at runtime by the python wasm file.
 func NewReadFS(fs FS) FS {
 	if _, ok := fs.(*readFS); ok {
 		return fs
@@ -28,10 +33,70 @@ func (r *readFS) Path() string {
 
 // OpenFile implements FS.OpenFile
 func (r *readFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
-	if flag == 0 || flag == os.O_RDONLY {
-		return r.fs.OpenFile(path, flag, perm)
+	if flag != 0 && flag != os.O_RDONLY {
+		return nil, syscall.ENOSYS
+	}
+
+	f, err := r.fs.OpenFile(path, flag, perm)
+	if err != nil {
+		return nil, err
+	}
+	return maskForReads(f), nil
+}
+
+// maskForReads masks the file with read-only interfaces used by wazero.
+//
+// This technique was adapted from similar code in zipkin-go.
+func maskForReads(f fs.File) fs.File {
+	// The below are the types wazero casts into.
+	// Note: os.File implements this even for normal files.
+	d, i0 := f.(fs.ReadDirFile)
+	ra, i1 := f.(io.ReaderAt)
+	s, i2 := f.(io.Seeker)
+
+	// Wrap any combination of the types above.
+	switch {
+	case !i0 && !i1 && !i2: // 0, 0, 0
+		return struct{ fs.File }{f}
+	case !i0 && !i1 && i2: // 0, 0, 1
+		return struct {
+			fs.File
+			io.Seeker
+		}{f, s}
+	case !i0 && i1 && !i2: // 0, 1, 0
+		return struct {
+			fs.File
+			io.ReaderAt
+		}{f, ra}
+	case !i0 && i1 && i2: // 0, 1, 1
+		return struct {
+			fs.File
+			io.ReaderAt
+			io.Seeker
+		}{f, ra, s}
+	case i0 && !i1 && !i2: // 1, 0, 0
+		return struct {
+			fs.ReadDirFile
+		}{d}
+	case i0 && !i1 && i2: // 1, 0, 1
+		return struct {
+			fs.ReadDirFile
+			io.Seeker
+		}{d, s}
+	case i0 && i1 && !i2: // 1, 1, 0
+		return struct {
+			fs.ReadDirFile
+			io.ReaderAt
+		}{d, ra}
+	case i0 && i1 && i2: // 1, 1, 1
+		return struct {
+			fs.ReadDirFile
+			io.ReaderAt
+			io.Seeker
+		}{d, ra, s}
+	default:
+		panic("BUG: unhandled pattern")
 	}
-	return nil, syscall.ENOSYS
 }
 
 // Mkdir implements FS.Mkdir
diff --git a/internal/syscallfs/syscall.go b/internal/syscallfs/syscall.go
index fbbaf7ef..ed4e4083 100644
--- a/internal/syscallfs/syscall.go
+++ b/internal/syscallfs/syscall.go
@@ -22,3 +22,7 @@ func adjustUnlinkError(err error) error {
 func rename(old, new string) error {
 	return syscall.Rename(old, new)
 }
+
+func maybeWrapFile(f file) file {
+	return f
+}
diff --git a/internal/syscallfs/syscall_windows.go b/internal/syscallfs/syscall_windows.go
index c55e0664..ecfb3edb 100644
--- a/internal/syscallfs/syscall_windows.go
+++ b/internal/syscallfs/syscall_windows.go
@@ -2,16 +2,22 @@ package syscallfs
 
 import (
 	"errors"
+	"io"
 	"io/fs"
 	"os"
 	"syscall"
 )
 
+// See https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
 const (
 	// ERROR_ACCESS_DENIED is a Windows error returned by syscall.Unlink
 	// instead of syscall.EPERM
 	ERROR_ACCESS_DENIED = syscall.Errno(5)
 
+	// ERROR_INVALID_HANDLE is a Windows error returned by syscall.Write
+	// instead of syscall.EBADF
+	ERROR_INVALID_HANDLE = syscall.Errno(6)
+
 	// ERROR_ALREADY_EXISTS is a Windows error returned by os.Mkdir
 	// instead of syscall.EEXIST
 	ERROR_ALREADY_EXISTS = syscall.Errno(183)
@@ -68,7 +74,8 @@ func rename(old, new string) (err error) {
 		}
 
 		if oldIsDir && newIsDir {
-			// Windows doesn't let you overwrite a directory
+			// Windows doesn't let you overwrite a directory. If we aim to
+			// allow this, we'll have to delete here and retry.
 			return syscall.EINVAL
 		} else if newIsDir {
 			err = syscall.EISDIR
@@ -78,3 +85,41 @@ func rename(old, new string) (err error) {
 	}
 	return
 }
+
+// maybeWrapFile deals with errno portability issues in Windows. This code is
+// likely to change as we complete syscall support needed for WASI and GOOS=js.
+//
+// If we don't map to syscall.Errno, wasm will crash in odd way attempting the
+// same. This approach is an alternative to making our own fs.File public type.
+// We aren't doing that yet, as mapping problems are generally contained to
+// Windows. Hence, file is intentionally not exported.
+func maybeWrapFile(f file) file {
+	return struct {
+		readFile
+		io.Writer
+	}{f, &windowsWriter{f}}
+}
+
+// windowsWriter translates error codes not mapped properly by Go.
+type windowsWriter struct {
+	w io.Writer
+}
+
+// Write implements io.Writer
+func (w windowsWriter) Write(p []byte) (n int, err error) {
+	n, err = w.w.Write(p)
+	if err == nil {
+		return
+	}
+
+	// os.File.Wrap wraps the syscall error in a path error
+	if pe, ok := err.(*fs.PathError); ok {
+		switch pe.Err {
+		case ERROR_INVALID_HANDLE:
+			pe.Err = syscall.EBADF
+		case ERROR_ACCESS_DENIED:
+			pe.Err = syscall.EPERM
+		}
+	}
+	return
+}
diff --git a/internal/syscallfs/syscallfs.go b/internal/syscallfs/syscallfs.go
index c9d2a702..f1583dcd 100644
--- a/internal/syscallfs/syscallfs.go
+++ b/internal/syscallfs/syscallfs.go
@@ -39,6 +39,18 @@ type FS interface {
 
 	// OpenFile is similar to os.OpenFile, except the path is relative to this
 	// file system.
+	//
+	// # Constraints on the returned file
+	//
+	// Implementations that can read flags should enforce them regardless of
+	// the type returned. For example, while os.File implements io.Writer,
+	// attempts to write to a directory or a file opened with os.O_RDONLY fail
+	// with an os.PathError of syscall.EBADF.
+	//
+	// Some implementations choose whether to enforce read-only opens, namely
+	// fs.FS. While fs.FS is supported (Adapt), wazero cannot runtime enforce
+	// open flags. Instead, we encourage good behavior and test our built-in
+	// implementations.
 	OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error)
 	// ^^ TODO: Consider syscall.Open, though this implies defining and
 	// coercing flags and perms similar to what is done in os.OpenFile.
@@ -107,61 +119,6 @@ type FS interface {
 	Utimes(path string, atimeNsec, mtimeNsec int64) error
 }
 
-// maskForReads masks the file with read-only interfaces used by wazero.
-//
-// Note: This technique was adapted from similar code in zipkin-go.
-func maskForReads(f fs.File) fs.File {
-	// The below are the types wazero casts into.
-	// Note: os.File implements this even for normal files.
-	d, i0 := f.(fs.ReadDirFile)
-	ra, i1 := f.(io.ReaderAt)
-	s, i2 := f.(io.Seeker)
-
-	// Wrap any combination of the types above.
-	switch {
-	case !i0 && !i1 && !i2: // 0, 0, 0
-		return struct{ fs.File }{f}
-	case !i0 && !i1 && i2: // 0, 0, 1
-		return struct {
-			fs.File
-			io.Seeker
-		}{f, s}
-	case !i0 && i1 && !i2: // 0, 1, 0
-		return struct {
-			fs.File
-			io.ReaderAt
-		}{f, ra}
-	case !i0 && i1 && i2: // 0, 1, 1
-		return struct {
-			fs.File
-			io.ReaderAt
-			io.Seeker
-		}{f, ra, s}
-	case i0 && !i1 && !i2: // 1, 0, 0
-		return struct {
-			fs.ReadDirFile
-		}{d}
-	case i0 && !i1 && i2: // 1, 0, 1
-		return struct {
-			fs.ReadDirFile
-			io.Seeker
-		}{d, s}
-	case i0 && i1 && !i2: // 1, 1, 0
-		return struct {
-			fs.ReadDirFile
-			io.ReaderAt
-		}{d, ra}
-	case i0 && i1 && i2: // 1, 1, 1
-		return struct {
-			fs.ReadDirFile
-			io.ReaderAt
-			io.Seeker
-		}{d, ra, s}
-	default:
-		panic("BUG: unhandled pattern")
-	}
-}
-
 // StatPath is a convenience that calls FS.OpenFile until there is a stat
 // method.
 func StatPath(fs FS, path string) (fs.FileInfo, error) {
@@ -172,3 +129,16 @@ func StatPath(fs FS, path string) (fs.FileInfo, error) {
 	defer f.Close()
 	return f.Stat()
 }
+
+// readFile declares all read interfaces defined on os.File used by wazero.
+type readFile interface {
+	fs.ReadDirFile
+	io.ReaderAt
+	io.Seeker
+}
+
+// file declares all interfaces defined on os.File used by wazero.
+type file interface {
+	readFile
+	io.Writer
+}
diff --git a/internal/wasm/host.go b/internal/wasm/host.go
index 9736c735..71cedde4 100644
--- a/internal/wasm/host.go
+++ b/internal/wasm/host.go
@@ -105,7 +105,13 @@ func NewHostModule(
 	}
 
 	m.IsHostModule = true
-	m.AssignModuleID([]byte(fmt.Sprintf("%s:%v:%v", moduleName, nameToGoFunc, enabledFeatures)))
+	// Uses the address of *wasm.Module as the module ID so that host functions can have each state per compilation.
+	// Downside of this is that compilation cache on host functions (trampoline codes for Go functions and
+	// Wasm codes for Wasm-implemented host functions) are not available and compiles each time. On the other hand,
+	// compilation of host modules is not costly as it's merely small trampolines vs the real-world native Wasm binary.
+	// TODO: refactor engines so that we can properly cache compiled machine codes for host modules.
+	// TODO: this allows us to remove "namespace" API by sharing wasm.Engine (or its cache map and filesystem cache) across multiple wazero.Runtime.
+	m.AssignModuleID([]byte(fmt.Sprintf("@@@@@@@@%p", m))) // @@@@@@@@ = any 8 bytes different from Wasm header.
 	m.BuildFunctionDefinitions()
 	return
 }
diff --git a/internal/wasm/module.go b/internal/wasm/module.go
index 8eb3011d..5e2ab61c 100644
--- a/internal/wasm/module.go
+++ b/internal/wasm/module.go
@@ -617,19 +617,16 @@ func (m *ModuleInstance) BuildFunctions(mod *Module, importedFunctions []*Functi
 	}
 	for i, section := range mod.FunctionSection {
 		offset := uint32(i) + importCount
-		code := mod.CodeSection[i]
 		d := mod.FunctionDefinitionSection[offset]
 		// This object is only referenced from a slice. Instead of creating a heap object
 		// here and storing a pointer, we store the struct directly in the slice. This
 		// reduces the number of heap objects which improves GC performance.
 		fns[offset] = FunctionInstance{
-			IsHostFunction: code.IsHostFunction,
-			GoFunc:         code.GoFunc,
-			TypeID:         m.TypeIDs[section],
-			Module:         m,
-			Idx:            d.index,
-			Type:           d.funcType,
-			Definition:     d,
+			TypeID:     m.TypeIDs[section],
+			Module:     m,
+			Idx:        d.index,
+			Type:       d.funcType,
+			Definition: d,
 		}
 	}
 	return
diff --git a/internal/wasm/store.go b/internal/wasm/store.go
index 2ab9ac93..3b456e0f 100644
--- a/internal/wasm/store.go
+++ b/internal/wasm/store.go
@@ -100,20 +100,9 @@ type (
 	// FunctionInstance represents a function instance in a Store.
 	// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#function-instances%E2%91%A0
 	FunctionInstance struct {
-		// IsHostFunction is the data returned by the same field documented on
-		// wasm.Code.
-		IsHostFunction bool
-
 		// Type is the signature of this function.
 		Type *FunctionType
 
-		// GoFunc is non-nil when IsHostFunction and defined in go, either
-		// api.GoFunction or api.GoModuleFunction.
-		//
-		// Note: This has no serialization format, so is not encodable.
-		// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#host-functions%E2%91%A2
-		GoFunc interface{}
-
 		// Fields above here are settable prior to instantiation. Below are set by the Store during instantiation.
 
 		// ModuleInstance holds the pointer to the module instance to which this function belongs.


Test patch:
diff --git a/internal/syscallfs/adapter_test.go b/internal/syscallfs/adapter_test.go
index bc4d5abf..122106db 100644
--- a/internal/syscallfs/adapter_test.go
+++ b/internal/syscallfs/adapter_test.go
@@ -12,9 +12,7 @@ import (
 )
 
 func TestAdapt_MkDir(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := Adapt(os.DirFS(dir))
+	testFS := Adapt(os.DirFS(t.TempDir()))
 
 	err := testFS.Mkdir("mkdir", fs.ModeDir)
 	require.Equal(t, syscall.ENOSYS, err)
@@ -41,12 +39,11 @@ func TestAdapt_Rename(t *testing.T) {
 }
 
 func TestAdapt_Rmdir(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := Adapt(os.DirFS(dir))
+	tmpDir := t.TempDir()
+	testFS := Adapt(os.DirFS(tmpDir))
 
 	path := "rmdir"
-	realPath := pathutil.Join(dir, path)
+	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.Mkdir(realPath, 0o700))
 
 	err := testFS.Rmdir(path)
@@ -54,12 +51,11 @@ func TestAdapt_Rmdir(t *testing.T) {
 }
 
 func TestAdapt_Unlink(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := Adapt(os.DirFS(dir))
+	tmpDir := t.TempDir()
+	testFS := Adapt(os.DirFS(tmpDir))
 
 	path := "unlink"
-	realPath := pathutil.Join(dir, path)
+	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 	err := testFS.Unlink(path)
@@ -67,12 +63,11 @@ func TestAdapt_Unlink(t *testing.T) {
 }
 
 func TestAdapt_Utimes(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := Adapt(os.DirFS(dir))
+	tmpDir := t.TempDir()
+	testFS := Adapt(os.DirFS(tmpDir))
 
 	path := "utimes"
-	realPath := pathutil.Join(dir, path)
+	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 	err := testFS.Utimes(path, 1, 1)
@@ -80,15 +75,13 @@ func TestAdapt_Utimes(t *testing.T) {
 }
 
 func TestAdapt_Open_Read(t *testing.T) {
-	tmpDir := t.TempDir()
-
 	// Create a subdirectory, so we can test reads outside the FS root.
+	tmpDir := t.TempDir()
 	tmpDir = pathutil.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
-
 	testFS := Adapt(os.DirFS(tmpDir))
 
-	testFS_Open_Read(t, tmpDir, testFS)
+	testOpen_Read(t, tmpDir, testFS)
 
 	t.Run("path outside root invalid", func(t *testing.T) {
 		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
@@ -97,3 +90,30 @@ func TestAdapt_Open_Read(t *testing.T) {
 		require.True(t, errors.Is(err, fs.ErrInvalid))
 	})
 }
+
+// hackFS cheats the fs.FS contract by opening for write (os.O_RDWR).
+//
+// Until we have an alternate public interface for filesystems, some users will
+// rely on this. Via testing, we ensure we don't accidentally break them.
+type hackFS string
+
+func (dir hackFS) Open(name string) (fs.File, error) {
+	path := ensureTrailingPathSeparator(string(dir)) + name
+
+	if f, err := os.OpenFile(path, os.O_RDWR, 0); err == nil {
+		return f, nil
+	} else if errors.Is(err, syscall.EISDIR) {
+		return os.OpenFile(path, os.O_RDONLY, 0)
+	} else {
+		return nil, err
+	}
+}
+
+// TestAdapt_HackedWrites ensures we allow writes even if they violate the
+// fs.FS contract.
+func TestAdapt_HackedWrites(t *testing.T) {
+	tmpDir := t.TempDir()
+	testFS := Adapt(hackFS(tmpDir))
+
+	testOpen_O_RDWR(t, tmpDir, testFS)
+}
diff --git a/internal/syscallfs/dirfs_test.go b/internal/syscallfs/dirfs_test.go
index 86178ab5..bf7417e6 100644
--- a/internal/syscallfs/dirfs_test.go
+++ b/internal/syscallfs/dirfs_test.go
@@ -13,12 +13,12 @@ import (
 )
 
 func TestDirFS_MkDir(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := dirFS(dir)
+	tmpDir := t.TempDir()
+	testFS, err := NewDirFS(tmpDir)
+	require.NoError(t, err)
 
 	name := "mkdir"
-	realPath := pathutil.Join(dir, name)
+	realPath := pathutil.Join(tmpDir, name)
 
 	t.Run("doesn't exist", func(t *testing.T) {
 		require.NoError(t, testFS.Mkdir(name, fs.ModeDir))
@@ -30,7 +30,7 @@ func TestDirFS_MkDir(t *testing.T) {
 
 	t.Run("dir exists", func(t *testing.T) {
 		err := testFS.Mkdir(name, fs.ModeDir)
-		require.Equal(t, syscall.EEXIST, errors.Unwrap(err))
+		requireErrno(t, syscall.EEXIST, err)
 	})
 
 	t.Run("file exists", func(t *testing.T) {
@@ -38,18 +38,19 @@ func TestDirFS_MkDir(t *testing.T) {
 		require.NoError(t, os.Mkdir(realPath, 0o700))
 
 		err := testFS.Mkdir(name, fs.ModeDir)
-		require.Equal(t, syscall.EEXIST, errors.Unwrap(err))
+		requireErrno(t, syscall.EEXIST, err)
 	})
 }
 
 func TestDirFS_Rename(t *testing.T) {
 	t.Run("from doesn't exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		file1 := "file1"
 		file1Path := pathutil.Join(tmpDir, file1)
-		err := os.WriteFile(file1Path, []byte{1}, 0o600)
+		err = os.WriteFile(file1Path, []byte{1}, 0o600)
 		require.NoError(t, err)
 
 		err = testFS.Rename("file2", file1)
@@ -57,12 +58,13 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to non-exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		file1 := "file1"
 		file1Path := pathutil.Join(tmpDir, file1)
 		file1Contents := []byte{1}
-		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		err = os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		file2 := "file2"
@@ -72,7 +74,7 @@ func TestDirFS_Rename(t *testing.T) {
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(file1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		requireErrno(t, syscall.ENOENT, err)
 
 		s, err := os.Stat(file2Path)
 		require.NoError(t, err)
@@ -80,7 +82,8 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to non-exist", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		dir1 := "dir1"
 		dir1Path := pathutil.Join(tmpDir, dir1)
@@ -88,12 +91,12 @@ func TestDirFS_Rename(t *testing.T) {
 
 		dir2 := "dir2"
 		dir2Path := pathutil.Join(tmpDir, dir2)
-		err := testFS.Rename(dir1, dir2)
+		err = testFS.Rename(dir1, dir2)
 		require.NoError(t, err)
 
 		// Show the prior path no longer exists
 		_, err = os.Stat(dir1Path)
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		requireErrno(t, syscall.ENOENT, err)
 
 		s, err := os.Stat(dir2Path)
 		require.NoError(t, err)
@@ -101,7 +104,8 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to file", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		dir1 := "dir1"
 		dir1Path := pathutil.Join(tmpDir, dir1)
@@ -111,7 +115,7 @@ func TestDirFS_Rename(t *testing.T) {
 		dir2Path := pathutil.Join(tmpDir, dir2)
 
 		// write a file to that path
-		err := os.WriteFile(dir2Path, []byte{2}, 0o600)
+		err = os.WriteFile(dir2Path, []byte{2}, 0o600)
 		require.NoError(t, err)
 
 		err = testFS.Rename(dir1, dir2)
@@ -128,12 +132,13 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		file1 := "file1"
 		file1Path := pathutil.Join(tmpDir, file1)
 		file1Contents := []byte{1}
-		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		err = os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		dir1 := "dir1"
@@ -145,7 +150,8 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to dir", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		dir1 := "dir1"
 		dir1Path := pathutil.Join(tmpDir, dir1)
@@ -155,7 +161,7 @@ func TestDirFS_Rename(t *testing.T) {
 		file1 := "file1"
 		file1Path := pathutil.Join(dir1Path, file1)
 		file1Contents := []byte{1}
-		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		err = os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		dir2 := "dir2"
@@ -181,12 +187,13 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to file", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		file1 := "file1"
 		file1Path := pathutil.Join(tmpDir, file1)
 		file1Contents := []byte{1}
-		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		err = os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		file2 := "file2"
@@ -209,13 +216,14 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("dir to itself", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		dir1 := "dir1"
 		dir1Path := pathutil.Join(tmpDir, dir1)
 		require.NoError(t, os.Mkdir(dir1Path, 0o700))
 
-		err := testFS.Rename(dir1, dir1)
+		err = testFS.Rename(dir1, dir1)
 		require.NoError(t, err)
 
 		s, err := os.Stat(dir1Path)
@@ -224,12 +232,13 @@ func TestDirFS_Rename(t *testing.T) {
 	})
 	t.Run("file to itself", func(t *testing.T) {
 		tmpDir := t.TempDir()
-		testFS := dirFS(tmpDir)
+		testFS, err := NewDirFS(tmpDir)
+		require.NoError(t, err)
 
 		file1 := "file1"
 		file1Path := pathutil.Join(tmpDir, file1)
 		file1Contents := []byte{1}
-		err := os.WriteFile(file1Path, file1Contents, 0o600)
+		err = os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		err = testFS.Rename(file1, file1)
@@ -242,12 +251,13 @@ func TestDirFS_Rename(t *testing.T) {
 }
 
 func TestDirFS_Rmdir(t *testing.T) {
-	dir := t.TempDir()
+	tmpDir := t.TempDir()
 
-	testFS := dirFS(dir)
+	testFS, err := NewDirFS(tmpDir)
+	require.NoError(t, err)
 
 	name := "rmdir"
-	realPath := pathutil.Join(dir, name)
+	realPath := pathutil.Join(tmpDir, name)
 
 	t.Run("doesn't exist", func(t *testing.T) {
 		err := testFS.Rmdir(name)
@@ -282,12 +292,13 @@ func TestDirFS_Rmdir(t *testing.T) {
 }
 
 func TestDirFS_Unlink(t *testing.T) {
-	dir := t.TempDir()
+	tmpDir := t.TempDir()
 
-	testFS := dirFS(dir)
+	testFS, err := NewDirFS(tmpDir)
+	require.NoError(t, err)
 
 	name := "unlink"
-	realPath := pathutil.Join(dir, name)
+	realPath := pathutil.Join(tmpDir, name)
 
 	t.Run("doesn't exist", func(t *testing.T) {
 		err := testFS.Unlink(name)
@@ -315,21 +326,25 @@ func TestDirFS_Unlink(t *testing.T) {
 func TestDirFS_Utimes(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	testFS := dirFS(tmpDir)
+	testFS, err := NewDirFS(tmpDir)
+	require.NoError(t, err)
 
-	testFS_Utimes(t, tmpDir, testFS)
+	testUtimes(t, tmpDir, testFS)
 }
 
-func TestDirFS_Open_Read(t *testing.T) {
+func TestDirFS_Open(t *testing.T) {
 	tmpDir := t.TempDir()
 
 	// Create a subdirectory, so we can test reads outside the FS root.
 	tmpDir = pathutil.Join(tmpDir, t.Name())
 	require.NoError(t, os.Mkdir(tmpDir, 0o700))
 
-	testFS := Adapt(dirFS(tmpDir))
+	testFS, err := NewDirFS(tmpDir)
+	require.NoError(t, err)
+
+	testOpen_Read(t, tmpDir, testFS)
 
-	testFS_Open_Read(t, tmpDir, testFS)
+	testOpen_O_RDWR(t, tmpDir, testFS)
 
 	t.Run("path outside root valid", func(t *testing.T) {
 		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
diff --git a/internal/syscallfs/readfs_test.go b/internal/syscallfs/readfs_test.go
index 34c22a11..cad28f52 100644
--- a/internal/syscallfs/readfs_test.go
+++ b/internal/syscallfs/readfs_test.go
@@ -11,9 +11,8 @@ import (
 )
 
 func TestReadFS_MkDir(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := NewReadFS(dirFS(dir))
+	tmpDir := t.TempDir()
+	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
 
 	err := testFS.Mkdir("mkdir", fs.ModeDir)
 	require.Equal(t, syscall.ENOSYS, err)
@@ -21,7 +20,7 @@ func TestReadFS_MkDir(t *testing.T) {
 
 func TestReadFS_Rename(t *testing.T) {
 	tmpDir := t.TempDir()
-	testFS := NewReadFS(dirFS(tmpDir))
+	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
 
 	file1 := "file1"
 	file1Path := pathutil.Join(tmpDir, file1)
@@ -40,12 +39,11 @@ func TestReadFS_Rename(t *testing.T) {
 }
 
 func TestReadFS_Rmdir(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := NewReadFS(dirFS(dir))
+	tmpDir := t.TempDir()
+	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
 
 	path := "rmdir"
-	realPath := pathutil.Join(dir, path)
+	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.Mkdir(realPath, 0o700))
 
 	err := testFS.Rmdir(path)
@@ -53,12 +51,11 @@ func TestReadFS_Rmdir(t *testing.T) {
 }
 
 func TestReadFS_Unlink(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := NewReadFS(dirFS(dir))
+	tmpDir := t.TempDir()
+	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
 
 	path := "unlink"
-	realPath := pathutil.Join(dir, path)
+	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 	err := testFS.Unlink(path)
@@ -66,12 +63,11 @@ func TestReadFS_Unlink(t *testing.T) {
 }
 
 func TestReadFS_Utimes(t *testing.T) {
-	dir := t.TempDir()
-
-	testFS := NewReadFS(dirFS(dir))
+	tmpDir := t.TempDir()
+	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
 
 	path := "utimes"
-	realPath := pathutil.Join(dir, path)
+	realPath := pathutil.Join(tmpDir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
 	err := testFS.Utimes(path, 1, 1)
@@ -80,8 +76,7 @@ func TestReadFS_Utimes(t *testing.T) {
 
 func TestReadFS_Open_Read(t *testing.T) {
 	tmpDir := t.TempDir()
+	testFS := NewReadFS(Adapt(hackFS(tmpDir)))
 
-	testFS := NewReadFS(dirFS(tmpDir))
-
-	testFS_Open_Read(t, tmpDir, testFS)
+	testOpen_Read(t, tmpDir, testFS)
 }
diff --git a/internal/syscallfs/syscallfs_test.go b/internal/syscallfs/syscallfs_test.go
index eb473c52..84ade151 100644
--- a/internal/syscallfs/syscallfs_test.go
+++ b/internal/syscallfs/syscallfs_test.go
@@ -15,7 +15,32 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-func testFS_Open_Read(t *testing.T, tmpDir string, testFS FS) {
+func testOpen_O_RDWR(t *testing.T, tmpDir string, testFS FS) {
+	file := "file"
+	realPath := path.Join(tmpDir, file)
+	err := os.WriteFile(realPath, []byte{}, 0o600)
+	require.NoError(t, err)
+
+	f, err := testFS.OpenFile(file, os.O_RDWR, 0)
+	require.NoError(t, err)
+	defer f.Close()
+
+	w, ok := f.(io.Writer)
+	require.True(t, ok)
+
+	// If the write flag was honored, we should be able to write!
+	fileContents := []byte{1, 2, 3, 4}
+	n, err := w.Write(fileContents)
+	require.NoError(t, err)
+	require.Equal(t, len(fileContents), n)
+
+	// Verify the contents actually wrote.
+	b, err := os.ReadFile(realPath)
+	require.NoError(t, err)
+	require.Equal(t, fileContents, b)
+}
+
+func testOpen_Read(t *testing.T, tmpDir string, testFS FS) {
 	file := "file"
 	fileContents := []byte{1, 2, 3, 4}
 	err := os.WriteFile(path.Join(tmpDir, file), fileContents, 0o700)
@@ -34,7 +59,7 @@ func testFS_Open_Read(t *testing.T, tmpDir string, testFS FS) {
 		_, err := testFS.OpenFile("nope", os.O_RDONLY, 0)
 
 		// We currently follow os.Open not syscall.Open, so the error is wrapped.
-		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
+		requireErrno(t, syscall.ENOENT, err)
 	})
 
 	t.Run("dir exists", func(t *testing.T) {
@@ -51,9 +76,10 @@ func testFS_Open_Read(t *testing.T, tmpDir string, testFS FS) {
 		require.False(t, e[0].IsDir())
 		require.Equal(t, file1, e[0].Name())
 
-		// Ensure it doesn't implement io.Writer
-		_, ok = f.(io.Writer)
-		require.False(t, ok)
+		if w, ok := f.(io.Writer); ok {
+			_, err := w.Write([]byte("hello"))
+			requireErrno(t, syscall.EBADF, err)
+		}
 	})
 
 	t.Run("file exists", func(t *testing.T) {
@@ -81,13 +107,18 @@ func testFS_Open_Read(t *testing.T, tmpDir string, testFS FS) {
 		require.NoError(t, err)
 		require.Equal(t, fileContents[1:], b)
 
-		// Ensure it doesn't implement io.Writer
-		_, ok = f.(io.Writer)
-		require.False(t, ok)
+		if w, ok := f.(io.Writer); ok {
+			_, err := w.Write([]byte("hello"))
+			if runtime.GOOS == "windows" {
+				requireErrno(t, syscall.EPERM, err)
+			} else {
+				requireErrno(t, syscall.EBADF, err)
+			}
+		}
 	})
 }
 
-func testFS_Utimes(t *testing.T, tmpDir string, testFS FS) {
+func testUtimes(t *testing.T, tmpDir string, testFS FS) {
 	file := "file"
 	err := os.WriteFile(path.Join(tmpDir, file), []byte{}, 0o700)
 	require.NoError(t, err)
@@ -163,3 +194,9 @@ func testFS_Utimes(t *testing.T, tmpDir string, testFS FS) {
 		})
 	}
 }
+
+// requireErrno should only be used for functions that wrap the underlying
+// syscall.Errno.
+func requireErrno(t *testing.T, expected syscall.Errno, actual error) {
+	require.True(t, errors.Is(actual, expected), "expected %v, but was %v", expected, actual)
+}