Repository: tetratelabs/wazero
Pull request #1003 (2023-01-04 01:44:54)

Consolidates internal code to syscallfs
This consolidates internal code to syscallfs, which removes the fs.FS specific path rules, except when adapting one to syscallfs. For example, this allows the underlying filesystem to decide if relative paths are supported or not, as well any EINVAL related concerns.


Patch:
diff --git a/imports/assemblyscript/assemblyscript.go b/imports/assemblyscript/assemblyscript.go
index 554750b1..4e2833ec 100644
--- a/imports/assemblyscript/assemblyscript.go
+++ b/imports/assemblyscript/assemblyscript.go
@@ -165,8 +165,8 @@ func abortWithMessage(ctx context.Context, mod api.Module, stack []uint64) {
 	columnNumber := uint32(stack[3])
 
 	// Don't panic if there was a problem reading the message
-	stderr := fsc.FdWriter(internalsys.FdStderr)
-	if msg, msgOk := readAssemblyScriptString(mem, message); msgOk {
+	stderr := internalsys.WriterForFile(fsc, internalsys.FdStderr)
+	if msg, msgOk := readAssemblyScriptString(mem, message); msgOk && stderr != nil {
 		if fn, fnOk := readAssemblyScriptString(mem, fileName); fnOk {
 			_, _ = fmt.Fprintf(stderr, "%s at %s:%d:%d\n", msg, fn, lineNumber, columnNumber)
 		}
@@ -200,8 +200,7 @@ var traceStdout = &wasm.HostFunc{
 		IsHostFunction: true,
 		GoFunc: api.GoModuleFunc(func(_ context.Context, mod api.Module, stack []uint64) {
 			fsc := mod.(*wasm.CallContext).Sys.FS()
-			stdout := fsc.FdWriter(internalsys.FdStdout)
-			traceTo(mod, stack, stdout)
+			traceTo(mod, stack, internalsys.WriterForFile(fsc, internalsys.FdStdout))
 		}),
 	},
 }
@@ -209,8 +208,7 @@ var traceStdout = &wasm.HostFunc{
 // traceStderr implements trace to the configured Stderr.
 var traceStderr = traceStdout.WithGoModuleFunc(func(_ context.Context, mod api.Module, stack []uint64) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	stderr := fsc.FdWriter(internalsys.FdStderr)
-	traceTo(mod, stack, stderr)
+	traceTo(mod, stack, internalsys.WriterForFile(fsc, internalsys.FdStderr))
 })
 
 // traceTo implements the function "trace" in AssemblyScript. e.g.
@@ -224,6 +222,9 @@ var traceStderr = traceStdout.WithGoModuleFunc(func(_ context.Context, mod api.M
 //
 // See https://github.com/AssemblyScript/assemblyscript/blob/fa14b3b03bd4607efa52aaff3132bea0c03a7989/std/assembly/wasi/index.ts#L61
 func traceTo(mod api.Module, params []uint64, writer io.Writer) {
+	if writer == nil {
+		return // closed
+	}
 	message := uint32(params[0])
 	nArgs := uint32(params[1])
 	arg0 := api.DecodeF64(params[2])
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 50fa306f..2bd28431 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -12,6 +12,7 @@ import (
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/sys"
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 	. "github.com/tetratelabs/wazero/internal/wasi_snapshot_preview1"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
@@ -57,8 +58,8 @@ func fdCloseFn(_ context.Context, mod api.Module, params []uint64) Errno {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 	fd := uint32(params[0])
 
-	if ok := fsc.CloseFile(fd); !ok {
-		return ErrnoBadf
+	if err := fsc.CloseFile(fd); err != nil {
+		return ToErrno(err)
 	}
 	return ErrnoSuccess
 }
@@ -121,20 +122,19 @@ func fdFdstatGetFn(_ context.Context, mod api.Module, params []uint64) Errno {
 		return ErrnoFault
 	}
 
-	stat, err := fsc.StatFile(fd)
-	if err != nil {
-		return ToErrno(err)
-	}
-
-	filetype := getWasiFiletype(stat.Mode())
 	var fdflags uint16
-
-	// Determine if it is writeable
-	if w := fsc.FdWriter(fd); w != nil {
+	var stat fs.FileInfo
+	var err error
+	if f, ok := fsc.LookupFile(fd); !ok {
+		return ErrnoBadf
+	} else if stat, err = f.File.Stat(); err != nil {
+		return ToErrno(err)
+	} else if _, ok := f.File.(io.Writer); ok {
 		// TODO: maybe cache flags to open instead
 		fdflags = FD_APPEND
 	}
 
+	filetype := getWasiFiletype(stat.Mode())
 	writeFdstat(buf, filetype, fdflags)
 
 	return ErrnoSuccess
@@ -233,7 +233,7 @@ func fdFilestatGetFunc(mod api.Module, fd, resultBuf uint32) Errno {
 		return ErrnoFault
 	}
 
-	stat, err := fsc.StatFile(fd)
+	stat, err := sys.StatFile(fsc, fd)
 	if err != nil {
 		return ToErrno(err)
 	}
@@ -358,14 +358,16 @@ func fdPrestatGetFn(_ context.Context, mod api.Module, params []uint64) Errno {
 		return ErrnoBadf
 	}
 
-	entry, ok := fsc.OpenedFile(fd)
-	if !ok {
-		return ErrnoBadf
+	var name string
+	if f, ok := fsc.LookupFile(fd); !ok {
+		return ErrnoBadf // closed
+	} else {
+		name = f.Name
 	}
 
 	// Upper 32-bits are zero because...
 	// * Zero-value 8-bit tag, and 3-byte zero-value padding
-	prestat := uint64(len(entry.Name) << 32)
+	prestat := uint64(len(name) << 32)
 	if !mod.Memory().WriteUint64Le(resultPrestat, prestat) {
 		return ErrnoFault
 	}
@@ -417,17 +419,19 @@ func fdPrestatDirNameFn(_ context.Context, mod api.Module, params []uint64) Errn
 		return ErrnoBadf
 	}
 
-	f, ok := fsc.OpenedFile(fd)
-	if !ok {
-		return ErrnoBadf
+	var name string
+	if f, ok := fsc.LookupFile(fd); !ok {
+		return ErrnoBadf // closed
+	} else {
+		name = f.Name
 	}
 
 	// Some runtimes may have another semantics. See /RATIONALE.md
-	if uint32(len(f.Name)) < pathLen {
+	if uint32(len(name)) < pathLen {
 		return ErrnoNametoolong
 	}
 
-	if !mod.Memory().Write(path, []byte(f.Name)[:pathLen]) {
+	if !mod.Memory().Write(path, []byte(name)[:pathLen]) {
 		return ErrnoFault
 	}
 	return ErrnoSuccess
@@ -519,21 +523,21 @@ func fdReadOrPread(mod api.Module, params []uint64, isPread bool) Errno {
 		resultNread = uint32(params[3])
 	}
 
-	r := fsc.FdReader(fd)
-	if r == nil {
+	r, ok := fsc.LookupFile(fd)
+	if !ok {
 		return ErrnoBadf
 	}
 
-	read := r.Read
+	read := r.File.Read
 	if isPread {
-		if ra, ok := r.(io.ReaderAt); ok {
+		if ra, ok := r.File.(io.ReaderAt); ok {
 			// ReadAt is the Go equivalent to pread.
 			read = func(p []byte) (int, error) {
 				n, err := ra.ReadAt(p, offset)
 				offset += int64(n)
 				return n, err
 			}
-		} else if s, ok := r.(io.Seeker); ok {
+		} else if s, ok := r.File.(io.Seeker); ok {
 			// Unfortunately, it is often not supported.
 			// See /RATIONALE.md "fd_pread: io.Seeker fallback when io.ReaderAt is not supported"
 			initialOffset, err := s.Seek(0, io.SeekCurrent)
@@ -853,7 +857,7 @@ func writeDirent(buf []byte, dNext uint64, dNamlen uint32, dType bool) {
 
 // openedDir returns the directory and ErrnoSuccess if the fd points to a readable directory.
 func openedDir(fsc *sys.FSContext, fd uint32) (fs.ReadDirFile, *sys.ReadDir, Errno) {
-	if f, ok := fsc.OpenedFile(fd); !ok {
+	if f, ok := fsc.LookupFile(fd); !ok {
 		return nil, nil, ErrnoBadf
 	} else if d, ok := f.File.(fs.ReadDirFile); !ok {
 		// fd_readdir docs don't indicate whether to return ErrnoNotdir or
@@ -933,7 +937,7 @@ func fdSeekFn(_ context.Context, mod api.Module, params []uint64) Errno {
 
 	var seeker io.Seeker
 	// Check to see if the file descriptor is available
-	if f, ok := fsc.OpenedFile(fd); !ok {
+	if f, ok := fsc.LookupFile(fd); !ok {
 		return ErrnoBadf
 		// fs.FS doesn't declare io.Seeker, but implementations such as os.File implement it.
 	} else if seeker, ok = f.File.(io.Seeker); !ok {
@@ -1040,7 +1044,7 @@ func fdWriteFn(_ context.Context, mod api.Module, params []uint64) Errno {
 	iovsCount := uint32(params[2])
 	resultNwritten := uint32(params[3])
 
-	writer := fsc.FdWriter(fd)
+	writer := sys.WriterForFile(fsc, fd)
 	if writer == nil {
 		return ErrnoBadf
 	}
@@ -1118,10 +1122,8 @@ func pathCreateDirectoryFn(_ context.Context, mod api.Module, params []uint64) E
 		return errno
 	}
 
-	if fd, err := fsc.Mkdir(pathName, 0o700); err != nil {
+	if err := fsc.FS().Mkdir(pathName, 0o700); err != nil {
 		return ToErrno(err)
-	} else {
-		_ = fsc.CloseFile(fd)
 	}
 
 	return ErrnoSuccess
@@ -1183,20 +1185,23 @@ func pathFilestatGetFn(_ context.Context, mod api.Module, params []uint64) Errno
 	}
 	pathName := string(b)
 
-	// Prepend the path if necessary.
-	if dir, ok := fsc.OpenedFile(dirfd); !ok {
-		return ErrnoBadf
-	} else if _, ok := dir.File.(fs.ReadDirFile); !ok {
-		return ErrnoNotdir // TODO: cache filetype instead of poking.
-	} else {
+	// os.File implements ReadDirFile, so we have to check with stat.
+	stat, err := sys.StatFile(fsc, dirfd)
+	if err != nil {
+		return ToErrno(err)
+	}
+
+	if !stat.IsDir() {
+		return ErrnoNotdir
+	} else { // prepend the name
 		// TODO: consolidate "at" logic with path_open as same issues occur.
-		pathName = path.Join(dir.Name, pathName)
+		pathName = path.Join(stat.Name(), pathName)
 	}
 
 	// Stat the file without allocating a file descriptor
-	stat, errnoResult := statFile(fsc, pathName)
-	if errnoResult != ErrnoSuccess {
-		return errnoResult
+	stat, err = syscallfs.StatPath(fsc.FS(), pathName)
+	if err != nil {
+		return ToErrno(err)
 	}
 
 	// Write the stat result to memory
@@ -1315,22 +1320,20 @@ func pathOpenFn(_ context.Context, mod api.Module, params []uint64) Errno {
 
 	fileOpenFlags, isDir := openFlags(oflags, fdflags)
 
-	var newFD uint32
-	var err error
 	if isDir && oflags&O_CREAT != 0 {
 		return ErrnoInval // use pathCreateDirectory!
-	} else {
-		newFD, err = fsc.OpenFile(pathName, fileOpenFlags, 0o600)
 	}
 
+	newFD, err := fsc.OpenFile(pathName, fileOpenFlags, 0o600)
 	if err != nil {
 		return ToErrno(err)
 	}
 
 	// Check any flags that require the file to evaluate.
 	if isDir {
-		if errno := failIfNotDirectory(fsc, newFD); errno != ErrnoSuccess {
-			return errno
+		if stat, err := sys.StatFile(fsc, newFD); err != nil || !stat.IsDir() {
+			_ = fsc.CloseFile(newFD)
+			return ErrnoNotdir
 		}
 	}
 
@@ -1358,8 +1361,8 @@ func atPath(fsc *sys.FSContext, mem api.Memory, dirFd, path, pathLen uint32) (st
 		// "/tmp/foo/bar" not "/bar".
 	}
 
-	if _, ok := fsc.OpenedFile(dirFd); !ok {
-		return "", ErrnoBadf
+	if _, ok := fsc.LookupFile(dirFd); !ok {
+		return "", ErrnoBadf // closed
 	}
 
 	b, ok := mem.Read(path, pathLen)
@@ -1392,17 +1395,6 @@ func openFlags(oflags, fdflags uint16) (openFlags int, isDir bool) {
 	return
 }
 
-func failIfNotDirectory(fsc *sys.FSContext, fd uint32) Errno {
-	// Lookup the previous file
-	if f, ok := fsc.OpenedFile(fd); !ok {
-		return ErrnoBadf
-	} else if _, ok := f.File.(fs.ReadDirFile); !ok {
-		_ = fsc.CloseFile(fd)
-		return ErrnoNotdir
-	}
-	return ErrnoSuccess
-}
-
 // pathReadlink is the WASI function named PathReadlinkName that reads the
 // contents of a symbolic link.
 //
@@ -1453,7 +1445,7 @@ func pathRemoveDirectoryFn(_ context.Context, mod api.Module, params []uint64) E
 		return errno
 	}
 
-	if err := fsc.Rmdir(pathName); err != nil {
+	if err := fsc.FS().Rmdir(pathName); err != nil {
 		return ToErrno(err)
 	}
 
@@ -1512,7 +1504,7 @@ func pathRenameFn(_ context.Context, mod api.Module, params []uint64) Errno {
 		return errno
 	}
 
-	if err := fsc.Rename(oldPathName, newPathName); err != nil {
+	if err := fsc.FS().Rename(oldPathName, newPathName); err != nil {
 		return ToErrno(err)
 	}
 
@@ -1568,20 +1560,9 @@ func pathUnlinkFileFn(_ context.Context, mod api.Module, params []uint64) Errno
 		return errno
 	}
 
-	if err := fsc.Unlink(pathName); err != nil {
+	if err := fsc.FS().Unlink(pathName); err != nil {
 		return ToErrno(err)
 	}
 
 	return ErrnoSuccess
 }
-
-// statFile attempts to stat the file at the given path. Errors coerce to WASI
-// Errno.
-func statFile(fsc *sys.FSContext, name string) (stat fs.FileInfo, errno Errno) {
-	var err error
-	stat, err = fsc.StatPath(name)
-	if err != nil {
-		errno = ToErrno(err)
-	}
-	return
-}
diff --git a/imports/wasi_snapshot_preview1/poll.go b/imports/wasi_snapshot_preview1/poll.go
index 367c517d..e3abb924 100644
--- a/imports/wasi_snapshot_preview1/poll.go
+++ b/imports/wasi_snapshot_preview1/poll.go
@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"github.com/tetratelabs/wazero/api"
+	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	. "github.com/tetratelabs/wazero/internal/wasi_snapshot_preview1"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
@@ -132,9 +133,11 @@ func processFDEvent(mod api.Module, eventType byte, inBuf []byte) Errno {
 	// Choose the best error, which falls back to unsupported, until we support
 	// files.
 	errno := ErrnoNotsup
-	if eventType == EventTypeFdRead && fsc.FdReader(fd) == nil {
-		errno = ErrnoBadf
-	} else if eventType == EventTypeFdWrite && fsc.FdWriter(fd) == nil {
+	if eventType == EventTypeFdRead {
+		if _, ok := fsc.LookupFile(fd); !ok {
+			errno = ErrnoBadf
+		}
+	} else if eventType == EventTypeFdWrite && internalsys.WriterForFile(fsc, fd) == nil {
 		errno = ErrnoBadf
 	}
 
diff --git a/internal/gojs/fs.go b/internal/gojs/fs.go
index 795e199a..8c9bfb25 100644
--- a/internal/gojs/fs.go
+++ b/internal/gojs/fs.go
@@ -13,6 +13,7 @@ import (
 	"github.com/tetratelabs/wazero/internal/gojs/goos"
 	"github.com/tetratelabs/wazero/internal/platform"
 	internalsys "github.com/tetratelabs/wazero/internal/sys"
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
 
@@ -93,6 +94,7 @@ func (jsfsOpen) invoke(ctx context.Context, mod api.Module, args ...interface{})
 	callback := args[3].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
+
 	fd, err := fsc.OpenFile(path, int(flags), fs.FileMode(perm))
 
 	return callback.invoke(ctx, mod, goos.RefJsfs, err, fd) // note: error first
@@ -112,13 +114,13 @@ 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) {
+func syscallStat(mod api.Module, path string) (*jsSt, error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	if fd, err := fsc.OpenFile(name, os.O_RDONLY, 0); err != nil {
+
+	if stat, err := syscallfs.StatPath(fsc.FS(), path); err != nil {
 		return nil, err
 	} else {
-		defer fsc.CloseFile(fd)
-		return syscallFstat(fsc, fd)
+		return newJsSt(stat), nil
 	}
 }
 
@@ -168,21 +170,23 @@ const (
 
 // syscallFstat is like syscall.Fstat
 func syscallFstat(fsc *internalsys.FSContext, fd uint32) (*jsSt, error) {
-	if f, ok := fsc.OpenedFile(fd); !ok {
-		return nil, syscall.EBADF
-	} else if stat, err := f.File.Stat(); err != nil {
+	stat, err := internalsys.StatFile(fsc, fd)
+	if err != nil {
 		return nil, err
-	} else {
-		ret := &jsSt{}
-		ret.isDir = stat.IsDir()
-		ret.mode = getJsMode(stat.Mode())
-		ret.size = stat.Size()
-		atimeNsec, mtimeNsec, ctimeNsec := platform.StatTimes(stat)
-		ret.atimeMs = atimeNsec / 1e6
-		ret.mtimeMs = mtimeNsec / 1e6
-		ret.ctimeMs = ctimeNsec / 1e6
-		return ret, nil
 	}
+	return newJsSt(stat), nil
+}
+
+func newJsSt(stat fs.FileInfo) *jsSt {
+	ret := &jsSt{}
+	ret.isDir = stat.IsDir()
+	ret.mode = getJsMode(stat.Mode())
+	ret.size = stat.Size()
+	atimeNsec, mtimeNsec, ctimeNsec := platform.StatTimes(stat)
+	ret.atimeMs = atimeNsec / 1e6
+	ret.mtimeMs = mtimeNsec / 1e6
+	ret.ctimeMs = ctimeNsec / 1e6
+	return ret
 }
 
 // getJsMode is required because the mode property read in `GOOS=js` is
@@ -230,10 +234,7 @@ func (jsfsClose) invoke(ctx context.Context, mod api.Module, args ...interface{}
 	fd := toUint32(args[0])
 	callback := args[1].(funcWrapper)
 
-	var err error
-	if ok := fsc.CloseFile(fd); !ok {
-		err = syscall.EBADF // already closed
-	}
+	err := fsc.CloseFile(fd)
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
@@ -263,13 +264,13 @@ func (jsfsRead) invoke(ctx context.Context, mod api.Module, args ...interface{})
 func syscallRead(mod api.Module, fd uint32, offset interface{}, p []byte) (n uint32, err error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	r := fsc.FdReader(fd)
-	if r == nil {
+	f, ok := fsc.LookupFile(fd)
+	if !ok {
 		err = syscall.EBADF
 	}
 
 	if offset != nil {
-		if s, ok := r.(io.Seeker); ok {
+		if s, ok := f.File.(io.Seeker); ok {
 			if _, err := s.Seek(toInt64(offset), io.SeekStart); err != nil {
 				return 0, err
 			}
@@ -278,7 +279,7 @@ func syscallRead(mod api.Module, fd uint32, offset interface{}, p []byte) (n uin
 		}
 	}
 
-	if nRead, e := r.Read(p); e == nil || e == io.EOF {
+	if nRead, e := f.File.Read(p); e == nil || e == io.EOF {
 		// fs_js.go cannot parse io.EOF so coerce it to nil.
 		// See https://github.com/golang/go/issues/43913
 		n = uint32(nRead)
@@ -317,7 +318,7 @@ func (jsfsWrite) invoke(ctx context.Context, mod api.Module, args ...interface{}
 func syscallWrite(mod api.Module, fd uint32, offset interface{}, p []byte) (n uint32, err error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	if writer := fsc.FdWriter(fd); writer == nil {
+	if writer := internalsys.WriterForFile(fsc, fd); writer == nil {
 		err = syscall.EBADF
 	} else if nWritten, e := writer.Write(p); e == nil || e == io.EOF {
 		// fs_js.go cannot parse io.EOF so coerce it to nil.
@@ -346,15 +347,14 @@ func (jsfsReaddir) invoke(ctx context.Context, mod api.Module, args ...interface
 func syscallReaddir(_ context.Context, mod api.Module, name string) (*objectArray, error) {
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fd, err := fsc.OpenFile(name, os.O_RDONLY, 0)
+	// don't allocate a file descriptor
+	f, err := fsc.FS().OpenFile(name, os.O_RDONLY, 0)
 	if err != nil {
 		return nil, err
 	}
-	defer fsc.CloseFile(fd)
+	defer f.Close() //nolint
 
-	if f, ok := fsc.OpenedFile(fd); !ok {
-		return nil, syscall.EBADF
-	} else if d, ok := f.File.(fs.ReadDirFile); !ok {
+	if d, ok := f.(fs.ReadDirFile); !ok {
 		return nil, syscall.ENOTDIR
 	} else if l, err := d.ReadDir(-1); err != nil {
 		return nil, err
@@ -399,20 +399,11 @@ func (processCwd) invoke(ctx context.Context, _ api.Module, _ ...interface{}) (i
 type processChdir struct{}
 
 func (processChdir) invoke(ctx context.Context, mod api.Module, args ...interface{}) (interface{}, error) {
-	fsc := mod.(*wasm.CallContext).Sys.FS()
-
 	path := args[0].(string)
 
-	// TODO: refactor so that sys has path-based ops, also needed in WASI.
-	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
-	} else if s, err := f.File.Stat(); err != nil {
-		fsc.CloseFile(fd)
-		return nil, syscall.ENOENT
-	} else if !s.IsDir() {
-		fsc.CloseFile(fd)
+	if s, err := syscallStat(mod, path); err != nil {
+		return nil, mapJSError(err)
+	} else if !s.isDir {
 		return nil, syscall.ENOTDIR
 	} else {
 		getState(ctx).cwd = path
@@ -431,7 +422,12 @@ func (jsfsMkdir) invoke(ctx context.Context, mod api.Module, args ...interface{}
 	callback := args[2].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	fd, err := fsc.Mkdir(path, fs.FileMode(perm))
+
+	var fd uint32
+	var err error
+	if err = fsc.FS().Mkdir(path, fs.FileMode(perm)); err == nil {
+		fd, err = fsc.OpenFile(path, os.O_RDONLY, 0)
+	}
 
 	return callback.invoke(ctx, mod, goos.RefJsfs, err, fd) // note: error first
 }
@@ -446,7 +442,7 @@ func (jsfsRmdir) invoke(ctx context.Context, mod api.Module, args ...interface{}
 	callback := args[1].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	err := fsc.Rmdir(path)
+	err := fsc.FS().Rmdir(path)
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
@@ -462,7 +458,7 @@ func (jsfsRename) invoke(ctx context.Context, mod api.Module, args ...interface{
 	callback := args[2].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	err := fsc.Rename(from, to)
+	err := fsc.FS().Rename(from, to)
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
@@ -477,7 +473,7 @@ func (jsfsUnlink) invoke(ctx context.Context, mod api.Module, args ...interface{
 	callback := args[1].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	err := fsc.Unlink(path)
+	err := fsc.FS().Unlink(path)
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
@@ -494,7 +490,7 @@ func (jsfsUtimes) invoke(ctx context.Context, mod api.Module, args ...interface{
 	callback := args[3].(funcWrapper)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	err := fsc.Utimes(path, atimeSec*1e9, mtimeSec*1e9)
+	err := fsc.FS().Utimes(path, atimeSec*1e9, mtimeSec*1e9)
 
 	return jsfsInvoke(ctx, mod, callback, err)
 }
diff --git a/internal/gojs/runtime.go b/internal/gojs/runtime.go
index 06b6b1c0..213de99c 100644
--- a/internal/gojs/runtime.go
+++ b/internal/gojs/runtime.go
@@ -8,6 +8,7 @@ import (
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/gojs/custom"
 	"github.com/tetratelabs/wazero/internal/gojs/goarch"
+	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/wasm"
 )
 
@@ -39,12 +40,9 @@ func wasmWrite(_ context.Context, mod api.Module, stack goarch.Stack) {
 	p := stack.ParamBytes(mod.Memory(), 1 /*, 2 */)
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
-	writer := fsc.FdWriter(fd)
-	if writer == nil {
-		panic(fmt.Errorf("unexpected fd %d", fd))
-	}
-
-	if _, err := writer.Write(p); err != nil {
+	if writer := internalsys.WriterForFile(fsc, fd); writer == nil {
+		panic(fmt.Errorf("fd %d invalid", fd))
+	} else if _, err := writer.Write(p); err != nil {
 		panic(fmt.Errorf("error writing p: %w", err))
 	}
 }
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 87853977..e3d2142c 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -38,22 +38,6 @@ const (
 	modeCharDevice = uint32(fs.ModeCharDevice | 0o640)
 )
 
-// EmptyFS is exported to special-case an empty file system.
-var EmptyFS = &emptyFS{}
-
-type emptyFS struct{}
-
-// compile-time check to ensure emptyFS implements fs.FS
-var _ fs.FS = &emptyFS{}
-
-// Open implements the same method as documented on fs.FS.
-func (f *emptyFS) Open(name string) (fs.File, error) {
-	if !fs.ValidPath(name) {
-		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
-	}
-	return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
-}
-
 // An emptyRootDir is a fake "/" directory.
 type emptyRootDir struct{}
 
@@ -175,7 +159,7 @@ type ReadDir struct {
 
 type FSContext struct {
 	// fs is the root ("/") mount.
-	fs fs.FS
+	fs syscallfs.FS
 
 	// openedFiles is a map of file descriptor numbers (>=FdRoot) to open files
 	// (or directories) and defaults to empty.
@@ -190,27 +174,18 @@ var errNotDir = errors.New("not a directory")
 // Otherwise, `root` is assigned file descriptor FdRoot and the returned
 // context can open files in that file system. Any error on opening "." is
 // returned.
-func NewFSContext(stdin io.Reader, stdout, stderr io.Writer, root fs.FS) (fsc *FSContext, err error) {
+func NewFSContext(stdin io.Reader, stdout, stderr io.Writer, root syscallfs.FS) (fsc *FSContext, err error) {
 	fsc = &FSContext{fs: root}
 	fsc.openedFiles.Insert(stdinReader(stdin))
 	fsc.openedFiles.Insert(stdioWriter(stdout, noopStdoutStat))
 	fsc.openedFiles.Insert(stdioWriter(stderr, noopStderrStat))
 
-	if root == EmptyFS {
+	if root == syscallfs.EmptyFS {
 		return fsc, nil
 	}
 
-	// Open the root directory by using "." as "/" is not relevant in fs.FS.
-	// This not only validates the file system, but also allows us to test if
-	// this is a real file or not. ex. `file.(*os.File)`.
-	//
-	// Note: We don't use fs.ReadDirFS as this isn't implemented by os.DirFS.
-	var rootDir fs.File
-	if sfs, ok := root.(syscallfs.FS); ok {
-		rootDir, err = sfs.OpenFile(".", os.O_RDONLY, 0)
-	} else {
-		rootDir, err = root.Open(".")
-	}
+	// Test if this is a real file or not. ex. `file.(*os.File)`.
+	rootDir, err := root.OpenFile(".", os.O_RDONLY, 0)
 	if err != nil {
 		// This could fail because someone made a special-purpose file system,
 		// which only passes certain filenames and not ".".
@@ -255,19 +230,6 @@ func stdioStat(f interface{}, defaultStat stdioFileInfo) fs.FileInfo {
 	return defaultStat
 }
 
-// OpenedFile returns a file and true if it was opened or nil and false, if syscall.EBADF.
-func (c *FSContext) OpenedFile(fd uint32) (*FileEntry, bool) {
-	return c.openedFiles.Lookup(fd)
-}
-
-func (c *FSContext) StatFile(fd uint32) (fs.FileInfo, error) {
-	f, ok := c.openedFiles.Lookup(fd)
-	if !ok {
-		return nil, syscall.EBADF
-	}
-	return f.File.Stat()
-}
-
 // fileModeStat is a fake fs.FileInfo which only returns its mode.
 // This is used for character devices.
 type fileModeStat fs.FileMode
@@ -281,156 +243,37 @@ 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.(syscallfs.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
+// FS returns the underlying filesystem. Any files that should be added to the
+// table should be inserted via InsertFile.
+func (c *FSContext) FS() syscallfs.FS {
+	return c.fs
 }
 
-// OpenFile is like syscall.Open and returns the file descriptor of the new file or an error.
-func (c *FSContext) OpenFile(path string, flags int, perm fs.FileMode) (newFD uint32, err error) {
-	var f fs.File
-	if wfs, ok := c.fs.(syscallfs.FS); ok {
-		path = c.cleanPath(path)
-		f, err = wfs.OpenFile(path, flags, perm)
-	} else {
-		// While os.Open says it is read-only, in reality the files returned
-		// are often writable. Fail only on the flags which won't work.
-		switch {
-		case flags&os.O_APPEND != 0:
-			fallthrough
-		case flags&os.O_CREATE != 0:
-			fallthrough
-		case flags&os.O_TRUNC != 0:
-			return 0, syscall.ENOSYS
-		default:
-			// only time fs.FS is used
-			f, err = c.fs.Open(c.cleanPath(path))
-		}
-	}
-
-	if err != nil {
+// OpenFile opens the file into the table and returns its file descriptor.
+// The result must be closed by CloseFile or Close.
+func (c *FSContext) OpenFile(path string, flag int, perm fs.FileMode) (uint32, error) {
+	if f, err := c.fs.OpenFile(path, flag, perm); err != nil {
 		return 0, err
-	}
-
-	newFD = c.openedFiles.Insert(&FileEntry{Name: pathutil.Base(path), File: f})
-	return newFD, nil
-}
-
-// Rmdir is like syscall.Rmdir.
-func (c *FSContext) Rmdir(path string) (err error) {
-	if wfs, ok := c.fs.(syscallfs.FS); ok {
-		path = c.cleanPath(path)
-		return wfs.Rmdir(path)
-	}
-	err = syscall.ENOSYS
-	return
-}
-
-func (c *FSContext) StatPath(path string) (fs.FileInfo, error) {
-	fd, err := c.OpenFile(path, os.O_RDONLY, 0)
-	if err != nil {
-		return nil, err
-	}
-	defer c.CloseFile(fd)
-	return c.StatFile(fd)
-}
-
-// Rename is like syscall.Rename.
-func (c *FSContext) Rename(from, to string) (err error) {
-	if wfs, ok := c.fs.(syscallfs.FS); ok {
-		from = c.cleanPath(from)
-		to = c.cleanPath(to)
-		return wfs.Rename(from, to)
-	}
-	err = syscall.ENOSYS
-	return
-}
-
-// Unlink is like syscall.Unlink.
-func (c *FSContext) Unlink(path string) (err error) {
-	if wfs, ok := c.fs.(syscallfs.FS); ok {
-		path = c.cleanPath(path)
-		return wfs.Unlink(path)
-	}
-	err = syscall.ENOSYS
-	return
-}
-
-// Utimes is like syscall.Utimes.
-func (c *FSContext) Utimes(path string, atimeNsec, mtimeNsec int64) (err error) {
-	if wfs, ok := c.fs.(syscallfs.FS); ok {
-		path = c.cleanPath(path)
-		return wfs.Utimes(path, atimeNsec, mtimeNsec)
-	}
-	err = syscall.ENOSYS
-	return
-}
-
-func (c *FSContext) cleanPath(path string) string {
-	if len(path) == 0 {
-		return path
-	}
-	// fs.ValidFile cannot be rooted (start with '/')
-	cleaned := path
-	if path[0] == '/' {
-		cleaned = path[1:]
-	}
-	cleaned = pathutil.Clean(cleaned) // e.g. "sub/." -> "sub"
-	return cleaned
-}
-
-// FdWriter returns a valid writer for the given file descriptor or nil if syscall.EBADF.
-func (c *FSContext) FdWriter(fd uint32) io.Writer {
-	// Check to see if the file descriptor is available
-	if f, ok := c.openedFiles.Lookup(fd); !ok {
-		return nil
-	} else if writer, ok := f.File.(io.Writer); !ok {
-		// Go's syscall.Write also returns EBADF if the FD is present, but not writeable
-		return nil
 	} else {
-		return writer
+		newFD := c.openedFiles.Insert(&FileEntry{Name: pathutil.Base(path), File: f})
+		return newFD, nil
 	}
 }
 
-// FdReader returns a valid reader for the given file descriptor or nil if syscall.EBADF.
-func (c *FSContext) FdReader(fd uint32) io.Reader {
-	switch fd {
-	case FdStdout, FdStderr:
-		return nil // writer, not a readable file.
-	case FdRoot:
-		return nil // directory, not a readable file.
-	}
-
-	if f, ok := c.openedFiles.Lookup(fd); !ok {
-		return nil // TODO: could be a directory not a file.
-	} else {
-		return f.File
-	}
+// LookupFile returns a file if it is in the table.
+func (c *FSContext) LookupFile(fd uint32) (*FileEntry, bool) {
+	f, ok := c.openedFiles.Lookup(fd)
+	return f, ok
 }
 
-// CloseFile returns true if a file was opened and closed without error, or false if syscall.EBADF.
-func (c *FSContext) CloseFile(fd uint32) bool {
+// CloseFile returns any error closing the existing file.
+func (c *FSContext) CloseFile(fd uint32) error {
 	f, ok := c.openedFiles.Lookup(fd)
 	if !ok {
-		return false
+		return syscall.EBADF
 	}
 	c.openedFiles.Delete(fd)
-
-	if err := f.File.Close(); err != nil {
-		return false
-	}
-	return true
+	return f.File.Close()
 }
 
 // Close implements api.Closer
@@ -447,3 +290,23 @@ func (c *FSContext) Close(context.Context) (err error) {
 	c.openedFiles = FileTable{}
 	return
 }
+
+// StatFile is a convenience that calls FSContext.LookupFile then fs.File Stat.
+// syscall.EBADF is returned on lookup failure.
+func StatFile(fsc *FSContext, fd uint32) (stat fs.FileInfo, err error) {
+	if f, ok := fsc.LookupFile(fd); !ok {
+		err = syscall.EBADF
+	} else {
+		stat, err = f.File.Stat()
+	}
+	return
+}
+
+// WriterForFile returns a writer for the given file descriptor or nil if not
+// opened or not writeable (e.g. a directory or a file not opened for writes).
+func WriterForFile(fsc *FSContext, fd uint32) (writer io.Writer) {
+	if f, ok := fsc.LookupFile(fd); ok {
+		writer = f.File.(io.Writer)
+	}
+	return
+}
diff --git a/internal/sys/sys.go b/internal/sys/sys.go
index c678f801..2b19976e 100644
--- a/internal/sys/sys.go
+++ b/internal/sys/sys.go
@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 	"github.com/tetratelabs/wazero/sys"
 )
 
@@ -181,9 +182,9 @@ func NewContext(
 	}
 
 	if fs != nil {
-		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, fs)
+		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, syscallfs.Adapt(fs))
 	} else {
-		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, EmptyFS)
+		sysCtx.fsc, err = NewFSContext(stdin, stdout, stderr, syscallfs.EmptyFS)
 	}
 
 	return
diff --git a/internal/syscallfs/adapter.go b/internal/syscallfs/adapter.go
new file mode 100644
index 00000000..fc129acf
--- /dev/null
+++ b/internal/syscallfs/adapter.go
@@ -0,0 +1,77 @@
+package syscallfs
+
+import (
+	"fmt"
+	"io/fs"
+	"os"
+	pathutil "path"
+	"syscall"
+)
+
+// Adapt returns a read-only FS unless the input is already one.
+func Adapt(fs fs.FS) FS {
+	if sys, ok := fs.(FS); ok {
+		return sys
+	}
+	return &adapter{fs}
+}
+
+type adapter struct{ fs fs.FS }
+
+// Open implements the same method as documented on fs.FS
+func (ro *adapter) Open(name string) (fs.File, error) {
+	panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name))
+}
+
+// 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
+	}
+	return maskForReads(f), nil
+}
+
+func cleanPath(name string) string {
+	if len(name) == 0 {
+		return name
+	}
+	// fs.ValidFile cannot be rooted (start with '/')
+	cleaned := name
+	if name[0] == '/' {
+		cleaned = name[1:]
+	}
+	cleaned = pathutil.Clean(cleaned) // e.g. "sub/." -> "sub"
+	return cleaned
+}
+
+// Mkdir implements FS.Mkdir
+func (ro *adapter) Mkdir(path string, perm fs.FileMode) error {
+	return syscall.ENOSYS
+}
+
+// Rename implements FS.Rename
+func (ro *adapter) Rename(from, to string) error {
+	return syscall.ENOSYS
+}
+
+// Rmdir implements FS.Rmdir
+func (ro *adapter) Rmdir(path string) error {
+	return syscall.ENOSYS
+}
+
+// Unlink implements FS.Unlink
+func (ro *adapter) Unlink(path string) error {
+	return syscall.ENOSYS
+}
+
+// Utimes implements FS.Utimes
+func (ro *adapter) Utimes(path string, atimeNsec, mtimeNsec int64) error {
+	return syscall.ENOSYS
+}
diff --git a/internal/syscallfs/dirfs.go b/internal/syscallfs/dirfs.go
index 1ed4f7b1..150be6a8 100644
--- a/internal/syscallfs/dirfs.go
+++ b/internal/syscallfs/dirfs.go
@@ -29,10 +29,6 @@ func (dir dirFS) Open(name string) (fs.File, error) {
 
 // OpenFile implements FS.OpenFile
 func (dir dirFS) 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}
-	}
-
 	f, err := os.OpenFile(path.Join(string(dir), name), flag, perm)
 	if err != nil {
 		return nil, err
@@ -46,23 +42,12 @@ func (dir dirFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, err
 
 // Mkdir implements FS.Mkdir
 func (dir dirFS) Mkdir(name string, perm fs.FileMode) error {
-	if !fs.ValidPath(name) {
-		return &fs.PathError{Op: "mkdir", Path: name, Err: fs.ErrInvalid}
-	}
-
 	err := os.Mkdir(path.Join(string(dir), name), perm)
-
 	return adjustMkdirError(err)
 }
 
 // Rename implements FS.Rename
 func (dir dirFS) Rename(from, to string) error {
-	if !fs.ValidPath(from) {
-		return syscall.EINVAL
-	}
-	if !fs.ValidPath(to) {
-		return syscall.EINVAL
-	}
 	if from == to {
 		return nil
 	}
@@ -71,31 +56,18 @@ func (dir dirFS) Rename(from, to string) error {
 
 // Rmdir implements FS.Rmdir
 func (dir dirFS) Rmdir(name string) error {
-	if !fs.ValidPath(name) {
-		return syscall.EINVAL
-	}
-
 	err := syscall.Rmdir(path.Join(string(dir), name))
-
 	return adjustRmdirError(err)
 }
 
 // Unlink implements FS.Unlink
 func (dir dirFS) Unlink(name string) error {
-	if !fs.ValidPath(name) {
-		return syscall.EINVAL
-	}
-
 	err := syscall.Unlink(path.Join(string(dir), name))
-
 	return adjustUnlinkError(err)
 }
 
 // Utimes implements FS.Utimes
 func (dir dirFS) Utimes(name string, atimeNsec, mtimeNsec int64) error {
-	if !fs.ValidPath(name) {
-		return syscall.EINVAL
-	}
 	return syscall.UtimesNano(path.Join(string(dir), name), []syscall.Timespec{
 		syscall.NsecToTimespec(atimeNsec),
 		syscall.NsecToTimespec(mtimeNsec),
diff --git a/internal/syscallfs/empty.go b/internal/syscallfs/empty.go
new file mode 100644
index 00000000..14fb233e
--- /dev/null
+++ b/internal/syscallfs/empty.go
@@ -0,0 +1,48 @@
+package syscallfs
+
+import (
+	"fmt"
+	"io/fs"
+	"syscall"
+)
+
+// EmptyFS is an FS that returns syscall.ENOENT for all read functions, and
+// syscall.ENOSYS otherwise.
+var EmptyFS FS = unsupported{}
+
+type unsupported struct{}
+
+// Open implements the same method as documented on fs.FS
+func (unsupported) Open(name string) (fs.File, error) {
+	panic(fmt.Errorf("unexpected to call fs.FS.Open(%s)", name))
+}
+
+// OpenFile implements FS.OpenFile
+func (unsupported) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
+	return nil, &fs.PathError{Op: "open", Path: path, Err: syscall.ENOENT}
+}
+
+// Mkdir implements FS.Mkdir
+func (unsupported) Mkdir(path string, perm fs.FileMode) error {
+	return syscall.ENOSYS
+}
+
+// Rename implements FS.Rename
+func (unsupported) Rename(from, to string) error {
+	return syscall.ENOSYS
+}
+
+// Rmdir implements FS.Rmdir
+func (unsupported) Rmdir(path string) error {
+	return syscall.ENOSYS
+}
+
+// Unlink implements FS.Unlink
+func (unsupported) Unlink(path string) error {
+	return syscall.ENOSYS
+}
+
+// Utimes implements FS.Utimes
+func (unsupported) Utimes(path string, atimeNsec, mtimeNsec int64) error {
+	return syscall.ENOSYS
+}
diff --git a/internal/syscallfs/readfs.go b/internal/syscallfs/readfs.go
index 5d3a599c..3d9a4626 100644
--- a/internal/syscallfs/readfs.go
+++ b/internal/syscallfs/readfs.go
@@ -22,15 +22,15 @@ func (ro *readFS) Open(name string) (fs.File, error) {
 }
 
 // OpenFile implements FS.OpenFile
-func (ro *readFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
+func (ro *readFS) OpenFile(path string, flag int, perm fs.FileMode) (fs.File, error) {
 	if flag == 0 || flag == os.O_RDONLY {
-		return ro.fs.OpenFile(name, flag, perm)
+		return ro.fs.OpenFile(path, flag, perm)
 	}
 	return nil, syscall.ENOSYS
 }
 
 // Mkdir implements FS.Mkdir
-func (ro *readFS) Mkdir(name string, perm fs.FileMode) error {
+func (ro *readFS) Mkdir(path string, perm fs.FileMode) error {
 	return syscall.ENOSYS
 }
 
@@ -40,16 +40,16 @@ func (ro *readFS) Rename(from, to string) error {
 }
 
 // Rmdir implements FS.Rmdir
-func (ro *readFS) Rmdir(name string) error {
+func (ro *readFS) Rmdir(path string) error {
 	return syscall.ENOSYS
 }
 
 // Unlink implements FS.Unlink
-func (ro *readFS) Unlink(name string) error {
+func (ro *readFS) Unlink(path string) error {
 	return syscall.ENOSYS
 }
 
 // Utimes implements FS.Utimes
-func (ro *readFS) Utimes(name string, atimeNsec, mtimeNsec int64) error {
-	return ro.fs.Utimes(name, atimeNsec, mtimeNsec)
+func (ro *readFS) Utimes(path string, atimeNsec, mtimeNsec int64) error {
+	return syscall.ENOSYS
 }
diff --git a/internal/syscallfs/syscallfs.go b/internal/syscallfs/syscallfs.go
index 7acbf0d0..01cedc01 100644
--- a/internal/syscallfs/syscallfs.go
+++ b/internal/syscallfs/syscallfs.go
@@ -3,6 +3,7 @@ package syscallfs
 import (
 	"io"
 	"io/fs"
+	"os"
 )
 
 // FS is a writeable fs.FS bridge backed by syscall functions needed for ABI
@@ -19,13 +20,13 @@ type FS interface {
 
 	// 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)
+	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.
 
 	// Mkdir is similar to os.Mkdir, except the path is relative to this file
 	// system.
-	Mkdir(name string, perm fs.FileMode) error
+	Mkdir(path string, perm fs.FileMode) error
 	// ^^ TODO: Consider syscall.Mkdir, though this implies defining and
 	// coercing flags and perms similar to what is done in os.Mkdir.
 
@@ -141,3 +142,14 @@ func maskForReads(f fs.File) fs.File {
 		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) {
+	f, err := fs.OpenFile(path, os.O_RDONLY, 0)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return f.Stat()
+}


Test patch:
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 2433e72f..68288c4b 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -67,11 +67,11 @@ func Test_fdClose(t *testing.T) {
 `, "\n"+log.String())
 
 	// Verify fdToClose is closed and removed from the opened FDs.
-	_, ok := fsc.OpenedFile(fdToClose)
+	_, ok := fsc.LookupFile(fdToClose)
 	require.False(t, ok)
 
 	// Verify fdToKeep is not closed
-	_, ok = fsc.OpenedFile(fdToKeep)
+	_, ok = fsc.LookupFile(fdToKeep)
 	require.True(t, ok)
 
 	log.Reset()
@@ -104,10 +104,10 @@ func Test_fdFdstatGet(t *testing.T) {
 	// open both paths without using WASI
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fileFd, err := fsc.OpenFile(file, os.O_RDONLY, 0)
+	fileFD, err := fsc.OpenFile(file, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	dirFd, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
+	dirFD, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -175,7 +175,7 @@ func Test_fdFdstatGet(t *testing.T) {
 		},
 		{
 			name: "file",
-			fd:   fileFd,
+			fd:   fileFD,
 			expectedMemory: []byte{
 				4, 0, // fs_filetype
 				0, 0, 0, 0, 0, 0, // fs_flags
@@ -189,7 +189,7 @@ func Test_fdFdstatGet(t *testing.T) {
 		},
 		{
 			name: "dir",
-			fd:   dirFd,
+			fd:   dirFD,
 			expectedMemory: []byte{
 				3, 0, // fs_filetype
 				0, 0, 0, 0, 0, 0, // fs_flags
@@ -212,7 +212,7 @@ func Test_fdFdstatGet(t *testing.T) {
 		},
 		{
 			name:          "resultFdstat exceeds the maximum valid address by 1",
-			fd:            dirFd,
+			fd:            dirFD,
 			resultFdstat:  memorySize - 24 + 1,
 			expectedErrno: ErrnoFault,
 			expectedLog: `
@@ -273,10 +273,10 @@ func Test_fdFilestatGet(t *testing.T) {
 	// open both paths without using WASI
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fileFd, err := fsc.OpenFile(file, os.O_RDONLY, 0)
+	fileFD, err := fsc.OpenFile(file, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	dirFd, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
+	dirFD, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -363,7 +363,7 @@ func Test_fdFilestatGet(t *testing.T) {
 		},
 		{
 			name: "file",
-			fd:   fileFd,
+			fd:   fileFD,
 			expectedMemory: []byte{
 				0, 0, 0, 0, 0, 0, 0, 0, // dev
 				0, 0, 0, 0, 0, 0, 0, 0, // ino
@@ -381,7 +381,7 @@ func Test_fdFilestatGet(t *testing.T) {
 		},
 		{
 			name: "dir",
-			fd:   dirFd,
+			fd:   dirFD,
 			expectedMemory: []byte{
 				0, 0, 0, 0, 0, 0, 0, 0, // dev
 				0, 0, 0, 0, 0, 0, 0, 0, // ino
@@ -408,7 +408,7 @@ func Test_fdFilestatGet(t *testing.T) {
 		},
 		{
 			name:           "resultFilestat exceeds the maximum valid address by 1",
-			fd:             dirFd,
+			fd:             dirFD,
 			resultFilestat: memorySize - 64 + 1,
 			expectedErrno:  ErrnoFault,
 			expectedLog: `
@@ -1059,7 +1059,7 @@ func Test_fdRead_Errors(t *testing.T) {
 
 var (
 	fdReadDirFs = fstest.MapFS{
-		"notdir":   {},
+		"file":     {},
 		"emptydir": {Mode: fs.ModeDir},
 		"dir":      {Mode: fs.ModeDir},
 		"dir/-":    {},                 // len = 24+1 = 25
@@ -1341,7 +1341,7 @@ func Test_fdReaddir(t *testing.T) {
 			defer log.Reset()
 
 			// Assign the state we are testing
-			file, ok := fsc.OpenedFile(fd)
+			file, ok := fsc.LookupFile(fd)
 			require.True(t, ok)
 			dir := tc.dir()
 			defer dir.File.Close()
@@ -1383,10 +1383,10 @@ func Test_fdReaddir_Errors(t *testing.T) {
 
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	dirFD, err := fsc.OpenFile("dir", os.O_RDONLY, 0)
+	fileFD, err := fsc.OpenFile("file", os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	fileFD, err := fsc.OpenFile("notdir", os.O_RDONLY, 0)
+	dirFD, err := fsc.OpenFile("dir", os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -1405,7 +1405,7 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			bufLen:        1000,
 			expectedErrno: ErrnoFault,
 			expectedLog: `
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=65536,buf_len=1000,cookie=0,result.bufused=0)
+==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=65536,buf_len=1000,cookie=0,result.bufused=0)
 <== errno=EFAULT
 `,
 		},
@@ -1427,19 +1427,8 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			resultBufused: 1000, // arbitrary
 			expectedErrno: ErrnoBadf,
 			expectedLog: `
-==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=0,buf_len=24,cookie=0,result.bufused=1000)
+==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=0,buf_len=24,cookie=0,result.bufused=1000)
 <== errno=EBADF
-`,
-		},
-		{
-			name:          "out-of-memory reading buf",
-			fd:            dirFD,
-			buf:           memLen,
-			bufLen:        1000,
-			expectedErrno: ErrnoFault,
-			expectedLog: `
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=65536,buf_len=1000,cookie=0,result.bufused=0)
-<== errno=EFAULT
 `,
 		},
 		{
@@ -1449,7 +1438,7 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			bufLen:        1000,
 			expectedErrno: ErrnoFault,
 			expectedLog: `
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=65535,buf_len=1000,cookie=0,result.bufused=0)
+==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=65535,buf_len=1000,cookie=0,result.bufused=0)
 <== errno=EFAULT
 `,
 		},
@@ -1460,7 +1449,7 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			resultBufused: 1000,
 			expectedErrno: ErrnoInval,
 			expectedLog: `
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=0,buf_len=1,cookie=0,result.bufused=1000)
+==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=0,buf_len=1,cookie=0,result.bufused=1000)
 <== errno=EINVAL
 `,
 		},
@@ -1472,19 +1461,7 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			resultBufused: 2000,
 			expectedErrno: ErrnoInval,
 			expectedLog: `
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=0,buf_len=1000,cookie=1,result.bufused=2000)
-<== errno=EINVAL
-`,
-		},
-		{
-			name: "cookie invalid when no prior state",
-			fd:   dirFD,
-			buf:  0, bufLen: 1000,
-			cookie:        1,
-			resultBufused: 2000,
-			expectedErrno: ErrnoInval,
-			expectedLog: `
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=0,buf_len=1000,cookie=1,result.bufused=2000)
+==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=0,buf_len=1000,cookie=1,result.bufused=2000)
 <== errno=EINVAL
 `,
 		},
@@ -1497,7 +1474,7 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			resultBufused: 2000,
 			expectedErrno: ErrnoInval,
 			expectedLog: `
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=0,buf_len=1000,cookie=-1,result.bufused=2000)
+==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=0,buf_len=1000,cookie=-1,result.bufused=2000)
 <== errno=EINVAL
 `,
 		},
@@ -1509,7 +1486,7 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			defer log.Reset()
 
 			// Reset the directory so that tests don't taint each other.
-			if file, ok := fsc.OpenedFile(tc.fd); ok && tc.fd == dirFD {
+			if file, ok := fsc.LookupFile(tc.fd); ok && tc.fd == dirFD {
 				dir, err := fdReadDirFs.Open("dir")
 				require.NoError(t, err)
 				defer dir.Close()
@@ -1604,7 +1581,7 @@ func Test_fdSeek(t *testing.T) {
 
 			// Since we initialized this file, we know it is a seeker (because it is a MapFile)
 			fsc := mod.(*wasm.CallContext).Sys.FS()
-			f, ok := fsc.OpenedFile(fd)
+			f, ok := fsc.LookupFile(fd)
 			require.True(t, ok)
 			seeker := f.File.(io.Seeker)
 
@@ -1961,17 +1938,6 @@ func Test_pathCreateDirectory_Errors(t *testing.T) {
 			expectedLog: `
 ==> wasi_snapshot_preview1.path_create_directory(fd=3,path=OOM(65536,1))
 <== errno=EFAULT
-`,
-		},
-		{
-			name:          "path invalid",
-			fd:            sys.FdRoot,
-			pathName:      "../foo",
-			pathLen:       6,
-			expectedErrno: ErrnoInval,
-			expectedLog: `
-==> wasi_snapshot_preview1.path_create_directory(fd=3,path=../foo)
-<== errno=EINVAL
 `,
 		},
 		{
@@ -2043,10 +2009,10 @@ func Test_pathFilestatGet(t *testing.T) {
 	// open both paths without using WASI
 	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	fileFd, err := fsc.OpenFile(file, os.O_RDONLY, 0)
+	fileFD, err := fsc.OpenFile(file, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
-	dirFd, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
+	dirFD, err := fsc.OpenFile(dir, os.O_RDONLY, 0)
 	require.NoError(t, err)
 
 	tests := []struct {
@@ -2080,7 +2046,7 @@ func Test_pathFilestatGet(t *testing.T) {
 		},
 		{
 			name:           "file under dir",
-			fd:             dirFd, // root
+			fd:             dirFD, // root
 			memory:         initialMemoryFile,
 			pathLen:        1,
 			resultFilestat: 2,
@@ -2133,7 +2099,7 @@ func Test_pathFilestatGet(t *testing.T) {
 		},
 		{
 			name:           "bad FD - not dir",
-			fd:             fileFd,
+			fd:             fileFD,
 			memory:         initialMemoryFile,
 			pathLen:        1,
 			resultFilestat: 2,
@@ -2157,7 +2123,7 @@ func Test_pathFilestatGet(t *testing.T) {
 		},
 		{
 			name:           "path under dir doesn't exist",
-			fd:             dirFd,
+			fd:             dirFD,
 			memory:         initialMemoryNotExists,
 			pathLen:        1,
 			resultFilestat: 2,
@@ -2169,7 +2135,7 @@ func Test_pathFilestatGet(t *testing.T) {
 		},
 		{
 			name:           "path invalid",
-			fd:             dirFd,
+			fd:             dirFD,
 			memory:         []byte("?../foo"),
 			pathLen:        6,
 			resultFilestat: 7,
@@ -2321,9 +2287,9 @@ func Test_pathOpen(t *testing.T) {
 			fdflags: FD_APPEND,
 			expected: func(t *testing.T, fsc *sys.FSContext) {
 				contents := []byte("hello")
-				_, err := fsc.FdWriter(expectedOpenedFd).Write(contents)
+				_, err := sys.WriterForFile(fsc, expectedOpenedFd).Write(contents)
 				require.NoError(t, err)
-				require.True(t, fsc.CloseFile(expectedOpenedFd))
+				require.NoError(t, fsc.CloseFile(expectedOpenedFd))
 
 				// verify the contents were appended
 				b := readFile(t, dir, appendName)
@@ -2353,9 +2319,9 @@ func Test_pathOpen(t *testing.T) {
 			expected: func(t *testing.T, fsc *sys.FSContext) {
 				// expect to create a new file
 				contents := []byte("hello")
-				_, err := fsc.FdWriter(expectedOpenedFd).Write(contents)
+				_, err := sys.WriterForFile(fsc, expectedOpenedFd).Write(contents)
 				require.NoError(t, err)
-				require.True(t, fsc.CloseFile(expectedOpenedFd))
+				require.NoError(t, fsc.CloseFile(expectedOpenedFd))
 
 				// verify the contents were written
 				b := readFile(t, dir, "creat")
@@ -2385,9 +2351,9 @@ func Test_pathOpen(t *testing.T) {
 			expected: func(t *testing.T, fsc *sys.FSContext) {
 				// expect to create a new file
 				contents := []byte("hello")
-				_, err := fsc.FdWriter(expectedOpenedFd).Write(contents)
+				_, err := sys.WriterForFile(fsc, expectedOpenedFd).Write(contents)
 				require.NoError(t, err)
-				require.True(t, fsc.CloseFile(expectedOpenedFd))
+				require.NoError(t, fsc.CloseFile(expectedOpenedFd))
 
 				// verify the contents were written
 				b := readFile(t, dir, path.Join(dirName, "O_CREAT-O_TRUNC"))
@@ -2404,7 +2370,7 @@ func Test_pathOpen(t *testing.T) {
 			oflags: O_DIRECTORY,
 			path:   func(*testing.T) string { return dirName },
 			expected: func(t *testing.T, fsc *sys.FSContext) {
-				stat, err := fsc.StatFile(expectedOpenedFd)
+				stat, err := sys.StatFile(fsc, expectedOpenedFd)
 				require.NoError(t, err)
 				require.True(t, stat.IsDir())
 			},
@@ -2419,7 +2385,7 @@ func Test_pathOpen(t *testing.T) {
 			path:   func(*testing.T) string { return dirName },
 			oflags: O_DIRECTORY,
 			expected: func(t *testing.T, fsc *sys.FSContext) {
-				stat, err := fsc.StatFile(expectedOpenedFd)
+				stat, err := sys.StatFile(fsc, expectedOpenedFd)
 				require.NoError(t, err)
 				require.True(t, stat.IsDir())
 			},
@@ -2446,9 +2412,9 @@ func Test_pathOpen(t *testing.T) {
 			oflags: O_TRUNC,
 			expected: func(t *testing.T, fsc *sys.FSContext) {
 				contents := []byte("hello")
-				_, err := fsc.FdWriter(expectedOpenedFd).Write(contents)
+				_, err := sys.WriterForFile(fsc, expectedOpenedFd).Write(contents)
 				require.NoError(t, err)
-				require.True(t, fsc.CloseFile(expectedOpenedFd))
+				require.NoError(t, fsc.CloseFile(expectedOpenedFd))
 
 				// verify the contents were truncated
 				b := readFile(t, dir, "trunc")
@@ -2499,12 +2465,12 @@ func Test_pathOpen(t *testing.T) {
 
 func requireContents(t *testing.T, fsc *sys.FSContext, expectedOpenedFd uint32, fileName string, fileContents []byte) {
 	// verify the file was actually opened
-	f, ok := fsc.OpenedFile(expectedOpenedFd)
+	f, ok := fsc.LookupFile(expectedOpenedFd)
 	require.True(t, ok)
 	require.Equal(t, fileName, f.Name)
 
 	// verify the contents are readable
-	b, err := io.ReadAll(fsc.FdReader(expectedOpenedFd))
+	b, err := io.ReadAll(f.File)
 	require.NoError(t, err)
 	require.Equal(t, fileContents, b)
 }
@@ -2528,7 +2494,7 @@ func writeFile(t *testing.T, tmpDir, file string, contents []byte) {
 func Test_pathOpen_Errors(t *testing.T) {
 	validFD := uint32(3) // arbitrary valid fd after 0, 1, and 2, that are stdin/out/err
 	dirName := "wazero"
-	fileName := "notdir" // name length as wazero
+	fileName := "file" // name length as wazero
 	testFS := fstest.MapFS{
 		dirName:  &fstest.MapFile{Mode: os.ModeDir},
 		fileName: &fstest.MapFile{},
@@ -2563,18 +2529,6 @@ func Test_pathOpen_Errors(t *testing.T) {
 			expectedLog: `
 ==> wasi_snapshot_preview1.path_open(fd=3,dirflags=,path=OOM(65536,6),oflags=,fs_rights_base=,fs_rights_inheriting=,fdflags=)
 <== (opened_fd=,errno=EFAULT)
-`,
-		},
-		{
-			name:     "path invalid",
-			fd:       validFD,
-			pathName: "../foo",
-			pathLen:  6,
-			// fstest.MapFS returns file not found instead of invalid on invalid path
-			expectedErrno: ErrnoNoent,
-			expectedLog: `
-==> wasi_snapshot_preview1.path_open(fd=3,dirflags=,path=../foo,oflags=,fs_rights_base=,fs_rights_inheriting=,fdflags=)
-<== (opened_fd=,errno=ENOENT)
 `,
 		},
 		{
@@ -2619,10 +2573,10 @@ func Test_pathOpen_Errors(t *testing.T) {
 			fd:            validFD,
 			pathName:      fileName,
 			path:          validPath,
-			pathLen:       validPathLen,
+			pathLen:       uint32(len(fileName)),
 			expectedErrno: ErrnoNotdir,
 			expectedLog: `
-==> wasi_snapshot_preview1.path_open(fd=3,dirflags=,path=notdir,oflags=DIRECTORY,fs_rights_base=,fs_rights_inheriting=,fdflags=)
+==> wasi_snapshot_preview1.path_open(fd=3,dirflags=,path=file,oflags=DIRECTORY,fs_rights_base=,fs_rights_inheriting=,fdflags=)
 <== (opened_fd=,errno=ENOTDIR)
 `,
 		},
@@ -2632,10 +2586,10 @@ func Test_pathOpen_Errors(t *testing.T) {
 			fd:            validFD,
 			pathName:      fileName,
 			path:          validPath,
-			pathLen:       validPathLen,
+			pathLen:       uint32(len(fileName)),
 			expectedErrno: ErrnoInval,
 			expectedLog: `
-==> wasi_snapshot_preview1.path_open(fd=3,dirflags=,path=notdir,oflags=CREAT|DIRECTORY,fs_rights_base=,fs_rights_inheriting=,fdflags=)
+==> wasi_snapshot_preview1.path_open(fd=3,dirflags=,path=file,oflags=CREAT|DIRECTORY,fs_rights_base=,fs_rights_inheriting=,fdflags=)
 <== (opened_fd=,errno=EINVAL)
 `,
 		},
@@ -2741,17 +2695,6 @@ func Test_pathRemoveDirectory_Errors(t *testing.T) {
 			expectedLog: `
 ==> wasi_snapshot_preview1.path_remove_directory(fd=3,path=OOM(65536,1))
 <== errno=EFAULT
-`,
-		},
-		{
-			name:          "path invalid",
-			fd:            sys.FdRoot,
-			pathName:      "../foo",
-			pathLen:       6,
-			expectedErrno: ErrnoInval,
-			expectedLog: `
-==> wasi_snapshot_preview1.path_remove_directory(fd=3,path=../foo)
-<== errno=EINVAL
 `,
 		},
 		{
@@ -2945,32 +2888,6 @@ func Test_pathRename_Errors(t *testing.T) {
 			expectedLog: `
 ==> wasi_snapshot_preview1.path_rename(fd=3,old_path=a,new_fd=3,new_path=OOM(65536,1))
 <== errno=EFAULT
-`,
-		},
-		{
-			name:          "old path invalid",
-			oldFd:         sys.FdRoot,
-			newFd:         sys.FdRoot,
-			oldPathName:   "../foo",
-			oldPathLen:    6,
-			expectedErrno: ErrnoInval,
-			expectedLog: `
-==> wasi_snapshot_preview1.path_rename(fd=3,old_path=../foo,new_fd=3,new_path=)
-<== errno=EINVAL
-`,
-		},
-		{
-			name:          "new path invalid",
-			oldFd:         sys.FdRoot,
-			newFd:         sys.FdRoot,
-			oldPathName:   file,
-			oldPathLen:    uint32(len(file)),
-			newPathName:   "../foo",
-			newPathLen:    6,
-			expectedErrno: ErrnoInval,
-			expectedLog: `
-==> wasi_snapshot_preview1.path_rename(fd=3,old_path=../f,new_fd=3,new_path=../foo)
-<== errno=EINVAL
 `,
 		},
 		{
@@ -3120,17 +3037,6 @@ func Test_pathUnlinkFile_Errors(t *testing.T) {
 			expectedLog: `
 ==> wasi_snapshot_preview1.path_unlink_file(fd=3,path=OOM(65536,1))
 <== errno=EFAULT
-`,
-		},
-		{
-			name:          "path invalid",
-			fd:            sys.FdRoot,
-			pathName:      "../foo",
-			pathLen:       6,
-			expectedErrno: ErrnoInval,
-			expectedLog: `
-==> wasi_snapshot_preview1.path_unlink_file(fd=3,path=../foo)
-<== errno=EINVAL
 `,
 		},
 		{
@@ -3191,6 +3097,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, os.O_RDONLY, 0)
 	require.NoError(t, err)
 	return mod, fd, log, r
@@ -3198,29 +3105,17 @@ func requireOpenFile(t *testing.T, pathName string, data []byte) (api.Module, ui
 
 // requireOpenWritableFile is temporary until we add the ability to open files for writing.
 func requireOpenWritableFile(t *testing.T, tmpDir string, pathName string) (api.Module, uint32, *bytes.Buffer, api.Closer) {
-	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, os.O_RDWR, 0)
-	require.NoError(t, err)
-
-	// Swap the read-only file with a writeable one until #390
-	f, ok := fsc.OpenedFile(fd)
-	require.True(t, ok)
-	f.File.Close()
-	f.File = writeable
+	absolutePath := path.Join(tmpDir, pathName)
+	require.NoError(t, os.WriteFile(absolutePath, []byte{}, 0o600))
 
-	return mod, fd, log, r
-}
+	writeFS, err := syscallfs.NewDirFS(tmpDir)
+	require.NoError(t, err)
 
-// createWriteableFile uses real files when io.Writer tests are needed.
-func createWriteableFile(t *testing.T, tmpDir string, pathName string, data []byte) (fs.File, fs.FS) {
-	require.NotNil(t, data)
-	absolutePath := path.Join(tmpDir, pathName)
-	require.NoError(t, os.WriteFile(absolutePath, data, 0o600))
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(writeFS))
+	fsc := mod.(*wasm.CallContext).Sys.FS()
 
-	// open the file for writing in a custom way until #390
-	f, err := os.OpenFile(absolutePath, os.O_RDWR, 0o600)
+	fd, err := fsc.OpenFile(pathName, os.O_RDWR, 0o600)
 	require.NoError(t, err)
-	return f, os.DirFS(tmpDir)
+
+	return mod, fd, log, r
 }
diff --git a/imports/wasi_snapshot_preview1/wasi_bench_test.go b/imports/wasi_snapshot_preview1/wasi_bench_test.go
index bfebf86d..2de336bb 100644
--- a/imports/wasi_snapshot_preview1/wasi_bench_test.go
+++ b/imports/wasi_snapshot_preview1/wasi_bench_test.go
@@ -178,11 +178,11 @@ func Benchmark_fdReaddir(b *testing.B) {
 			if err != nil {
 				b.Fatal(err)
 			}
-			f, ok := fsc.OpenedFile(fd)
+			f, ok := fsc.LookupFile(fd)
 			if !ok {
 				b.Fatal("couldn't open fd ", fd)
 			}
-			defer fsc.CloseFile(fd)
+			defer fsc.CloseFile(fd) //nolint
 
 			b.ResetTimer()
 			b.ReportAllocs()
@@ -284,7 +284,7 @@ func Benchmark_pathFilestat(b *testing.B) {
 				if err != nil {
 					b.Fatal(err)
 				}
-				defer fsc.CloseFile(fd)
+				defer fsc.CloseFile(fd) //nolint
 			}
 
 			fn := mod.ExportedFunction(PathFilestatGetName)
diff --git a/internal/gojs/compiler_test.go b/internal/gojs/compiler_test.go
index 94827b22..cf1a64d9 100644
--- a/internal/gojs/compiler_test.go
+++ b/internal/gojs/compiler_test.go
@@ -22,6 +22,7 @@ import (
 	gojs "github.com/tetratelabs/wazero/imports/go"
 	internalgojs "github.com/tetratelabs/wazero/internal/gojs"
 	"github.com/tetratelabs/wazero/internal/gojs/run"
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 )
 
 func compileAndRun(ctx context.Context, arg string, config wazero.ModuleConfig) (stdout, stderr string, err error) {
@@ -65,12 +66,12 @@ var testBin []byte
 // testCtx is configured in TestMain to re-use wazero's compilation cache.
 var (
 	testCtx context.Context
-	testFS  = fstest.MapFS{
+	testFS  = syscallfs.Adapt(fstest.MapFS{
 		"empty.txt":    {},
 		"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/sys/fs_test.go b/internal/sys/fs_test.go
index 7ef2890b..f80f87bd 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -7,10 +7,10 @@ import (
 	"io"
 	"io/fs"
 	"os"
-	"path"
 	"testing"
 	"testing/fstest"
 
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
@@ -31,26 +31,31 @@ func TestNewFSContext(t *testing.T) {
 	embedFS, err := fs.Sub(testdata, "testdata")
 	require.NoError(t, err)
 
+	dirfs, err := syscallfs.NewDirFS(".")
+	require.NoError(t, err)
+
 	// Test various usual configuration for the file system.
 	tests := []struct {
-		name         string
-		fs           fs.FS
-		expectOsFile bool
+		name string
+		fs   syscallfs.FS
 	}{
 		{
 			name: "embed.FS",
-			fs:   embedFS,
+			fs:   syscallfs.Adapt(embedFS),
 		},
 		{
-			name: "os.DirFS",
+			name: "syscallfs.NewDirFS",
 			// Don't use "testdata" because it may not be present in
 			// cross-architecture (a.k.a. scratch) build containers.
-			fs:           os.DirFS("."),
-			expectOsFile: true,
+			fs: dirfs,
+		},
+		{
+			name: "syscallfs.NewReadFS",
+			fs:   syscallfs.NewReadFS(dirfs),
 		},
 		{
 			name: "fstest.MapFS",
-			fs:   fstest.MapFS{},
+			fs:   syscallfs.Adapt(fstest.MapFS{}),
 		},
 	}
 
@@ -67,17 +72,11 @@ func TestNewFSContext(t *testing.T) {
 			require.NotNil(t, rootFile)
 			require.Equal(t, "/", rootFile.Name)
 
-			_, osFile := rootFile.File.(*os.File)
-			require.Equal(t, tc.expectOsFile, osFile)
-
-			f0, err := fsc.OpenFile("/", 0, 0)
-			require.NoError(t, err)
-
 			// Verify that each call to OpenFile returns a different file
 			// descriptor.
 			f1, err := fsc.OpenFile("/", 0, 0)
 			require.NoError(t, err)
-			require.NotEqual(t, f0, f1)
+			require.NotEqual(t, FdRoot, f1)
 
 			// Verify that file descriptors are reused.
 			//
@@ -87,119 +86,38 @@ func TestNewFSContext(t *testing.T) {
 			// test to ensure that our implementation properly reuses descriptor
 			// numbers but if we were to change the reuse strategy, this test
 			// would likely break and need to be updated.
-			require.True(t, fsc.CloseFile(f0))
+			require.NoError(t, fsc.CloseFile(f1))
 			f2, err := fsc.OpenFile("/", 0, 0)
 			require.NoError(t, err)
-			require.Equal(t, f0, f2)
+			require.Equal(t, f1, f2)
 		})
 	}
 }
 
-func TestEmptyFS(t *testing.T) {
-	testFS := EmptyFS
-
-	t.Run("validates path", func(t *testing.T) {
-		f, err := testFS.Open("/foo.txt")
-		require.Nil(t, f)
-		require.EqualError(t, err, "open /foo.txt: invalid argument")
-	})
-
-	t.Run("path not found", func(t *testing.T) {
-		f, err := testFS.Open("foo.txt")
-		require.Nil(t, f)
-		require.EqualError(t, err, "open foo.txt: file does not exist")
-	})
-}
-
 func TestEmptyFSContext(t *testing.T) {
-	testFS, err := NewFSContext(nil, nil, nil, EmptyFS)
+	testFS, err := NewFSContext(nil, nil, nil, syscallfs.EmptyFS)
 	require.NoError(t, err)
 
-	expected := &FSContext{fs: EmptyFS}
+	expected := &FSContext{fs: syscallfs.EmptyFS}
 	expected.openedFiles.Insert(noopStdin)
 	expected.openedFiles.Insert(noopStdout)
 	expected.openedFiles.Insert(noopStderr)
 
-	t.Run("OpenFile doesn't affect state", func(t *testing.T) {
-		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")
-
-		// Ensure this didn't modify state
-		require.Equal(t, expected, testFS)
-	})
-
 	t.Run("Close closes", func(t *testing.T) {
 		err := testFS.Close(testCtx)
 		require.NoError(t, err)
 
 		// Closes opened files
-		require.Equal(t, &FSContext{fs: EmptyFS}, testFS)
+		require.Equal(t, &FSContext{fs: syscallfs.EmptyFS}, testFS)
 	})
 }
 
-func TestContext_File(t *testing.T) {
-	embedFS, err := fs.Sub(testdata, "testdata")
-	require.NoError(t, err)
+func TestContext_Close(t *testing.T) {
+	testFS := syscallfs.Adapt(testfs.FS{"foo": &testfs.File{}})
 
-	fsc, err := NewFSContext(nil, nil, nil, embedFS)
+	fsc, err := NewFSContext(nil, nil, nil, testFS)
 	require.NoError(t, err)
-	defer fsc.Close(testCtx)
-
-	tests := []struct {
-		name     string
-		expected string
-	}{
-		{
-			name: "empty.txt",
-		},
-		{
-			name:     "test.txt",
-			expected: "animals\n",
-		},
-		{
-			name:     "sub/test.txt",
-			expected: "greet sub dir\n",
-		},
-		{
-			name:     "sub/sub/test.txt",
-			expected: "greet sub sub dir\n",
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.name, func(b *testing.T) {
-			fd, err := fsc.OpenFile(tc.name, os.O_RDONLY, 0)
-			require.NoError(t, err)
-			defer fsc.CloseFile(fd)
-
-			f, ok := fsc.OpenedFile(fd)
-			require.True(t, ok)
 
-			stat, err := f.File.Stat()
-			require.NoError(t, err)
-
-			// Ensure the name is the basename and matches the stat name.
-			require.Equal(t, path.Base(tc.name), f.Name)
-			require.Equal(t, f.Name, stat.Name())
-
-			buf := make([]byte, stat.Size())
-			size, err := f.File.Read(buf)
-			if err != nil {
-				require.Equal(t, io.EOF, err)
-			}
-			require.Equal(t, stat.Size(), int64(size))
-
-			require.Equal(t, tc.expected, string(buf[:size]))
-		})
-	}
-}
-
-func TestContext_Close(t *testing.T) {
-	fsc, err := NewFSContext(nil, nil, nil, testfs.FS{"foo": &testfs.File{}})
-	require.NoError(t, err)
 	// Verify base case
 	require.Equal(t, 1+FdRoot, uint32(fsc.openedFiles.Len()))
 
@@ -219,7 +137,10 @@ func TestContext_Close(t *testing.T) {
 
 func TestContext_Close_Error(t *testing.T) {
 	file := &testfs.File{CloseErr: errors.New("error closing")}
-	fsc, err := NewFSContext(nil, nil, nil, testfs.FS{"foo": file})
+
+	testFS := syscallfs.Adapt(testfs.FS{"foo": file})
+
+	fsc, err := NewFSContext(nil, nil, nil, testFS)
 	require.NoError(t, err)
 
 	// open another file
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
index 81f38a08..e4e8114d 100644
--- a/internal/sys/sys_test.go
+++ b/internal/sys/sys_test.go
@@ -6,15 +6,16 @@ import (
 	"time"
 
 	"github.com/tetratelabs/wazero/internal/platform"
+	"github.com/tetratelabs/wazero/internal/syscallfs"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
 	"github.com/tetratelabs/wazero/sys"
 )
 
 func TestContext_FS(t *testing.T) {
-	sysCtx := DefaultContext(testfs.FS{})
+	sysCtx := DefaultContext(syscallfs.EmptyFS)
 
-	fsc, err := NewFSContext(nil, nil, nil, testfs.FS{})
+	fsc, err := NewFSContext(nil, nil, nil, syscallfs.EmptyFS)
 	require.NoError(t, err)
 
 	require.Equal(t, fsc, sysCtx.FS())
@@ -50,7 +51,8 @@ func TestDefaultSysContext(t *testing.T) {
 	require.Equal(t, &ns, sysCtx.nanosleep)
 	require.Equal(t, platform.NewFakeRandSource(), sysCtx.RandSource())
 
-	expectedFS, _ := NewFSContext(nil, nil, nil, testfs.FS{})
+	testFS := syscallfs.Adapt(testfs.FS{})
+	expectedFS, _ := NewFSContext(nil, nil, nil, testFS)
 
 	expectedOpenedFiles := FileTable{}
 	expectedOpenedFiles.Insert(noopStdin)
diff --git a/internal/syscallfs/adapter_test.go b/internal/syscallfs/adapter_test.go
new file mode 100644
index 00000000..bc4d5abf
--- /dev/null
+++ b/internal/syscallfs/adapter_test.go
@@ -0,0 +1,99 @@
+package syscallfs
+
+import (
+	"errors"
+	"io/fs"
+	"os"
+	pathutil "path"
+	"syscall"
+	"testing"
+
+	"github.com/tetratelabs/wazero/internal/testing/require"
+)
+
+func TestAdapt_MkDir(t *testing.T) {
+	dir := t.TempDir()
+
+	testFS := Adapt(os.DirFS(dir))
+
+	err := testFS.Mkdir("mkdir", fs.ModeDir)
+	require.Equal(t, syscall.ENOSYS, err)
+}
+
+func TestAdapt_Rename(t *testing.T) {
+	tmpDir := t.TempDir()
+	testFS := Adapt(os.DirFS(tmpDir))
+
+	file1 := "file1"
+	file1Path := pathutil.Join(tmpDir, file1)
+	file1Contents := []byte{1}
+	err := os.WriteFile(file1Path, file1Contents, 0o600)
+	require.NoError(t, err)
+
+	file2 := "file2"
+	file2Path := pathutil.Join(tmpDir, file2)
+	file2Contents := []byte{2}
+	err = os.WriteFile(file2Path, file2Contents, 0o600)
+	require.NoError(t, err)
+
+	err = testFS.Rename(file1, file2)
+	require.Equal(t, syscall.ENOSYS, err)
+}
+
+func TestAdapt_Rmdir(t *testing.T) {
+	dir := t.TempDir()
+
+	testFS := Adapt(os.DirFS(dir))
+
+	path := "rmdir"
+	realPath := pathutil.Join(dir, path)
+	require.NoError(t, os.Mkdir(realPath, 0o700))
+
+	err := testFS.Rmdir(path)
+	require.Equal(t, syscall.ENOSYS, err)
+}
+
+func TestAdapt_Unlink(t *testing.T) {
+	dir := t.TempDir()
+
+	testFS := Adapt(os.DirFS(dir))
+
+	path := "unlink"
+	realPath := pathutil.Join(dir, path)
+	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
+
+	err := testFS.Unlink(path)
+	require.Equal(t, syscall.ENOSYS, err)
+}
+
+func TestAdapt_Utimes(t *testing.T) {
+	dir := t.TempDir()
+
+	testFS := Adapt(os.DirFS(dir))
+
+	path := "utimes"
+	realPath := pathutil.Join(dir, path)
+	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
+
+	err := testFS.Utimes(path, 1, 1)
+	require.Equal(t, syscall.ENOSYS, err)
+}
+
+func TestAdapt_Open_Read(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(os.DirFS(tmpDir))
+
+	testFS_Open_Read(t, tmpDir, testFS)
+
+	t.Run("path outside root invalid", func(t *testing.T) {
+		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
+
+		// fs.FS doesn't allow relative path lookups
+		require.True(t, errors.Is(err, fs.ErrInvalid))
+	})
+}
diff --git a/internal/syscallfs/dirfs_test.go b/internal/syscallfs/dirfs_test.go
index 4c09e590..86178ab5 100644
--- a/internal/syscallfs/dirfs_test.go
+++ b/internal/syscallfs/dirfs_test.go
@@ -4,7 +4,7 @@ import (
 	"errors"
 	"io/fs"
 	"os"
-	"path"
+	pathutil "path"
 	"runtime"
 	"syscall"
 	"testing"
@@ -18,7 +18,7 @@ func TestDirFS_MkDir(t *testing.T) {
 	testFS := dirFS(dir)
 
 	name := "mkdir"
-	realPath := path.Join(dir, name)
+	realPath := pathutil.Join(dir, name)
 
 	t.Run("doesn't exist", func(t *testing.T) {
 		require.NoError(t, testFS.Mkdir(name, fs.ModeDir))
@@ -48,7 +48,7 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		file1 := "file1"
-		file1Path := path.Join(tmpDir, file1)
+		file1Path := pathutil.Join(tmpDir, file1)
 		err := os.WriteFile(file1Path, []byte{1}, 0o600)
 		require.NoError(t, err)
 
@@ -60,13 +60,13 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		file1 := "file1"
-		file1Path := path.Join(tmpDir, file1)
+		file1Path := pathutil.Join(tmpDir, file1)
 		file1Contents := []byte{1}
 		err := os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		file2 := "file2"
-		file2Path := path.Join(tmpDir, file2)
+		file2Path := pathutil.Join(tmpDir, file2)
 		err = testFS.Rename(file1, file2)
 		require.NoError(t, err)
 
@@ -83,11 +83,11 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		dir1 := "dir1"
-		dir1Path := path.Join(tmpDir, dir1)
+		dir1Path := pathutil.Join(tmpDir, dir1)
 		require.NoError(t, os.Mkdir(dir1Path, 0o700))
 
 		dir2 := "dir2"
-		dir2Path := path.Join(tmpDir, dir2)
+		dir2Path := pathutil.Join(tmpDir, dir2)
 		err := testFS.Rename(dir1, dir2)
 		require.NoError(t, err)
 
@@ -104,11 +104,11 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		dir1 := "dir1"
-		dir1Path := path.Join(tmpDir, dir1)
+		dir1Path := pathutil.Join(tmpDir, dir1)
 		require.NoError(t, os.Mkdir(dir1Path, 0o700))
 
 		dir2 := "dir2"
-		dir2Path := path.Join(tmpDir, dir2)
+		dir2Path := pathutil.Join(tmpDir, dir2)
 
 		// write a file to that path
 		err := os.WriteFile(dir2Path, []byte{2}, 0o600)
@@ -131,13 +131,13 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		file1 := "file1"
-		file1Path := path.Join(tmpDir, file1)
+		file1Path := pathutil.Join(tmpDir, file1)
 		file1Contents := []byte{1}
 		err := os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		dir1 := "dir1"
-		dir1Path := path.Join(tmpDir, dir1)
+		dir1Path := pathutil.Join(tmpDir, dir1)
 		require.NoError(t, os.Mkdir(dir1Path, 0o700))
 
 		err = testFS.Rename(file1, dir1)
@@ -148,18 +148,18 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		dir1 := "dir1"
-		dir1Path := path.Join(tmpDir, dir1)
+		dir1Path := pathutil.Join(tmpDir, dir1)
 		require.NoError(t, os.Mkdir(dir1Path, 0o700))
 
 		// add a file to that directory
 		file1 := "file1"
-		file1Path := path.Join(dir1Path, file1)
+		file1Path := pathutil.Join(dir1Path, file1)
 		file1Contents := []byte{1}
 		err := os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		dir2 := "dir2"
-		dir2Path := path.Join(tmpDir, dir2)
+		dir2Path := pathutil.Join(tmpDir, dir2)
 		require.NoError(t, os.Mkdir(dir2Path, 0o700))
 
 		err = testFS.Rename(dir1, dir2)
@@ -175,7 +175,7 @@ func TestDirFS_Rename(t *testing.T) {
 		require.Equal(t, syscall.ENOENT, errors.Unwrap(err))
 
 		// Show the file inside that directory moved
-		s, err := os.Stat(path.Join(dir2Path, file1))
+		s, err := os.Stat(pathutil.Join(dir2Path, file1))
 		require.NoError(t, err)
 		require.False(t, s.IsDir())
 	})
@@ -184,13 +184,13 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		file1 := "file1"
-		file1Path := path.Join(tmpDir, file1)
+		file1Path := pathutil.Join(tmpDir, file1)
 		file1Contents := []byte{1}
 		err := os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
 
 		file2 := "file2"
-		file2Path := path.Join(tmpDir, file2)
+		file2Path := pathutil.Join(tmpDir, file2)
 		file2Contents := []byte{2}
 		err = os.WriteFile(file2Path, file2Contents, 0o600)
 		require.NoError(t, err)
@@ -212,7 +212,7 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		dir1 := "dir1"
-		dir1Path := path.Join(tmpDir, dir1)
+		dir1Path := pathutil.Join(tmpDir, dir1)
 		require.NoError(t, os.Mkdir(dir1Path, 0o700))
 
 		err := testFS.Rename(dir1, dir1)
@@ -227,7 +227,7 @@ func TestDirFS_Rename(t *testing.T) {
 		testFS := dirFS(tmpDir)
 
 		file1 := "file1"
-		file1Path := path.Join(tmpDir, file1)
+		file1Path := pathutil.Join(tmpDir, file1)
 		file1Contents := []byte{1}
 		err := os.WriteFile(file1Path, file1Contents, 0o600)
 		require.NoError(t, err)
@@ -247,7 +247,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 	testFS := dirFS(dir)
 
 	name := "rmdir"
-	realPath := path.Join(dir, name)
+	realPath := pathutil.Join(dir, name)
 
 	t.Run("doesn't exist", func(t *testing.T) {
 		err := testFS.Rmdir(name)
@@ -256,7 +256,7 @@ func TestDirFS_Rmdir(t *testing.T) {
 
 	t.Run("dir not empty", func(t *testing.T) {
 		require.NoError(t, os.Mkdir(realPath, 0o700))
-		fileInDir := path.Join(realPath, "file")
+		fileInDir := pathutil.Join(realPath, "file")
 		require.NoError(t, os.WriteFile(fileInDir, []byte{}, 0o600))
 
 		err := testFS.Rmdir(name)
@@ -287,7 +287,7 @@ func TestDirFS_Unlink(t *testing.T) {
 	testFS := dirFS(dir)
 
 	name := "unlink"
-	realPath := path.Join(dir, name)
+	realPath := pathutil.Join(dir, name)
 
 	t.Run("doesn't exist", func(t *testing.T) {
 		err := testFS.Unlink(name)
@@ -323,7 +323,18 @@ func TestDirFS_Utimes(t *testing.T) {
 func TestDirFS_Open_Read(t *testing.T) {
 	tmpDir := t.TempDir()
 
-	testFS := dirFS(tmpDir)
+	// 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_Open_Read(t, tmpDir, testFS)
+
+	t.Run("path outside root valid", func(t *testing.T) {
+		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
+
+		// syscall.FS allows relative path lookups
+		require.True(t, errors.Is(err, fs.ErrNotExist))
+	})
 }
diff --git a/internal/syscallfs/readfs_test.go b/internal/syscallfs/readfs_test.go
index e08d4b9f..34c22a11 100644
--- a/internal/syscallfs/readfs_test.go
+++ b/internal/syscallfs/readfs_test.go
@@ -3,7 +3,7 @@ package syscallfs
 import (
 	"io/fs"
 	"os"
-	"path"
+	pathutil "path"
 	"syscall"
 	"testing"
 
@@ -24,13 +24,13 @@ func TestReadFS_Rename(t *testing.T) {
 	testFS := NewReadFS(dirFS(tmpDir))
 
 	file1 := "file1"
-	file1Path := path.Join(tmpDir, file1)
+	file1Path := pathutil.Join(tmpDir, file1)
 	file1Contents := []byte{1}
 	err := os.WriteFile(file1Path, file1Contents, 0o600)
 	require.NoError(t, err)
 
 	file2 := "file2"
-	file2Path := path.Join(tmpDir, file2)
+	file2Path := pathutil.Join(tmpDir, file2)
 	file2Contents := []byte{2}
 	err = os.WriteFile(file2Path, file2Contents, 0o600)
 	require.NoError(t, err)
@@ -44,11 +44,11 @@ func TestReadFS_Rmdir(t *testing.T) {
 
 	testFS := NewReadFS(dirFS(dir))
 
-	name := "rmdir"
-	realPath := path.Join(dir, name)
+	path := "rmdir"
+	realPath := pathutil.Join(dir, path)
 	require.NoError(t, os.Mkdir(realPath, 0o700))
 
-	err := testFS.Rmdir(name)
+	err := testFS.Rmdir(path)
 	require.Equal(t, syscall.ENOSYS, err)
 }
 
@@ -57,20 +57,25 @@ func TestReadFS_Unlink(t *testing.T) {
 
 	testFS := NewReadFS(dirFS(dir))
 
-	name := "unlink"
-	realPath := path.Join(dir, name)
+	path := "unlink"
+	realPath := pathutil.Join(dir, path)
 	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
 
-	err := testFS.Unlink(name)
+	err := testFS.Unlink(path)
 	require.Equal(t, syscall.ENOSYS, err)
 }
 
 func TestReadFS_Utimes(t *testing.T) {
-	tmpDir := t.TempDir()
+	dir := t.TempDir()
 
-	testFS := NewReadFS(dirFS(tmpDir))
+	testFS := NewReadFS(dirFS(dir))
 
-	testFS_Utimes(t, tmpDir, testFS)
+	path := "utimes"
+	realPath := pathutil.Join(dir, path)
+	require.NoError(t, os.WriteFile(realPath, []byte{}, 0o600))
+
+	err := testFS.Utimes(path, 1, 1)
+	require.Equal(t, syscall.ENOSYS, err)
 }
 
 func TestReadFS_Open_Read(t *testing.T) {
diff --git a/internal/wasm/call_context_test.go b/internal/wasm/call_context_test.go
index ce10b47f..719e4299 100644
--- a/internal/wasm/call_context_test.go
+++ b/internal/wasm/call_context_test.go
@@ -155,14 +155,14 @@ func TestCallContext_Close(t *testing.T) {
 
 		// We use side effects to determine if Close in fact called Context.Close (without repeating sys_test.go).
 		// One side effect of Context.Close is that it clears the openedFiles map. Verify our base case.
-		_, ok := fsCtx.OpenedFile(3)
+		_, ok := fsCtx.LookupFile(3)
 		require.True(t, ok, "sysCtx.openedFiles was empty")
 
 		// Closing should not err.
 		require.NoError(t, m.Close(testCtx))
 
 		// Verify our intended side-effect
-		_, ok = fsCtx.OpenedFile(3)
+		_, ok = fsCtx.LookupFile(3)
 		require.False(t, ok, "expected no opened files")
 
 		// Verify no error closing again.
@@ -184,7 +184,7 @@ func TestCallContext_Close(t *testing.T) {
 		require.EqualError(t, m.Close(testCtx), "error closing")
 
 		// Verify our intended side-effect
-		_, ok := fsCtx.OpenedFile(3)
+		_, ok := fsCtx.LookupFile(3)
 		require.False(t, ok, "expected no opened files")
 	})
 }
@@ -251,14 +251,14 @@ func TestCallContext_CallDynamic(t *testing.T) {
 
 		// We use side effects to determine if Close in fact called Context.Close (without repeating sys_test.go).
 		// One side effect of Context.Close is that it clears the openedFiles map. Verify our base case.
-		_, ok := fsCtx.OpenedFile(3)
+		_, ok := fsCtx.LookupFile(3)
 		require.True(t, ok, "sysCtx.openedFiles was empty")
 
 		// Closing should not err.
 		require.NoError(t, m.Close(testCtx))
 
 		// Verify our intended side-effect
-		_, ok = fsCtx.OpenedFile(3)
+		_, ok = fsCtx.LookupFile(3)
 		require.False(t, ok, "expected no opened files")
 
 		// Verify no error closing again.
@@ -271,7 +271,8 @@ func TestCallContext_CallDynamic(t *testing.T) {
 		sysCtx := sys.DefaultContext(testFS)
 		fsCtx := sysCtx.FS()
 
-		_, err := fsCtx.OpenFile("/foo", os.O_RDONLY, 0)
+		path := "/foo"
+		_, err := fsCtx.OpenFile(path, os.O_RDONLY, 0)
 		require.NoError(t, err)
 
 		m, err := s.Instantiate(context.Background(), ns, &Module{}, t.Name(), sysCtx)
@@ -280,7 +281,7 @@ func TestCallContext_CallDynamic(t *testing.T) {
 		require.EqualError(t, m.Close(testCtx), "error closing")
 
 		// Verify our intended side-effect
-		_, ok := fsCtx.OpenedFile(3)
+		_, ok := fsCtx.LookupFile(3)
 		require.False(t, ok, "expected no opened files")
 	})
 }