Repository: tetratelabs/wazero
Pull request #970 (2022-12-28 11:30:44)

Adds experimental write support and implements on gojs
This adds writefs.FS, allowing functions to create and delete files. This begins by implementing them on `GOARCH=js GOOS=wasm`. The current status is a lot farther than before, even if completing write on WASI is left for a later PR (possibly by another volunteer).

For example, Go os package tests pass all the way up to symlinks now, something that can also be done in a follow-up.

```bash
$ mkdir tmp
$ wazero run  -mount=.:/ os.wasm -test.v
=== RUN   TestExpand
--- PASS: TestExpand (0.00s)
=== RUN   TestConsistentEnviron
--- PASS: TestConsistentEnviron (0.00s)
=== RUN   TestUnsetenv
--- PASS: TestUnsetenv (0.00s)
=== RUN   TestClearenv
--- PASS: TestClearenv (0.00s)
=== RUN   TestLookupEnv
--- PASS: TestLookupEnv (0.00s)
=== RUN   TestEnvironConsistency
--- PASS: TestEnvironConsistency (0.00s)
=== RUN   TestErrIsExist
--- PASS: TestErrIsExist (0.00s)
=== RUN   TestErrIsNotExist
--- PASS: TestErrIsNotExist (0.00s)
=== RUN   TestIsExist
--- PASS: TestIsExist (0.00s)
=== RUN   TestIsPermission
--- PASS: TestIsPermission (0.00s)
=== RUN   TestErrPathNUL
--- PASS: TestErrPathNUL (0.00s)
=== RUN   TestPathErrorUnwrap
--- PASS: TestPathErrorUnwrap (0.00s)
=== RUN   TestErrorIsMethods
--- PASS: TestErrorIsMethods (0.00s)
=== RUN   TestExecutable
    exec.go:34: skipping test: cannot exec subprocess on js/wasm
--- SKIP: TestExecutable (0.00s)
=== RUN   TestExecutableDeleted
    exec.go:34: skipping test: cannot exec subprocess on js/wasm
--- SKIP: TestExecutableDeleted (0.00s)
=== RUN   TestStat
    os_test.go:178: stat failed: stat /etc/group: No such file or directory
--- FAIL: TestStat (0.00s)
=== RUN   TestStatError
error instantiating wasm binary: TODO: call fs.symlink (recovered by wazero)
wasm stack trace:
	go.syscall/js.valueCall(i32)
	.syscall_js.valueCall(i32) i32
	.syscall_js.Value.Call(i32) i32
	.syscall.fsCall(i32) i32
	.syscall.Symlink(i32) i32
	.os.Symlink(i32) i32
	.os_test.TestStatError(i32) i32
	.wasm_pc_f_loop()
	.wasm_export_run(i32,i32)
```


Patch:
diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go
index 3c8518e1..42bded22 100644
--- a/cmd/wazero/wazero.go
+++ b/cmd/wazero/wazero.go
@@ -15,6 +15,7 @@ import (
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/experimental"
 	"github.com/tetratelabs/wazero/experimental/logging"
+	"github.com/tetratelabs/wazero/experimental/writefs"
 	gojs "github.com/tetratelabs/wazero/imports/go"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
 	"github.com/tetratelabs/wazero/internal/version"
@@ -158,38 +159,30 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 		env = append(env, fields[0], fields[1])
 	}
 
-	var mountFS fs.FS
-	if len(mounts) > 0 {
-		cfs := &compositeFS{
-			paths: map[string]fs.FS{},
-		}
-		for _, mount := range mounts {
-			if len(mount) == 0 {
-				fmt.Fprintln(stdErr, "invalid mount: empty string")
-				exit(1)
-			}
-
-			// TODO(anuraaga): Support wasm paths with colon in them.
-			var host, guest string
-			if clnIdx := strings.LastIndexByte(mount, ':'); clnIdx != -1 {
-				host, guest = mount[:clnIdx], mount[clnIdx+1:]
-			} else {
-				host = mount
-				guest = host
-			}
-
-			if guest[0] == '.' {
-				fmt.Fprintf(stdErr, "invalid mount: guest path must not start with .: %s\n", guest)
-				exit(1)
-			}
-
-			// wazero always calls fs.Open with a relative path.
-			if guest[0] == '/' {
-				guest = guest[1:]
+	validatedMounts := validateMounts(mounts, stdErr, exit)
+
+	var rootFS fs.FS
+	switch len(validatedMounts) {
+	case 0: // nofs
+	case 1:
+		mount := validatedMounts[0]
+		host := mount[0]
+		guest := mount[1]
+		if guest == "" { // guest is root
+			rootFS = writefs.New(host)
+		} else { // TODO: subfs
+			rootFS = &compositeFS{
+				paths: map[string]fs.FS{guest: os.DirFS(host)},
 			}
+		}
+	default:
+		cfs := &compositeFS{paths: map[string]fs.FS{}}
+		for _, mount := range validatedMounts {
+			host := mount[0]
+			guest := mount[1]
 			cfs.paths[guest] = os.DirFS(host)
 		}
-		mountFS = cfs
+		rootFS = cfs
 	}
 
 	wasm, err := os.ReadFile(wasmPath)
@@ -221,8 +214,8 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 	for i := 0; i < len(env); i += 2 {
 		conf = conf.WithEnv(env[i], env[i+1])
 	}
-	if mountFS != nil {
-		conf = conf.WithFS(mountFS)
+	if rootFS != nil {
+		conf = conf.WithFS(rootFS)
 	}
 
 	code, err := rt.CompileModule(ctx, wasm)
@@ -253,6 +246,42 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer, exit func(cod
 	exit(0)
 }
 
+func validateMounts(mounts sliceFlag, stdErr logging.Writer, exit func(code int)) (validatedMounts [][2]string) {
+	for _, mount := range mounts {
+		if len(mount) == 0 {
+			fmt.Fprintln(stdErr, "invalid mount: empty string")
+			exit(1)
+		}
+
+		// TODO(anuraaga): Support wasm paths with colon in them.
+		var host, guest string
+		if clnIdx := strings.LastIndexByte(mount, ':'); clnIdx != -1 {
+			host, guest = mount[:clnIdx], mount[clnIdx+1:]
+		} else {
+			host = mount
+			guest = host
+		}
+
+		if guest[0] == '.' {
+			fmt.Fprintf(stdErr, "invalid mount: guest path must not start with .: %s\n", guest)
+			exit(1)
+		}
+
+		// wazero always calls fs.Open with a relative path.
+		if guest[0] == '/' {
+			guest = guest[1:]
+		}
+
+		if abs, err := filepath.Abs(host); err != nil {
+			fmt.Fprintf(stdErr, "invalid mount: host path %q invalid: %v\n", host, err)
+			exit(1)
+		} else {
+			validatedMounts = append(validatedMounts, [2]string{abs, guest})
+		}
+	}
+	return
+}
+
 func detectImports(imports []api.FunctionDefinition) (needsWASI, needsGo bool) {
 	for _, f := range imports {
 		moduleName, _, _ := f.Import()
diff --git a/experimental/writefs/writefs.go b/experimental/writefs/writefs.go
new file mode 100644
index 00000000..6a45dcec
--- /dev/null
+++ b/experimental/writefs/writefs.go
@@ -0,0 +1,62 @@
+package writefs
+
+import (
+	"io/fs"
+	"os"
+)
+
+// FS is a fs.FS which can also create new files or directories.
+//
+// Any unsupported method should return syscall.ENOSYS.
+//
+// See https://github.com/golang/go/issues/45757
+type FS interface {
+	fs.FS
+
+	// OpenFile is similar to os.OpenFile, except the path is relative to this
+	// file system.
+	OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error)
+
+	// Mkdir is similar to os.Mkdir, except the path is relative to this file
+	// system.
+	Mkdir(name string, perm fs.FileMode) error
+
+	// Remove is similar to os.Remove, except the path is relative to this file
+	// system.
+	Remove(path string) error
+}
+
+func New(absoluteDir string) FS {
+	return writeFS(absoluteDir)
+}
+
+type writeFS string
+
+// Open implements fs.FS
+func (dir writeFS) Open(name string) (fs.File, error) {
+	return dir.OpenFile(name, os.O_RDONLY, 0) // same as os.Open(string)
+}
+
+// OpenFile implements FS.OpenFile
+func (dir writeFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
+	if !fs.ValidPath(name) {
+		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
+	}
+	return os.OpenFile(string(dir)+"/"+name, flag, perm)
+}
+
+// Mkdir implements FS.Mkdir
+func (dir writeFS) Mkdir(name string, perm fs.FileMode) error {
+	if !fs.ValidPath(name) {
+		return &fs.PathError{Op: "mkdir", Path: name, Err: fs.ErrInvalid}
+	}
+	return os.Mkdir(string(dir)+"/"+name, perm)
+}
+
+// Remove implements FS.Remove
+func (dir writeFS) Remove(path string) error {
+	if !fs.ValidPath(path) {
+		return &fs.PathError{Op: "remove", Path: path, Err: fs.ErrInvalid}
+	}
+	return os.Remove(string(dir) + "/" + path)
+}
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index ba832a4c..54fa473c 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"io/fs"
 	"math"
+	"os"
 	"path"
 	"syscall"
 
@@ -1290,8 +1291,7 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno {
 	// rights aren't used
 	_, _ = params[5], params[6]
 
-	// TODO: only notable fdflag for opening is FD_APPEND
-	_ /* fdflags */ = uint16(params[7])
+	fdflags := uint16(params[7])
 	resultOpenedFd := uint32(params[8])
 
 	// Note: We don't handle AT_FDCWD, as that's resolved in the compiler.
@@ -1308,20 +1308,30 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno {
 		return ErrnoFault
 	}
 
+	fileOpenFlags, isDir := openFlags(oflags, fdflags)
+
 	// TODO: path is not precise here, as it should be a path relative to the
 	// FD, which isn't always rootFD (3). This means the path for Open may need
 	// to be built up. For example, if dirfd represents "/tmp/foo" and
 	// path="bar", this should open "/tmp/foo/bar" not "/bar".
 	//
 	// See https://linux.die.net/man/2/openat
-	newFD, errno := openFile(fsc, string(b))
-	if errno != ErrnoSuccess {
-		return errno
+	pathName := string(b)
+	var newFD uint32
+	var err error
+	if isDir && oflags&wasi_snapshot_preview1.O_CREAT != 0 {
+		newFD, err = fsc.Mkdir(pathName, 0o700)
+	} else {
+		newFD, err = fsc.OpenFile(pathName, fileOpenFlags, 0o600)
+	}
+
+	if err != nil {
+		return toErrno(err)
 	}
 
 	// Check any flags that require the file to evaluate.
-	if oflags&wasi_snapshot_preview1.O_DIRECTORY != 0 {
-		if errno = failIfNotDirectory(fsc, newFD); errno != ErrnoSuccess {
+	if isDir {
+		if errno := failIfNotDirectory(fsc, newFD); errno != ErrnoSuccess {
 			return errno
 		}
 	}
@@ -1333,6 +1343,30 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno {
 	return ErrnoSuccess
 }
 
+func openFlags(oflags, fdflags uint16) (openFlags int, isDir bool) {
+	isDir = oflags&wasi_snapshot_preview1.O_DIRECTORY != 0
+	openFlags = os.O_RDONLY
+	if oflags&wasi_snapshot_preview1.O_CREAT != 0 {
+		if !isDir { // assume read write
+			openFlags = os.O_RDWR
+		}
+		openFlags |= os.O_CREATE
+	}
+	if isDir {
+		return
+	}
+	if oflags&wasi_snapshot_preview1.O_EXCL != 0 {
+		openFlags |= os.O_EXCL
+	}
+	if oflags&wasi_snapshot_preview1.O_TRUNC != 0 {
+		openFlags |= os.O_TRUNC
+	}
+	if fdflags&wasi_snapshot_preview1.FD_APPEND != 0 {
+		openFlags |= os.O_APPEND
+	}
+	return
+}
+
 func failIfNotDirectory(fsc *internalsys.FSContext, fd uint32) Errno {
 	// Lookup the previous file
 	if f, ok := fsc.OpenedFile(fd); !ok {
@@ -1392,19 +1426,6 @@ var pathUnlinkFile = stubFunction(
 	"fd", "path", "path_len",
 )
 
-// openFile attempts to open the file at the given path. Errors coerce to WASI
-// Errno.
-func openFile(fsc *internalsys.FSContext, name string) (fd uint32, errno Errno) {
-	newFD, err := fsc.OpenFile(name)
-	if err == nil {
-		fd = newFD
-		errno = ErrnoSuccess
-		return
-	}
-	errno = toErrno(err)
-	return
-}
-
 // statFile attempts to stat the file at the given path. Errors coerce to WASI
 // Errno.
 func statFile(fsc *internalsys.FSContext, name string) (stat fs.FileInfo, errno Errno) {
@@ -1424,6 +1445,8 @@ func statFile(fsc *internalsys.FSContext, name string) (stat fs.FileInfo, errno
 func toErrno(err error) Errno {
 	// handle all the cases of FS.Open or internal to FSContext.OpenFile
 	switch {
+	case errors.Is(err, syscall.ENOSYS):
+		return ErrnoNosys
 	case errors.Is(err, fs.ErrInvalid):
 		return ErrnoInval
 	case errors.Is(err, fs.ErrNotExist):
diff --git a/internal/gojs/custom/fs.go b/internal/gojs/custom/fs.go
index 823c77bb..9420b4cc 100644
--- a/internal/gojs/custom/fs.go
+++ b/internal/gojs/custom/fs.go
@@ -12,6 +12,9 @@ const (
 	NameFsRead    = "read"
 	NameFsWrite   = "write"
 	NameFsReaddir = "readdir"
+	NameFsMkdir   = "mkdir"
+	NameFsRmdir   = "rmdir"
+	NameFsUnlink  = "unlink"
 )
 
 // FsNameSection are the functions defined in the object named NameFs. Results
@@ -58,4 +61,19 @@ var FsNameSection = map[string]*Names{
 		ParamNames:  []string{"name", NameCallback},
 		ResultNames: []string{"err", "dirents"},
 	},
+	NameFsMkdir: {
+		Name:        NameFsMkdir,
+		ParamNames:  []string{"path", "perm", NameCallback},
+		ResultNames: []string{"err", "fd"},
+	},
+	NameFsRmdir: {
+		Name:        NameFsRmdir,
+		ParamNames:  []string{"path", NameCallback},
+		ResultNames: []string{"err", "ok"},
+	},
+	NameFsUnlink: {
+		Name:        NameFsUnlink,
+		ParamNames:  []string{"path", NameCallback},
+		ResultNames: []string{"err", "ok"},
+	},
 }
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index a4487501..06a1275d 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -32,12 +32,12 @@ var (
 		addFunction(custom.NameFsClose, &jsfsClose{}).
 		addFunction(custom.NameFsRead, &jsfsRead{}).
 		addFunction(custom.NameFsWrite, &jsfsWrite{}).
-		addFunction(custom.NameFsReaddir, &jsfsReaddir{})
+		addFunction(custom.NameFsReaddir, &jsfsReaddir{}).
+		addFunction(custom.NameFsMkdir, &jsfsMkdir{}).
+		addFunction(custom.NameFsRmdir, &jsfsUnlink{}).
+		addFunction(custom.NameFsUnlink, &jsfsUnlink{})
 
 	// TODO: stub all these with syscall.ENOSYS
-	//	* _, err := fsCall("mkdir", path, perm) // syscall.Mkdir
-	//	* _, err := fsCall("unlink", path) // syscall.Unlink
-	//	* _, err := fsCall("rmdir", path) // syscall.Rmdir
 	//	* _, err := fsCall("chmod", path, mode) // syscall.Chmod
 	//	* _, err := fsCall("fchmod", fd, mode) // syscall.Fchmod
 	//	* _, err := fsCall("chown", path, uint32(uid), uint32(gid)) // syscall.Chown
@@ -64,22 +64,22 @@ var (
 		})
 
 	// oWRONLY = jsfsConstants Get("O_WRONLY").Int() // fs_js.go init
-	oWRONLY = api.EncodeF64(float64(os.O_WRONLY))
+	oWRONLY = float64(os.O_WRONLY)
 
 	// oRDWR = jsfsConstants Get("O_RDWR").Int() // fs_js.go init
-	oRDWR = api.EncodeF64(float64(os.O_RDWR))
+	oRDWR = float64(os.O_RDWR)
 
 	// o CREAT = jsfsConstants Get("O_CREAT").Int() // fs_js.go init
-	oCREAT = api.EncodeF64(float64(os.O_CREATE))
+	oCREAT = float64(os.O_CREATE)
 
 	// oTRUNC = jsfsConstants Get("O_TRUNC").Int() // fs_js.go init
-	oTRUNC = api.EncodeF64(float64(os.O_TRUNC))
+	oTRUNC = float64(os.O_TRUNC)
 
 	// oAPPEND = jsfsConstants Get("O_APPEND").Int() // fs_js.go init
-	oAPPEND = api.EncodeF64(float64(os.O_APPEND))
+	oAPPEND = float64(os.O_APPEND)
 
 	// oEXCL = jsfsConstants Get("O_EXCL").Int() // fs_js.go init
-	oEXCL = api.EncodeF64(float64(os.O_EXCL))
+	oEXCL = float64(os.O_EXCL)
 )
 
 // jsfsOpen implements fs.Open
@@ -90,7 +90,7 @@ type jsfsOpen struct{}
 // invoke implements jsFn.invoke
 func (*jsfsOpen) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
 	name := args[0].(string)
-	flags := toUint32(args[1]) // flags are derived from constants like oWRONLY
+	flags := toUint64(args[1]) // flags are derived from constants like oWRONLY
 	perm := toUint32(args[2])
 	callback := args[3].(funcWrapper)
 
@@ -115,7 +115,7 @@ func (*jsfsStat) invoke(ctx context.Context, mod api.Module, args ...interface{}
 // syscallStat is like syscall.Stat
 func syscallStat(mod api.Module, name string) (*jsSt, error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	if fd, err := fsc.OpenFile(name); err != nil {
+	if fd, err := fsc.OpenFile(name, os.O_RDONLY, 0); err != nil {
 		return nil, err
 	} else {
 		defer fsc.CloseFile(fd)
@@ -139,6 +139,21 @@ func (*jsfsFstat) invoke(ctx context.Context, mod api.Module, args ...interface{
 	return callback.invoke(ctx, mod, goos.RefJsfs, err, fstat) // note: error first
 }
 
+// mode constants from syscall_js.go
+const (
+	S_IFSOCK = uint32(0o000140000)
+	S_IFLNK  = uint32(0o000120000)
+	S_IFREG  = uint32(0o000100000)
+	S_IFBLK  = uint32(0o000060000)
+	S_IFDIR  = uint32(0o000040000)
+	S_IFCHR  = uint32(0o000020000)
+	S_IFIFO  = uint32(0o000010000)
+
+	S_ISUID = uint32(0o004000)
+	S_ISGID = uint32(0o002000)
+	S_ISVTX = uint32(0o001000)
+)
+
 // syscallFstat is like syscall.Fstat
 func syscallFstat(fsc *internalsys.FSContext, fd uint32) (*jsSt, error) {
 	if f, ok := fsc.OpenedFile(fd); !ok {
@@ -148,14 +163,49 @@ func syscallFstat(fsc *internalsys.FSContext, fd uint32) (*jsSt, error) {
 	} else {
 		ret := &jsSt{}
 		ret.isDir = stat.IsDir()
-		// TODO ret.dev=stat.Sys
-		ret.mode = uint32(stat.Mode())
-		ret.size = uint64(stat.Size())
-		ret.mtimeMs = uint64(stat.ModTime().UnixMilli())
+		ret.mode = getJsMode(stat.Mode())
+		ret.size = stat.Size()
+		ret.mtimeMs = stat.ModTime().UnixMilli()
 		return ret, nil
 	}
 }
 
+// getJsMode is required because the mode property read in `GOOS=js` is
+// incompatible with normal go. Particularly the directory flag isn't the same.
+func getJsMode(mode fs.FileMode) (jsMode uint32) {
+	jsMode = uint32(mode & fs.ModePerm)
+	switch mode & fs.ModeType {
+	case fs.ModeDir:
+		jsMode |= S_IFDIR
+	case fs.ModeSymlink:
+		jsMode |= S_IFLNK
+	case fs.ModeNamedPipe:
+		jsMode |= S_IFIFO
+	case fs.ModeSocket:
+		jsMode |= S_IFSOCK
+	case fs.ModeDevice:
+		jsMode |= S_IFBLK
+	case fs.ModeCharDevice:
+		jsMode |= S_IFCHR
+	case fs.ModeIrregular:
+		// unmapped to js
+	}
+
+	if mode&fs.ModeType == 0 {
+		jsMode |= S_IFREG
+	}
+	if mode&fs.ModeSetgid != 0 {
+		jsMode |= S_ISGID
+	}
+	if mode&fs.ModeSetuid != 0 {
+		jsMode |= S_ISUID
+	}
+	if mode&fs.ModeSticky != 0 {
+		jsMode |= S_ISVTX
+	}
+	return
+}
+
 // jsfsClose is used for syscall.Close
 //
 //	_, err := fsCall("close", fd)
@@ -291,7 +341,7 @@ func (*jsfsReaddir) invoke(ctx context.Context, mod api.Module, args ...interfac
 func syscallReaddir(_ context.Context, mod api.Module, name string) (*objectArray, error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fd, err := fsc.OpenFile(name)
+	fd, err := fsc.OpenFile(name, os.O_RDONLY, 0)
 	if err != nil {
 		return nil, err
 	}
@@ -351,7 +401,7 @@ func (*chdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (
 	path := args[0].(string)
 
 	// TODO: refactor so that sys has path-based ops, also needed in WASI.
-	if fd, err := fsc.OpenFile(path); err != nil {
+	if fd, err := fsc.OpenFile(path, os.O_RDONLY, 0); err != nil {
 		return nil, syscall.ENOENT
 	} else if f, ok := fsc.OpenedFile(fd); !ok {
 		return nil, syscall.ENOENT
@@ -367,23 +417,65 @@ func (*chdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (
 	}
 }
 
+// jsfsMkdir implements fs.Mkdir
+//
+//	jsFD /* Int */, err := fsCall("mkdir", path, perm)
+type jsfsMkdir struct{}
+
+// invoke implements jsFn.invoke
+func (*jsfsMkdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	name := args[0].(string)
+	perm := toUint32(args[1])
+	callback := args[2].(funcWrapper)
+
+	fd, err := syscallMkdir(mod, name, perm)
+	return callback.invoke(ctx, mod, goos.RefJsfs, err, fd) // note: error first
+}
+
+// syscallMkdir is like syscall.Mkdir
+func syscallMkdir(mod api.Module, name string, perm uint32) (uint32, error) {
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+	return fsc.Mkdir(name, fs.FileMode(perm))
+}
+
+// jsfsUnlink implements the following
+//
+//	_, err := fsCall("rmdir", path)
+//	_, err := fsCall("unlink", path) // syscall.Unlink
+type jsfsUnlink struct{}
+
+// invoke implements jsFn.invoke
+func (*jsfsUnlink) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
+	name := args[0].(string)
+	callback := args[1].(funcWrapper)
+
+	ok, err := syscallUnlink(mod, name)
+	return callback.invoke(ctx, mod, goos.RefJsfs, err, ok) // note: error first
+}
+
+// syscallUnlink is like syscall.Unlink
+func syscallUnlink(mod api.Module, name string) (interface{}, error) {
+	fsc := mod.(*wasm.CallContext).Sys.FS()
+	err := fsc.Unlink(name)
+	return err != nil, err
+}
+
 // jsSt is pre-parsed from fs_js.go setStat to avoid thrashing
 type jsSt struct {
-	isDir bool
-
-	dev     uint32
-	ino     uint32
+	isDir   bool
+	dev     int64
+	ino     uint64
 	mode    uint32
 	nlink   uint32
 	uid     uint32
 	gid     uint32
-	rdev    uint32
-	size    uint64
-	blksize uint32
-	blocks  uint32
-	atimeMs uint64
-	mtimeMs uint64
-	ctimeMs uint64
+	rdev    int64
+	size    int64
+	blksize int32
+	blocks  int32
+	atimeMs int64
+	mtimeMs int64
+	ctimeMs int64
 }
 
 // String implements fmt.Stringer
diff --git a/internal/gojs/state.go b/internal/gojs/state.go
index 175c14f6..b423104c 100644
--- a/internal/gojs/state.go
+++ b/internal/gojs/state.go
@@ -144,8 +144,12 @@ func storeRef(ctx context.Context, v interface{}) goos.Ref { //nolint
 	} else if _, ok := v.(string); ok {
 		id := getState(ctx).values.increment(v)
 		return goos.ValueRef(id, goos.TypeFlagString)
+	} else if i32, ok := v.(int32); ok {
+		return toFloatRef(float64(i32))
 	} else if u32, ok := v.(uint32); ok {
 		return toFloatRef(float64(u32))
+	} else if i64, ok := v.(int64); ok {
+		return toFloatRef(float64(i64))
 	} else if u64, ok := v.(uint64); ok {
 		return toFloatRef(float64(u64))
 	} else if f64, ok := v.(float64); ok {
@@ -261,6 +265,15 @@ func toInt64(arg interface{}) int64 {
 	return int64(arg.(float64))
 }
 
+func toUint64(arg interface{}) uint64 {
+	if arg == goos.RefValueZero || arg == undefined {
+		return 0
+	} else if u, ok := arg.(uint64); ok {
+		return u
+	}
+	return uint64(arg.(float64))
+}
+
 func toUint32(arg interface{}) uint32 {
 	if arg == goos.RefValueZero || arg == undefined {
 		return 0
diff --git a/internal/gojs/syscall.go b/internal/gojs/syscall.go
index 12384df0..1b0df54a 100644
--- a/internal/gojs/syscall.go
+++ b/internal/gojs/syscall.go
@@ -365,11 +365,13 @@ func (e *syscallErr) Error() string {
 // While usually I/O returns the correct errors, being explicit helps reduce
 // chance of problems.
 var (
-	ebadf   = &syscallErr{"EBADF"}
-	einval  = &syscallErr{"EBADF"}
-	eexist  = &syscallErr{"EEXIST"}
-	enoent  = &syscallErr{"ENOENT"}
-	enotdir = &syscallErr{"ENOTDIR"}
+	ebadf     = &syscallErr{"EBADF"}
+	einval    = &syscallErr{"EBADF"}
+	eexist    = &syscallErr{"EEXIST"}
+	enoent    = &syscallErr{"ENOENT"}
+	enosys    = &syscallErr{"ENOSYS"}
+	enotdir   = &syscallErr{"ENOTDIR"}
+	enotempty = &syscallErr{"ENOTEMPTY"}
 )
 
 // mapJSError maps I/O errors as the message must be the code, ex. "EINVAL",
@@ -383,10 +385,14 @@ func mapJSError(err error) *syscallErr {
 		return ebadf
 	case errors.Is(err, syscall.EINVAL), errors.Is(err, fs.ErrInvalid):
 		return einval
+	case errors.Is(err, syscall.ENOTEMPTY):
+		return enotempty
 	case errors.Is(err, syscall.EEXIST), errors.Is(err, fs.ErrExist):
 		return eexist
 	case errors.Is(err, syscall.ENOENT), errors.Is(err, fs.ErrNotExist):
 		return enoent
+	case errors.Is(err, syscall.ENOSYS):
+		return enosys
 	case errors.Is(err, syscall.ENOTDIR):
 		return enotdir
 	default:
@@ -397,9 +403,9 @@ func mapJSError(err error) *syscallErr {
 }
 
 // syscallOpen is like syscall.Open
-func syscallOpen(mod api.Module, name string, flags, perm uint32) (uint32, error) {
+func syscallOpen(mod api.Module, name string, flags uint64, perm uint32) (uint32, error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	return fsc.OpenFile(name)
+	return fsc.OpenFile(name, int(flags), fs.FileMode(perm))
 }
 
 // funcWrapper is the result of go's js.FuncOf ("_makeFuncWrapper" here).
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index b32e5fce..84deb60a 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -13,6 +13,7 @@ import (
 	"syscall"
 	"time"
 
+	"github.com/tetratelabs/wazero/experimental/writefs"
 	"github.com/tetratelabs/wazero/internal/platform"
 )
 
@@ -298,18 +299,50 @@ func (s fileModeStat) Sys() interface{}   { return nil }
 func (s fileModeStat) Name() string       { return "" }
 func (s fileModeStat) IsDir() bool        { return false }
 
+// Mkdir is like syscall.Mkdir and returns the file descriptor of the new
+// directory or an error.
+func (c *FSContext) Mkdir(name string, perm fs.FileMode) (newFD uint32, err error) {
+	name = c.cleanPath(name)
+	if wfs, ok := c.fs.(writefs.FS); ok {
+		if err = wfs.Mkdir(name, perm); err != nil {
+			return
+		}
+		// TODO: Determine how to handle when a directory already
+		// exists or is a file.
+		return c.OpenFile(name, os.O_RDONLY, perm)
+	}
+	err = syscall.ENOSYS
+	return
+}
+
+// Unlink is like syscall.Unlink.
+func (c *FSContext) Unlink(name string) (err error) {
+	if wfs, ok := c.fs.(writefs.FS); ok {
+		name = c.cleanPath(name)
+		return wfs.Remove(name)
+	}
+	err = syscall.ENOSYS
+	return
+}
+
 // OpenFile is like syscall.Open and returns the file descriptor of the new file or an error.
-//
-// TODO: Consider dirflags and oflags. Also, allow non-read-only open based on config about the mount.
-// e.g. allow os.O_RDONLY, os.O_WRONLY, or os.O_RDWR either by config flag or pattern on filename
-// See #390
-func (c *FSContext) OpenFile(name string /* TODO: flags int, perm int */) (uint32, error) {
-	f, err := c.openFile(name)
+func (c *FSContext) OpenFile(name string, flags int, perm fs.FileMode) (newFD uint32, err error) {
+	create := flags&os.O_CREATE != 0
+	var f fs.File
+	if wfs, ok := c.fs.(writefs.FS); ok {
+		name = c.cleanPath(name)
+		f, err = wfs.OpenFile(name, flags, perm)
+	} else if create {
+		err = syscall.ENOSYS
+	} else {
+		f, err = c.openFile(name)
+	}
+
 	if err != nil {
 		return 0, err
 	}
 
-	newFD := c.nextFD()
+	newFD = c.nextFD()
 	if newFD == 0 { // TODO: out of file descriptors
 		_ = f.Close()
 		return 0, syscall.EBADF
@@ -328,14 +361,17 @@ func (c *FSContext) StatPath(name string) (fs.FileInfo, error) {
 }
 
 func (c *FSContext) openFile(name string) (fs.File, error) {
+	return c.fs.Open(c.cleanPath(name))
+}
+
+func (c *FSContext) cleanPath(name string) string {
 	// fs.ValidFile cannot be rooted (start with '/')
 	fsOpenPath := name
 	if name[0] == '/' {
 		fsOpenPath = name[1:]
 	}
 	fsOpenPath = path.Clean(fsOpenPath) // e.g. "sub/." -> "sub"
-
-	return c.fs.Open(fsOpenPath)
+	return fsOpenPath
 }
 
 // FdWriter returns a valid writer for the given file descriptor or nil if syscall.EBADF.


Test patch:
diff --git a/experimental/writefs/writefs_test.go b/experimental/writefs/writefs_test.go
new file mode 100644
index 00000000..25f5c34c
--- /dev/null
+++ b/experimental/writefs/writefs_test.go
@@ -0,0 +1,91 @@
+package writefs
+
+import (
+	"io/fs"
+	"os"
+	"path"
+	"runtime"
+	"testing"
+	"testing/fstest"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+var testFiles = map[string]string{
+	"empty.txt":        "",
+	"test.txt":         "animals\n",
+	"sub/test.txt":     "greet sub dir\n",
+	"sub/sub/test.txt": "greet sub sub dir\n",
+}
+
+func TestFS(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		// This abstraction is a toe-hold, but we'll have to sort windows with
+		// our ideal filesystem tester.
+		t.Skip("TODO: windows")
+	}
+	dir := t.TempDir()
+	require.NoError(t, os.MkdirAll(path.Join(dir, "sub", "sub"), 0o700))
+
+	expected := make([]string, 0, len(testFiles))
+	for name, data := range testFiles {
+		expected = append(expected, name)
+		require.NoError(t, os.WriteFile(path.Join(dir, name), []byte(data), 0o600))
+	}
+
+	if err := fstest.TestFS(New(dir), expected...); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestMkDir(t *testing.T) {
+	dir := t.TempDir()
+
+	testFS := New(dir)
+
+	name := "mkdir"
+
+	t.Run("doesn't exist", func(t *testing.T) {
+		require.NoError(t, testFS.Mkdir(name, fs.ModeDir))
+		stat, err := os.Stat(path.Join(dir, name))
+		require.NoError(t, err)
+		require.Equal(t, name, stat.Name())
+		require.True(t, stat.IsDir())
+	})
+
+	t.Run("exists", func(t *testing.T) {
+		require.Error(t, testFS.Mkdir(name, fs.ModeDir))
+	})
+}
+
+func TestRemove(t *testing.T) {
+	dir := t.TempDir()
+
+	testFS := New(dir)
+
+	name := "remove"
+
+	t.Run("doesn't exist", func(t *testing.T) {
+		require.Error(t, testFS.Remove(name))
+	})
+
+	t.Run("dir exists", func(t *testing.T) {
+		realPath := path.Join(dir, name)
+		err := os.Mkdir(realPath, 0o700)
+		require.NoError(t, err)
+
+		require.NoError(t, testFS.Remove(name))
+		_, err = os.Stat(realPath)
+		require.Error(t, err)
+	})
+
+	t.Run("file exists", func(t *testing.T) {
+		realPath := path.Join(dir, name)
+		err := os.WriteFile(realPath, []byte{}, 0o600)
+		require.NoError(t, err)
+
+		require.NoError(t, testFS.Remove(name))
+		_, err = os.Stat(realPath)
+		require.Error(t, err)
+	})
+}
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 053338c0..00cb7be1 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -50,10 +50,10 @@ func Test_fdClose(t *testing.T) {
 	// open both paths without using WASI
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fdToClose, err := fsc.OpenFile(path1)
+	fdToClose, err := fsc.OpenFile(path1, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	fdToKeep, err := fsc.OpenFile(path2)
+	fdToKeep, err := fsc.OpenFile(path2, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	// Close
@@ -101,10 +101,10 @@ func Test_fdFdstatGet(t *testing.T) {
 	// open both paths without using WASI
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fileFd, err := fsc.OpenFile(file)
+	fileFd, err := fsc.OpenFile(file, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	dirFd, err := fsc.OpenFile(dir)
+	dirFd, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -270,10 +270,10 @@ func Test_fdFilestatGet(t *testing.T) {
 	// open both paths without using WASI
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fileFd, err := fsc.OpenFile(file)
+	fileFd, err := fsc.OpenFile(file, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	dirFd, err := fsc.OpenFile(dir)
+	dirFd, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -1124,7 +1124,7 @@ func Test_fdReaddir(t *testing.T) {
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fd, err := fsc.OpenFile("dir")
+	fd, err := fsc.OpenFile("dir", os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -1403,10 +1403,10 @@ func Test_fdReaddir_Errors(t *testing.T) {
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	dirFD, err := fsc.OpenFile("dir")
+	dirFD, err := fsc.OpenFile("dir", os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	fileFD, err := fsc.OpenFile("notdir")
+	fileFD, err := fsc.OpenFile("notdir", os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -2163,10 +2163,10 @@ func Test_pathFilestatGet(t *testing.T) {
 
 	rootFd := uint32(3) // after stderr
 
-	fileFd, err := fsc.OpenFile(file)
+	fileFd, err := fsc.OpenFile(file, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	dirFd, err := fsc.OpenFile(dir)
+	dirFd, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -2581,7 +2581,7 @@ func requireOpenFile(t *testing.T, pathName string, data []byte) (api.Module, ui
 	testFS := fstest.MapFS{pathName[1:]: mapFile} // strip the leading slash
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	fd, err := fsc.OpenFile(pathName)
+	fd, err := fsc.OpenFile(pathName, os.O_RDONLY, 0)
 	require.NoError(t, err)
 	return mod, fd, log, r
 }
@@ -2591,7 +2591,7 @@ func requireOpenWritableFile(t *testing.T, tmpDir string, pathName string) (api.
 	writeable, testFS := createWriteableFile(t, tmpDir, pathName, []byte{})
 	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(testFS))
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	fd, err := fsc.OpenFile(pathName)
+	fd, err := fsc.OpenFile(pathName, os.O_RDWR, 0)
 	require.NoError(t, err)
 
 	// Swap the read-only file with a writeable one until #390
diff --git a/imports/wasi_snapshot_preview1/wasi_bench_test.go b/imports/wasi_snapshot_preview1/wasi_bench_test.go
index a3fa7c5d..9523d8b4 100644
--- a/imports/wasi_snapshot_preview1/wasi_bench_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_bench_test.go
@@ -172,7 +172,7 @@ func Benchmark_fdReaddir(b *testing.B) {
 
 			// Open the root directory as a file-descriptor.
 			fsc := mod.(*wasm.CallContext).Sys.FS()
-			fd, err := fsc.OpenFile(".")
+			fd, err := fsc.OpenFile(".", os.O_RDONLY, 0)
 			if err != nil {
 				b.Fatal(err)
 			}
@@ -278,7 +278,7 @@ func Benchmark_pathFilestat(b *testing.B) {
 			fd := sys.FdRoot
 			if bc.fd != sys.FdRoot {
 				fsc := mod.(*wasm.CallContext).Sys.FS()
-				fd, err = fsc.OpenFile("zig")
+				fd, err = fsc.OpenFile("zig", os.O_RDONLY, 0)
 				if err != nil {
 					b.Fatal(err)
 				}
diff --git a/internal/gojs/compiler_test.go b/internal/gojs/compiler_test.go
index e32f1341..51a0b28d 100644
--- a/internal/gojs/compiler_test.go
+++ b/internal/gojs/compiler_test.go
@@ -51,9 +51,9 @@ var (
 	testCtx context.Context
 	testFS  = fstest.MapFS{
 		"empty.txt":    {},
-		"test.txt":     {Data: []byte("animals\n")},
-		"sub":          {Mode: fs.ModeDir},
-		"sub/test.txt": {Data: []byte("greet sub dir\n")},
+		"test.txt":     {Data: []byte("animals\n"), Mode: 0o644},
+		"sub":          {Mode: fs.ModeDir | 0o755},
+		"sub/test.txt": {Data: []byte("greet sub dir\n"), Mode: 0o444},
 	}
 	rt wazero.Runtime
 )
diff --git a/internal/gojs/fs_test.go b/internal/gojs/fs_test.go
index 9a679ec2..938e88a0 100644
--- a/internal/gojs/fs_test.go
+++ b/internal/gojs/fs_test.go
@@ -1,9 +1,12 @@
 package gojs_test
 
 import (
+	"os"
+	"path"
 	"testing"
 
 	"github.com/tetratelabs/wazero"
+	"github.com/tetratelabs/wazero/experimental/writefs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
@@ -12,15 +15,33 @@ func Test_fs(t *testing.T) {
 
 	stdout, stderr, err := compileAndRun(testCtx, "fs", wazero.NewModuleConfig().WithFS(testFS))
 
-	require.EqualError(t, err, `module "" closed with exit_code(0)`)
 	require.Zero(t, stderr)
+	require.EqualError(t, err, `module "" closed with exit_code(0)`)
 	require.Equal(t, `TestFS ok
 wd ok
 Not a directory
-/test.txt ok
-test.txt ok
+sub mode drwxr-xr-x
+/test.txt mode -rw-r--r--
+test.txt mode -rw-r--r--
 contents: animals
 
 empty: 
 `, stdout)
 }
+
+func Test_writefs(t *testing.T) {
+	t.Parallel()
+	tmpDir := t.TempDir()
+
+	fs := writefs.New(tmpDir)
+	// test expects to write under /tmp
+	require.NoError(t, os.Mkdir(path.Join(tmpDir, "tmp"), 0o700))
+
+	stdout, stderr, err := compileAndRun(testCtx, "writefs", wazero.NewModuleConfig().WithFS(fs))
+
+	require.Zero(t, stderr)
+	require.EqualError(t, err, `module "" closed with exit_code(0)`)
+	require.Equal(t, `/tmp/dir mode drwx------
+/tmp/dir/file mode -rw-------
+`, stdout)
+}
diff --git a/internal/gojs/testdata/fs/main.go b/internal/gojs/testdata/fs/main.go
index af4eeacd..4053214e 100644
--- a/internal/gojs/testdata/fs/main.go
+++ b/internal/gojs/testdata/fs/main.go
@@ -34,15 +34,13 @@ func testAdHoc() {
 		fmt.Println(err) // should be the textual message of the errno.
 	}
 
-	for _, path := range []string{"/test.txt", "test.txt"} {
-		s, err := os.Stat(path)
-		if err != nil {
+	// Ensure stat works, particularly mode.
+	for _, path := range []string{"sub", "/test.txt", "test.txt"} {
+		if stat, err := os.Stat(path); err != nil {
 			log.Panicln(err)
+		} else {
+			fmt.Println(path, "mode", stat.Mode())
 		}
-		if s.IsDir() {
-			log.Panicln(path, "is dir")
-		}
-		fmt.Println(path, "ok")
 	}
 
 	b, err := os.ReadFile("/test.txt")
diff --git a/internal/gojs/testdata/main.go b/internal/gojs/testdata/main.go
index 7e161cc7..5e1d25d6 100644
--- a/internal/gojs/testdata/main.go
+++ b/internal/gojs/testdata/main.go
@@ -14,6 +14,7 @@ import (
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/stdio"
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/syscall"
 	"github.com/tetratelabs/wazero/internal/gojs/testdata/time"
+	"github.com/tetratelabs/wazero/internal/gojs/testdata/writefs"
 )
 
 // main includes a registry of all tests to reduce compilation time.
@@ -25,6 +26,8 @@ func main() {
 		crypto.Main()
 	case "fs":
 		fs.Main()
+	case "writefs":
+		writefs.Main()
 	case "gc":
 		gc.Main()
 	case "goroutine":
diff --git a/internal/gojs/testdata/writefs/main.go b/internal/gojs/testdata/writefs/main.go
new file mode 100644
index 00000000..7dcb4634
--- /dev/null
+++ b/internal/gojs/testdata/writefs/main.go
@@ -0,0 +1,46 @@
+package writefs
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"path"
+)
+
+func Main() {
+	dir := path.Join(os.TempDir(), "dir")
+	err := os.Mkdir(dir, 0o700)
+	if err != nil {
+		log.Panicln(err)
+		return
+	}
+	defer os.Remove(dir)
+
+	file := path.Join(dir, "file")
+	err = os.WriteFile(file, []byte{}, 0o600)
+	if err != nil {
+		log.Panicln(err)
+		return
+	}
+	defer os.Remove(file)
+
+	// Ensure stat works, particularly mode.
+	for _, path := range []string{dir, file} {
+		if stat, err := os.Stat(path); err != nil {
+			log.Panicln(err)
+		} else {
+			fmt.Println(path, "mode", stat.Mode())
+		}
+	}
+
+	// should fail due to non-empty dir
+	if err = os.Remove(dir); err == nil {
+		log.Panicln("shouldn't be able to remove", dir)
+	}
+
+	// shouldn't fail
+	if err = os.RemoveAll(dir); err != nil {
+		log.Panicln(err)
+		return
+	}
+}
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index 4f2fb840..f6e55b67 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -104,7 +104,7 @@ func TestEmptyFSContext(t *testing.T) {
 	}
 
 	t.Run("OpenFile doesn't affect state", func(t *testing.T) {
-		fd, err := testFS.OpenFile("foo.txt")
+		fd, err := testFS.OpenFile("foo.txt", os.O_RDONLY, 0)
 		require.Zero(t, fd)
 		require.EqualError(t, err, "open foo.txt: file does not exist")
 
@@ -158,7 +158,7 @@ func TestContext_File(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(b *testing.T) {
-			fd, err := fsc.OpenFile(tc.name)
+			fd, err := fsc.OpenFile(tc.name, os.O_RDONLY, 0)
 			require.NoError(t, err)
 			defer fsc.CloseFile(fd)
 
@@ -190,7 +190,7 @@ func TestContext_Close(t *testing.T) {
 	// Verify base case
 	require.Equal(t, 1+FdRoot, uint32(len(fsc.openedFiles)))
 
-	_, err = fsc.OpenFile("foo")
+	_, err = fsc.OpenFile("foo", os.O_RDONLY, 0)
 	require.NoError(t, err)
 	require.Equal(t, 2+FdRoot, uint32(len(fsc.openedFiles)))
 
@@ -210,7 +210,7 @@ func TestContext_Close_Error(t *testing.T) {
 	require.NoError(t, err)
 
 	// open another file
-	_, err = fsc.OpenFile("foo")
+	_, err = fsc.OpenFile("foo", os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	require.EqualError(t, fsc.Close(testCtx), "error closing")
diff --git a/internal/wasm/call_context_test.go b/internal/wasm/call_context_test.go
index 698f9d19..ce10b47f 100644
--- a/internal/wasm/call_context_test.go
+++ b/internal/wasm/call_context_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"os"
 	"testing"
 
 	"github.com/tetratelabs/wazero/internal/sys"
@@ -146,7 +147,7 @@ func TestCallContext_Close(t *testing.T) {
 		sysCtx := sys.DefaultContext(testfs.FS{"foo": &testfs.File{}})
 		fsCtx := sysCtx.FS()
 
-		_, err := fsCtx.OpenFile("/foo")
+		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
 		require.NoError(t, err)
 
 		m, err := s.Instantiate(context.Background(), ns, &Module{}, t.Name(), sysCtx)
@@ -174,7 +175,7 @@ func TestCallContext_Close(t *testing.T) {
 		sysCtx := sys.DefaultContext(testFS)
 		fsCtx := sysCtx.FS()
 
-		_, err := fsCtx.OpenFile("/foo")
+		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
 		require.NoError(t, err)
 
 		m, err := s.Instantiate(context.Background(), ns, &Module{}, t.Name(), sysCtx)
@@ -242,7 +243,7 @@ func TestCallContext_CallDynamic(t *testing.T) {
 		sysCtx := sys.DefaultContext(testfs.FS{"foo": &testfs.File{}})
 		fsCtx := sysCtx.FS()
 
-		_, err := fsCtx.OpenFile("/foo")
+		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
 		require.NoError(t, err)
 
 		m, err := s.Instantiate(context.Background(), ns, &Module{}, t.Name(), sysCtx)
@@ -270,7 +271,7 @@ func TestCallContext_CallDynamic(t *testing.T) {
 		sysCtx := sys.DefaultContext(testFS)
 		fsCtx := sysCtx.FS()
 
-		_, err := fsCtx.OpenFile("/foo")
+		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
 		require.NoError(t, err)
 
 		m, err := s.Instantiate(context.Background(), ns, &Module{}, t.Name(), sysCtx)
diff --git a/internal/wasm/namespace_test.go b/internal/wasm/namespace_test.go
index 9f3325af..e064885c 100644
--- a/internal/wasm/namespace_test.go
+++ b/internal/wasm/namespace_test.go
@@ -3,6 +3,7 @@ package wasm
 import (
 	"context"
 	"errors"
+	"os"
 	"testing"
 
 	"github.com/tetratelabs/wazero/internal/sys"
@@ -237,7 +238,7 @@ func TestNamespace_CloseWithExitCode(t *testing.T) {
 		sysCtx := sys.DefaultContext(testFS)
 		fsCtx := sysCtx.FS()
 
-		_, err := fsCtx.OpenFile("/foo")
+		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
 		require.NoError(t, err)
 
 		ns, m1, m2 := newTestNamespace()