Repository: tetratelabs/wazero (go)

Issue:
Allow FSConfig to have arbitrarily nested mounts
**Is your feature request related to a problem? Please describe.**

Right now, you can mount /dev, but not /dev/net. This was most recently noticed by @Xe

**Describe the solution you'd like**

Mounts should be allowed to overlap, with the last added, most specific win. inodes should make sense when doing stat or readdir.

**Describe alternatives you've considered**

Currently, we just don't allow it. When originally written, the psuedo root directory had a lot of edge cases and this is even more. However, it is solvable if we do very good testing and don't punt inode tests.

**Additional context**

So, for example, if you mount
 * /dev and /dev/net, the directory listing of /dev will include its entries plus net in a consistent order
   * if the mount for /dev already includes net, its stat info will be overridden with the one from /dev/net mount
   * when reading /dev/net in wasip1, where dot dirents are required, ".." is the inode of the /dev mount

* / and /usr/bin,  the directory listing of /usr is a pseudo dir which includes bin
  * in wasi the ".." dirent of /usr/bin goes to the pseudo dir "/usr", and the ".." of /usr to root.

There are also some edge cases to handle around writes. basically make sure no psuedo dir is writable. resolving symlinks may be put out of scope.

Pull request description:
Allows wasip1 guests to use arbitrarily nested pre-opens
This removes composite filesystem support only used for guests compiled with `GOOS=js`. Now, these guests have mount a root path and do any composition externally (such as via symlinks or composing `fs.FS`).

Removing our composite filesystem reduces maintenance and allows `wasip1` guests to use any paths they want.

Before, we had a composite filesystem adapter, allowing wasm compiled by Go's `GOOS=js` to emulate pre-opens. This implementation was incomplete, preventing mounts like "/usr/bin" to work in wasip1. `GOOS=js` is both experimental and will be removed after Go 1.22 is released (two versions of its alternative `GOOS=wasip1`).

Fixes #1525

Patch:
diff --git a/cmd/wazero/wazero.go b/cmd/wazero/wazero.go
index abb648f9..93c24f67 100644
--- a/cmd/wazero/wazero.go
+++ b/cmd/wazero/wazero.go
@@ -23,6 +23,7 @@ import (
 	"github.com/tetratelabs/wazero/experimental/sock"
 	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
 	"github.com/tetratelabs/wazero/internal/platform"
+	internalsys "github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/version"
 	"github.com/tetratelabs/wazero/sys"
 )
@@ -336,6 +337,14 @@ func doRun(args []string, stdOut io.Writer, stdErr logging.Writer) int {
 			_, err = rt.InstantiateModule(ctx, code, conf)
 		}
 	case modeGo:
+		// Fail fast on multiple mounts with the deprecated GOOS=js.
+		// GOOS=js will be removed in favor of GOOS=wasip1 once v1.22 is out.
+		if count := len(mounts); count > 1 || (count == 1 && rootPath == "") {
+			fmt.Fprintf(stdErr, "invalid mount: only root mounts supported in GOOS=js: %v\n"+
+				"Consider switching to GOOS=wasip1.\n", mounts)
+			return 1
+		}
+
 		gojs.MustInstantiate(ctx, rt)
 
 		config := gojs.NewConfig(conf).WithOSUser()
@@ -394,11 +403,6 @@ func validateMounts(mounts sliceFlag, stdErr logging.Writer) (rc int, rootPath s
 			guestPath = dir
 		}
 
-		// Provide a better experience if duplicates are found later.
-		if guestPath == "" {
-			guestPath = "/"
-		}
-
 		// Eagerly validate the mounts as we know they should be on the host.
 		if abs, err := filepath.Abs(dir); err != nil {
 			fmt.Fprintf(stdErr, "invalid mount: path %q invalid: %v\n", dir, err)
@@ -420,7 +424,7 @@ func validateMounts(mounts sliceFlag, stdErr logging.Writer) (rc int, rootPath s
 			config = config.WithDirMount(dir, guestPath)
 		}
 
-		if guestPath == "/" {
+		if internalsys.StripPrefixesAndTrailingSlash(guestPath) == "" {
 			rootPath = dir
 		}
 	}
diff --git a/config.go b/config.go
index 56a32fcf..9e6e9fb0 100644
--- a/config.go
+++ b/config.go
@@ -839,11 +839,10 @@ func (c *moduleConfig) toSysContext() (sysCtx *internalsys.Context, err error) {
 		environ = append(environ, result)
 	}
 
-	var fs fsapi.FS
+	var fs []fsapi.FS
+	var guestPaths []string
 	if f, ok := c.fsConfig.(*fsConfig); ok {
-		if fs, err = f.toFS(); err != nil {
-			return
-		}
+		fs, guestPaths = f.preopens()
 	}
 
 	var listeners []*net.TCPListener
@@ -864,7 +863,7 @@ func (c *moduleConfig) toSysContext() (sysCtx *internalsys.Context, err error) {
 		c.walltime, c.walltimeResolution,
 		c.nanotime, c.nanotimeResolution,
 		c.nanosleep, c.osyield,
-		fs,
+		fs, guestPaths,
 		listeners,
 	)
 }
diff --git a/fsconfig.go b/fsconfig.go
index 099a7917..b603d1e9 100644
--- a/fsconfig.go
+++ b/fsconfig.go
@@ -4,6 +4,7 @@ import (
 	"io/fs"
 
 	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/sys"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 )
 
@@ -30,8 +31,7 @@ import (
 //
 // More notes on `guestPath`
 //   - Go compiled with runtime.GOOS=js do not pay attention to this value.
-//     Hence, you need to normalize the filesystem with NewRootFS to ensure
-//     paths requested resolve as expected.
+//     It only works with root mounts ("").
 //   - Working directories are typically tracked in wasm, though possible some
 //     relative paths are requested. For example, TinyGo may attempt to resolve
 //     a path "../.." in unit tests.
@@ -167,7 +167,10 @@ func (c *fsConfig) WithFSMount(fs fs.FS, guestPath string) FSConfig {
 }
 
 func (c *fsConfig) withMount(fs fsapi.FS, guestPath string) FSConfig {
-	cleaned := sysfs.StripPrefixesAndTrailingSlash(guestPath)
+	if _, ok := fs.(fsapi.UnimplementedFS); ok {
+		return c // don't add fake paths.
+	}
+	cleaned := sys.StripPrefixesAndTrailingSlash(guestPath)
 	ret := c.clone()
 	if i, ok := ret.guestPathToFS[cleaned]; ok {
 		ret.fs[i] = fs
@@ -180,6 +183,16 @@ func (c *fsConfig) withMount(fs fsapi.FS, guestPath string) FSConfig {
 	return ret
 }
 
-func (c *fsConfig) toFS() (fsapi.FS, error) {
-	return sysfs.NewRootFS(c.fs, c.guestPaths)
+// preopens returns the possible nil index-correlated preopened filesystems
+// with guest paths.
+func (c *fsConfig) preopens() ([]fsapi.FS, []string) {
+	preopenCount := len(c.fs)
+	if preopenCount == 0 {
+		return nil, nil
+	}
+	fs := make([]fsapi.FS, len(c.fs))
+	copy(fs, c.fs)
+	guestPaths := make([]string, len(c.guestPaths))
+	copy(guestPaths, c.guestPaths)
+	return fs, guestPaths
 }
diff --git a/internal/engine/compiler/engine.go b/internal/engine/compiler/engine.go
index d5ec8e27..ca07a716 100644
--- a/internal/engine/compiler/engine.go
+++ b/internal/engine/compiler/engine.go
@@ -496,6 +496,13 @@ func (e *engine) Close() (err error) {
 	e.mux.Lock()
 	defer e.mux.Unlock()
 	// Releasing the references to compiled codes including the memory-mapped machine codes.
+
+	for i := range e.codes {
+		for j := range e.codes[i].functions {
+			e.codes[i].functions[j].parent = nil
+		}
+	}
+
 	e.codes = nil
 	return
 }
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 3f1eb6e5..52be57bc 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -278,14 +278,21 @@ type FSContext struct {
 // descriptors to file entries.
 type FileTable = descriptor.Table[int32, *FileEntry]
 
-// ReaddirTable is a specialization of the descriptor.Table type used to map file
-// descriptors to Readdir structs.
+// ReaddirTable is a specialization of the descriptor.Table type used to map
+// file descriptors to Readdir structs.
 type ReaddirTable = descriptor.Table[int32, *Readdir]
 
-// RootFS returns the underlying filesystem. Any files that should be added to
-// the table should be inserted via InsertFile.
+// RootFS returns a possibly unimplemented root filesystem. Any files that
+// should be added to the table should be inserted via InsertFile.
+//
+// TODO: This is only used by GOOS=js and tests: Remove when we remove GOOS=js
+// (after Go 1.22 is released).
 func (c *FSContext) RootFS() fsapi.FS {
-	return c.rootFS
+	if rootFS := c.rootFS; rootFS == nil {
+		return fsapi.UnimplementedFS{}
+	} else {
+		return rootFS
+	}
 }
 
 // OpenFile opens the file into the table and returns its file descriptor.
@@ -426,18 +433,14 @@ func (c *FSContext) Close() (err error) {
 	return
 }
 
-// NewFSContext creates a FSContext with stdio streams and an optional
-// pre-opened filesystem.
-//
-// If `preopened` is not UnimplementedFS, it is inserted into
-// the file descriptor table as FdPreopen.
-func (c *Context) NewFSContext(
+// InitFSContext initializes a FSContext with stdio streams and optional
+// pre-opened filesystems and TCP listeners.
+func (c *Context) InitFSContext(
 	stdin io.Reader,
 	stdout, stderr io.Writer,
-	rootFS fsapi.FS,
+	fs []fsapi.FS, guestPaths []string,
 	tcpListeners []*net.TCPListener,
 ) (err error) {
-	c.fsc.rootFS = rootFS
 	inFile, err := stdinFileEntry(stdin)
 	if err != nil {
 		return err
@@ -454,24 +457,17 @@ func (c *Context) NewFSContext(
 	}
 	c.fsc.openedFiles.Insert(errWriter)
 
-	if _, ok := rootFS.(fsapi.UnimplementedFS); ok {
-		// don't add to the pre-opens
-	} else if comp, ok := rootFS.(*sysfs.CompositeFS); ok {
-		preopens := comp.FS()
-		for i, p := range comp.GuestPaths() {
-			c.fsc.openedFiles.Insert(&FileEntry{
-				FS:        preopens[i],
-				Name:      p,
-				IsPreopen: true,
-				File:      &lazyDir{fs: rootFS},
-			})
+	for i, fs := range fs {
+		guestPath := guestPaths[i]
+
+		if StripPrefixesAndTrailingSlash(guestPath) == "" {
+			c.fsc.rootFS = fs
 		}
-	} else {
 		c.fsc.openedFiles.Insert(&FileEntry{
-			FS:        rootFS,
-			Name:      "/",
+			FS:        fs,
+			Name:      guestPath,
 			IsPreopen: true,
-			File:      &lazyDir{fs: rootFS},
+			File:      &lazyDir{fs: fs},
 		})
 	}
 
@@ -480,3 +476,42 @@ func (c *Context) NewFSContext(
 	}
 	return nil
 }
+
+// StripPrefixesAndTrailingSlash skips any leading "./" or "/" such that the
+// result index begins with another string. A result of "." coerces to the
+// empty string "" because the current directory is handled by the guest.
+//
+// Results are the offset/len pair which is an optimization to avoid re-slicing
+// overhead, as this function is called for every path operation.
+//
+// Note: Relative paths should be handled by the guest, as that's what knows
+// what the current directory is. However, paths that escape the current
+// directory e.g. "../.." have been found in `tinygo test` and this
+// implementation takes care to avoid it.
+func StripPrefixesAndTrailingSlash(path string) string {
+	// strip trailing slashes
+	pathLen := len(path)
+	for ; pathLen > 0 && path[pathLen-1] == '/'; pathLen-- {
+	}
+
+	pathI := 0
+loop:
+	for pathI < pathLen {
+		switch path[pathI] {
+		case '/':
+			pathI++
+		case '.':
+			nextI := pathI + 1
+			if nextI < pathLen && path[nextI] == '/' {
+				pathI = nextI + 1
+			} else if nextI == pathLen {
+				pathI = nextI
+			} else {
+				break loop
+			}
+		default:
+			break loop
+		}
+	}
+	return path[pathI:pathLen]
+}
diff --git a/internal/sys/sys.go b/internal/sys/sys.go
index f035712e..aedd7c70 100644
--- a/internal/sys/sys.go
+++ b/internal/sys/sys.go
@@ -114,7 +114,7 @@ func (c *Context) RandSource() io.Reader {
 //
 // Note: This is only used for testing.
 func DefaultContext(fs fsapi.FS) *Context {
-	if sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, nil, fs, nil); err != nil {
+	if sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, nil, []fsapi.FS{fs}, []string{""}, nil); err != nil {
 		panic(fmt.Errorf("BUG: DefaultContext should never error: %w", err))
 	} else {
 		return sysCtx
@@ -135,7 +135,7 @@ func NewContext(
 	nanotimeResolution sys.ClockResolution,
 	nanosleep sys.Nanosleep,
 	osyield sys.Osyield,
-	rootFS fsapi.FS,
+	fs []fsapi.FS, guestPaths []string,
 	tcpListeners []*net.TCPListener,
 ) (sysCtx *Context, err error) {
 	sysCtx = &Context{args: args, environ: environ}
@@ -188,11 +188,7 @@ func NewContext(
 		sysCtx.osyield = platform.FakeOsyield
 	}
 
-	if rootFS != nil {
-		err = sysCtx.NewFSContext(stdin, stdout, stderr, rootFS, tcpListeners)
-	} else {
-		err = sysCtx.NewFSContext(stdin, stdout, stderr, fsapi.UnimplementedFS{}, tcpListeners)
-	}
+	err = sysCtx.InitFSContext(stdin, stdout, stderr, fs, guestPaths, tcpListeners)
 
 	return
 }
diff --git a/internal/sysfs/rootfs.go b/internal/sysfs/rootfs.go
deleted file mode 100644
index 75bd839f..00000000
--- a/internal/sysfs/rootfs.go
+++ /dev/null
@@ -1,559 +0,0 @@
-package sysfs
-
-import (
-	"fmt"
-	"io"
-	"io/fs"
-	"strings"
-	"syscall"
-
-	"github.com/tetratelabs/wazero/internal/fsapi"
-)
-
-func NewRootFS(fs []fsapi.FS, guestPaths []string) (fsapi.FS, error) {
-	switch len(fs) {
-	case 0:
-		return fsapi.UnimplementedFS{}, nil
-	case 1:
-		if StripPrefixesAndTrailingSlash(guestPaths[0]) == "" {
-			return fs[0], nil
-		}
-	}
-
-	ret := &CompositeFS{
-		string:            stringFS(fs, guestPaths),
-		fs:                make([]fsapi.FS, len(fs)),
-		guestPaths:        make([]string, len(fs)),
-		cleanedGuestPaths: make([]string, len(fs)),
-		rootGuestPaths:    map[string]int{},
-		rootIndex:         -1,
-	}
-
-	copy(ret.guestPaths, guestPaths)
-	copy(ret.fs, fs)
-
-	for i, guestPath := range guestPaths {
-		// Clean the prefix in the same way path matches will.
-		cleaned := StripPrefixesAndTrailingSlash(guestPath)
-		if cleaned == "" {
-			if ret.rootIndex != -1 {
-				return nil, fmt.Errorf("multiple root filesystems are invalid: %s", ret.string)
-			}
-			ret.rootIndex = i
-		} else if strings.HasPrefix(cleaned, "..") {
-			// ../ mounts are special cased and aren't returned in a directory
-			// listing, so we can ignore them for now.
-		} else if strings.Contains(cleaned, "/") {
-			return nil, fmt.Errorf("only single-level guest paths allowed: %s", ret.string)
-		} else {
-			ret.rootGuestPaths[cleaned] = i
-		}
-		ret.cleanedGuestPaths[i] = cleaned
-	}
-
-	// Ensure there is always a root match to keep runtime logic simpler.
-	if ret.rootIndex == -1 {
-		ret.rootIndex = len(fs)
-		ret.cleanedGuestPaths = append(ret.cleanedGuestPaths, "")
-		ret.fs = append(ret.fs, &fakeRootFS{})
-	}
-	return ret, nil
-}
-
-type CompositeFS struct {
-	fsapi.UnimplementedFS
-	// string is cached for convenience.
-	string string
-	// fs is index-correlated with cleanedGuestPaths
-	fs []fsapi.FS
-	// guestPaths are the original paths supplied by the end user, cleaned as
-	// cleanedGuestPaths.
-	guestPaths []string
-	// cleanedGuestPaths to match in precedence order, ascending.
-	cleanedGuestPaths []string
-	// rootGuestPaths are cleanedGuestPaths that exist directly under root, such as
-	// "tmp".
-	rootGuestPaths map[string]int
-	// rootIndex is the index in fs that is the root filesystem
-	rootIndex int
-}
-
-// String implements fmt.Stringer
-func (c *CompositeFS) String() string {
-	return c.string
-}
-
-func stringFS(fs []fsapi.FS, guestPaths []string) string {
-	var ret strings.Builder
-	ret.WriteString("[")
-	writeMount(&ret, fs[0], guestPaths[0])
-	for i, f := range fs[1:] {
-		ret.WriteString(" ")
-		writeMount(&ret, f, guestPaths[i+1])
-	}
-	ret.WriteString("]")
-	return ret.String()
-}
-
-func writeMount(ret *strings.Builder, f fsapi.FS, guestPath string) {
-	ret.WriteString(f.String())
-	ret.WriteString(":")
-	ret.WriteString(guestPath)
-	if _, ok := f.(*readFS); ok {
-		ret.WriteString(":ro")
-	}
-}
-
-// GuestPaths returns the underlying pre-open paths in original order.
-func (c *CompositeFS) GuestPaths() (guestPaths []string) {
-	return c.guestPaths
-}
-
-// FS returns the underlying filesystems in original order.
-func (c *CompositeFS) FS() (fs []fsapi.FS) {
-	fs = make([]fsapi.FS, len(c.guestPaths))
-	copy(fs, c.fs)
-	return
-}
-
-// OpenFile implements the same method as documented on api.FS
-func (c *CompositeFS) OpenFile(path string, flag int, perm fs.FileMode) (f fsapi.File, err syscall.Errno) {
-	matchIndex, relativePath := c.chooseFS(path)
-
-	f, err = c.fs[matchIndex].OpenFile(relativePath, flag, perm)
-	if err != 0 {
-		return
-	}
-
-	// Ensure the root directory listing includes any prefix mounts.
-	if matchIndex == c.rootIndex {
-		switch path {
-		case ".", "/", "":
-			if len(c.rootGuestPaths) > 0 {
-				f = &openRootDir{path: path, c: c, f: f}
-			}
-		}
-	}
-	return
-}
-
-// An openRootDir is a root directory open for reading, which has mounts inside
-// of it.
-type openRootDir struct {
-	fsapi.DirFile
-
-	path     string
-	c        *CompositeFS
-	f        fsapi.File     // the directory file itself
-	dirents  []fsapi.Dirent // the directory contents
-	direntsI int            // the read offset, an index into the files slice
-}
-
-// Ino implements the same method as documented on fsapi.File
-func (d *openRootDir) Ino() (uint64, syscall.Errno) {
-	return d.f.Ino()
-}
-
-// Stat implements the same method as documented on fsapi.File
-func (d *openRootDir) Stat() (fsapi.Stat_t, syscall.Errno) {
-	return d.f.Stat()
-}
-
-// Seek implements the same method as documented on fsapi.File
-func (d *openRootDir) Seek(offset int64, whence int) (newOffset int64, errno syscall.Errno) {
-	if offset != 0 || whence != io.SeekStart {
-		errno = syscall.ENOSYS
-		return
-	}
-	d.dirents = nil
-	d.direntsI = 0
-	return d.f.Seek(offset, whence)
-}
-
-// Readdir implements the same method as documented on fsapi.File
-func (d *openRootDir) Readdir(count int) (dirents []fsapi.Dirent, errno syscall.Errno) {
-	if d.dirents == nil {
-		if errno = d.readdir(); errno != 0 {
-			return
-		}
-	}
-
-	// logic similar to go:embed
-	n := len(d.dirents) - d.direntsI
-	if n == 0 {
-		return
-	}
-	if count > 0 && n > count {
-		n = count
-	}
-	dirents = make([]fsapi.Dirent, n)
-	for i := range dirents {
-		dirents[i] = d.dirents[d.direntsI+i]
-	}
-	d.direntsI += n
-	return
-}
-
-func (d *openRootDir) readdir() (errno syscall.Errno) {
-	// readDir reads the directory fully into d.dirents, replacing any entries that
-	// correspond to prefix matches or appending them to the end.
-	if d.dirents, errno = d.f.Readdir(-1); errno != 0 {
-		return
-	}
-
-	remaining := make(map[string]int, len(d.c.rootGuestPaths))
-	for k, v := range d.c.rootGuestPaths {
-		remaining[k] = v
-	}
-
-	for i := range d.dirents {
-		e := d.dirents[i]
-		if fsI, ok := remaining[e.Name]; ok {
-			if d.dirents[i], errno = d.rootEntry(e.Name, fsI); errno != 0 {
-				return
-			}
-			delete(remaining, e.Name)
-		}
-	}
-
-	var di fsapi.Dirent
-	for n, fsI := range remaining {
-		if di, errno = d.rootEntry(n, fsI); errno != 0 {
-			return
-		}
-		d.dirents = append(d.dirents, di)
-	}
-	return
-}
-
-// Sync implements the same method as documented on fsapi.File
-func (d *openRootDir) Sync() syscall.Errno {
-	return d.f.Sync()
-}
-
-// Datasync implements the same method as documented on fsapi.File
-func (d *openRootDir) Datasync() syscall.Errno {
-	return d.f.Datasync()
-}
-
-// Chmod implements the same method as documented on fsapi.File
-func (d *openRootDir) Chmod(fs.FileMode) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Chown implements the same method as documented on fsapi.File
-func (d *openRootDir) Chown(int, int) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Utimens implements the same method as documented on fsapi.File
-func (d *openRootDir) Utimens(*[2]syscall.Timespec) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Close implements fs.File
-func (d *openRootDir) Close() syscall.Errno {
-	return d.f.Close()
-}
-
-func (d *openRootDir) rootEntry(name string, fsI int) (fsapi.Dirent, syscall.Errno) {
-	if st, errno := d.c.fs[fsI].Stat("."); errno != 0 {
-		return fsapi.Dirent{}, errno
-	} else {
-		return fsapi.Dirent{Name: name, Ino: st.Ino, Type: st.Mode.Type()}, 0
-	}
-}
-
-// Lstat implements the same method as documented on api.FS
-func (c *CompositeFS) Lstat(path string) (fsapi.Stat_t, syscall.Errno) {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Lstat(relativePath)
-}
-
-// Stat implements the same method as documented on api.FS
-func (c *CompositeFS) Stat(path string) (fsapi.Stat_t, syscall.Errno) {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Stat(relativePath)
-}
-
-// Mkdir implements the same method as documented on api.FS
-func (c *CompositeFS) Mkdir(path string, perm fs.FileMode) syscall.Errno {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Mkdir(relativePath, perm)
-}
-
-// Chmod implements the same method as documented on api.FS
-func (c *CompositeFS) Chmod(path string, perm fs.FileMode) syscall.Errno {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Chmod(relativePath, perm)
-}
-
-// Chown implements the same method as documented on api.FS
-func (c *CompositeFS) Chown(path string, uid, gid int) syscall.Errno {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Chown(relativePath, uid, gid)
-}
-
-// Lchown implements the same method as documented on api.FS
-func (c *CompositeFS) Lchown(path string, uid, gid int) syscall.Errno {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Lchown(relativePath, uid, gid)
-}
-
-// Rename implements the same method as documented on api.FS
-func (c *CompositeFS) Rename(from, to string) syscall.Errno {
-	fromFS, fromPath := c.chooseFS(from)
-	toFS, toPath := c.chooseFS(to)
-	if fromFS != toFS {
-		return syscall.ENOSYS // not yet anyway
-	}
-	return c.fs[fromFS].Rename(fromPath, toPath)
-}
-
-// Readlink implements the same method as documented on api.FS
-func (c *CompositeFS) Readlink(path string) (string, syscall.Errno) {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Readlink(relativePath)
-}
-
-// Link implements the same method as documented on api.FS
-func (c *CompositeFS) Link(oldName, newName string) syscall.Errno {
-	fromFS, oldNamePath := c.chooseFS(oldName)
-	toFS, newNamePath := c.chooseFS(newName)
-	if fromFS != toFS {
-		return syscall.ENOSYS // not yet anyway
-	}
-	return c.fs[fromFS].Link(oldNamePath, newNamePath)
-}
-
-// Utimens implements the same method as documented on api.FS
-func (c *CompositeFS) Utimens(path string, times *[2]syscall.Timespec, symlinkFollow bool) syscall.Errno {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Utimens(relativePath, times, symlinkFollow)
-}
-
-// Symlink implements the same method as documented on api.FS
-func (c *CompositeFS) Symlink(oldName, link string) (err syscall.Errno) {
-	fromFS, oldNamePath := c.chooseFS(oldName)
-	toFS, linkPath := c.chooseFS(link)
-	if fromFS != toFS {
-		return syscall.ENOSYS // not yet anyway
-	}
-	return c.fs[fromFS].Symlink(oldNamePath, linkPath)
-}
-
-// Truncate implements the same method as documented on api.FS
-func (c *CompositeFS) Truncate(path string, size int64) syscall.Errno {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Truncate(relativePath, size)
-}
-
-// Rmdir implements the same method as documented on api.FS
-func (c *CompositeFS) Rmdir(path string) syscall.Errno {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Rmdir(relativePath)
-}
-
-// Unlink implements the same method as documented on api.FS
-func (c *CompositeFS) Unlink(path string) syscall.Errno {
-	matchIndex, relativePath := c.chooseFS(path)
-	return c.fs[matchIndex].Unlink(relativePath)
-}
-
-// chooseFS chooses the best fs and the relative path to use for the input.
-func (c *CompositeFS) chooseFS(path string) (matchIndex int, relativePath string) {
-	matchIndex = -1
-	matchPrefixLen := 0
-	pathI, pathLen := stripPrefixesAndTrailingSlash(path)
-
-	// Last is the highest precedence, so we iterate backwards. The last longest
-	// match wins. e.g. the pre-open "tmp" wins vs "" regardless of order.
-	for i := len(c.fs) - 1; i >= 0; i-- {
-		prefix := c.cleanedGuestPaths[i]
-		if eq, match := hasPathPrefix(path, pathI, pathLen, prefix); eq {
-			// When the input equals the prefix, there cannot be a longer match
-			// later. The relative path is the fsapi.FS root, so return empty
-			// string.
-			matchIndex = i
-			relativePath = ""
-			return
-		} else if match {
-			// Check to see if this is a longer match
-			prefixLen := len(prefix)
-			if prefixLen > matchPrefixLen || matchIndex == -1 {
-				matchIndex = i
-				matchPrefixLen = prefixLen
-			}
-		} // Otherwise, keep looking for a match
-	}
-
-	// Now, we know the path != prefix, but it matched an existing fs, because
-	// setup ensures there's always a root filesystem.
-
-	// If this was a root path match the cleaned path is the relative one to
-	// pass to the underlying filesystem.
-	if matchPrefixLen == 0 {
-		// Avoid re-slicing when the input is already clean
-		if pathI == 0 && len(path) == pathLen {
-			relativePath = path
-		} else {
-			relativePath = path[pathI:pathLen]
-		}
-		return
-	}
-
-	// Otherwise, it is non-root match: the relative path is past "$prefix/"
-	pathI += matchPrefixLen + 1 // e.g. prefix=foo, path=foo/bar -> bar
-	relativePath = path[pathI:pathLen]
-	return
-}
-
-// hasPathPrefix compares an input path against a prefix, both cleaned by
-// stripPrefixesAndTrailingSlash. This returns a pair of eq, match to allow an
-// early short circuit on match.
-//
-// Note: This is case-sensitive because POSIX paths are compared case
-// sensitively.
-func hasPathPrefix(path string, pathI, pathLen int, prefix string) (eq, match bool) {
-	matchLen := pathLen - pathI
-	if prefix == "" {
-		return matchLen == 0, true // e.g. prefix=, path=foo
-	}
-
-	prefixLen := len(prefix)
-	// reset pathLen temporarily to represent the length to match as opposed to
-	// the length of the string (that may contain leading slashes).
-	if matchLen == prefixLen {
-		if pathContainsPrefix(path, pathI, prefixLen, prefix) {
-			return true, true // e.g. prefix=bar, path=bar
-		}
-		return false, false
-	} else if matchLen < prefixLen {
-		return false, false // e.g. prefix=fooo, path=foo
-	}
-
-	if path[pathI+prefixLen] != '/' {
-		return false, false // e.g. prefix=foo, path=fooo
-	}
-
-	// Not equal, but maybe a match. e.g. prefix=foo, path=foo/bar
-	return false, pathContainsPrefix(path, pathI, prefixLen, prefix)
-}
-
-// pathContainsPrefix is faster than strings.HasPrefix even if we didn't cache
-// the index,len. See benchmarks.
-func pathContainsPrefix(path string, pathI, prefixLen int, prefix string) bool {
-	for i := 0; i < prefixLen; i++ {
-		if path[pathI] != prefix[i] {
-			return false // e.g. prefix=bar, path=foo or foo/bar
-		}
-		pathI++
-	}
-	return true // e.g. prefix=foo, path=foo or foo/bar
-}
-
-func StripPrefixesAndTrailingSlash(path string) string {
-	pathI, pathLen := stripPrefixesAndTrailingSlash(path)
-	return path[pathI:pathLen]
-}
-
-// stripPrefixesAndTrailingSlash skips any leading "./" or "/" such that the
-// result index begins with another string. A result of "." coerces to the
-// empty string "" because the current directory is handled by the guest.
-//
-// Results are the offset/len pair which is an optimization to avoid re-slicing
-// overhead, as this function is called for every path operation.
-//
-// Note: Relative paths should be handled by the guest, as that's what knows
-// what the current directory is. However, paths that escape the current
-// directory e.g. "../.." have been found in `tinygo test` and this
-// implementation takes care to avoid it.
-func stripPrefixesAndTrailingSlash(path string) (pathI, pathLen int) {
-	// strip trailing slashes
-	pathLen = len(path)
-	for ; pathLen > 0 && path[pathLen-1] == '/'; pathLen-- {
-	}
-
-	pathI = 0
-loop:
-	for pathI < pathLen {
-		switch path[pathI] {
-		case '/':
-			pathI++
-		case '.':
-			nextI := pathI + 1
-			if nextI < pathLen && path[nextI] == '/' {
-				pathI = nextI + 1
-			} else if nextI == pathLen {
-				pathI = nextI
-			} else {
-				break loop
-			}
-		default:
-			break loop
-		}
-	}
-	return
-}
-
-type fakeRootFS struct {
-	fsapi.UnimplementedFS
-}
-
-// OpenFile implements the same method as documented on api.FS
-func (fakeRootFS) OpenFile(path string, flag int, perm fs.FileMode) (fsapi.File, syscall.Errno) {
-	switch path {
-	case ".", "/", "":
-		return fakeRootDir{}, 0
-	}
-	return nil, syscall.ENOENT
-}
-
-type fakeRootDir struct {
-	fsapi.DirFile
-}
-
-// Ino implements the same method as documented on fsapi.File
-func (fakeRootDir) Ino() (uint64, syscall.Errno) {
-	return 0, 0
-}
-
-// Stat implements the same method as documented on fsapi.File
-func (fakeRootDir) Stat() (fsapi.Stat_t, syscall.Errno) {
-	return fsapi.Stat_t{Mode: fs.ModeDir, Nlink: 1}, 0
-}
-
-// Readdir implements the same method as documented on fsapi.File
-func (fakeRootDir) Readdir(int) (dirents []fsapi.Dirent, errno syscall.Errno) {
-	return // empty
-}
-
-// Sync implements the same method as documented on fsapi.File
-func (fakeRootDir) Sync() syscall.Errno {
-	return 0
-}
-
-// Datasync implements the same method as documented on fsapi.File
-func (fakeRootDir) Datasync() syscall.Errno {
-	return 0
-}
-
-// Chmod implements the same method as documented on fsapi.File
-func (fakeRootDir) Chmod(fs.FileMode) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Chown implements the same method as documented on fsapi.File
-func (fakeRootDir) Chown(int, int) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Utimens implements the same method as documented on fsapi.File
-func (fakeRootDir) Utimens(*[2]syscall.Timespec) syscall.Errno {
-	return syscall.ENOSYS
-}
-
-// Close implements the same method as documented on fsapi.File
-func (fakeRootDir) Close() syscall.Errno {
-	return 0
-}


Test patch:
diff --git a/cmd/wazero/wazero_test.go b/cmd/wazero/wazero_test.go
index 5264130a..67464356 100644
--- a/cmd/wazero/wazero_test.go
+++ b/cmd/wazero/wazero_test.go
@@ -397,8 +397,8 @@ func TestRun(t *testing.T) {
 		{
 			name:       "GOARCH=wasm GOOS=js hostlogging=proc",
 			wasm:       wasmCatGo,
-			wazeroOpts: []string{"--hostlogging=proc", fmt.Sprintf("--mount=%s:/animals:ro", bearDir)},
-			wasmArgs:   []string{"/animals/not-bear.txt"},
+			wazeroOpts: []string{"--hostlogging=proc", fmt.Sprintf("--mount=%s:/:ro", bearDir)},
+			wasmArgs:   []string{"/not-bear.txt"},
 			expectedStderr: `==> go.runtime.wasmExit(code=1)
 <==
 `,
@@ -424,6 +424,16 @@ func TestRun(t *testing.T) {
 <== (err=<nil>,ok=true)
 `, bearMode, bearMtime),
 		},
+		{
+			name:       "GOARCH=wasm GOOS=js not root mount",
+			wasm:       wasmCatGo,
+			wazeroOpts: []string{"--hostlogging=proc", fmt.Sprintf("--mount=%s:/animals:ro", bearDir)},
+			wasmArgs:   []string{"/not-bear.txt"},
+			expectedStderr: fmt.Sprintf(`invalid mount: only root mounts supported in GOOS=js: [%s:/animals:ro]
+Consider switching to GOOS=wasip1.
+`, bearDir),
+			expectedExitCode: 1,
+		},
 		{
 			name:       "cachedir existing absolute",
 			wazeroOpts: []string{"--cachedir=" + existingDir1},
diff --git a/fsconfig_test.go b/fsconfig_test.go
index a6c58bbd..2ea3aded 100644
--- a/fsconfig_test.go
+++ b/fsconfig_test.go
@@ -9,7 +9,7 @@ import (
 	"github.com/tetratelabs/wazero/internal/testing/require"
 )
 
-// TestFSConfig only tests the cases that change the inputs to sysfs.NewRootFS.
+// TestFSConfig only tests the cases that change the inputs to sysfs.ValidatePreopens.
 func TestFSConfig(t *testing.T) {
 	base := NewFSConfig()
 
@@ -17,46 +17,42 @@ func TestFSConfig(t *testing.T) {
 	testFS2 := testfs.FS{"/": &testfs.File{}}
 
 	tests := []struct {
-		name     string
-		input    FSConfig
-		expected fsapi.FS
+		name               string
+		input              FSConfig
+		expectedFS         []fsapi.FS
+		expectedGuestPaths []string
 	}{
 		{
-			name:     "empty",
-			input:    base,
-			expected: fsapi.UnimplementedFS{},
+			name:  "empty",
+			input: base,
 		},
 		{
-			name:     "WithFSMount",
-			input:    base.WithFSMount(testFS, "/"),
-			expected: sysfs.Adapt(testFS),
+			name:               "WithFSMount",
+			input:              base.WithFSMount(testFS, "/"),
+			expectedFS:         []fsapi.FS{sysfs.Adapt(testFS)},
+			expectedGuestPaths: []string{"/"},
 		},
 		{
-			name:     "WithFSMount overwrites",
-			input:    base.WithFSMount(testFS, "/").WithFSMount(testFS2, "/"),
-			expected: sysfs.Adapt(testFS2),
+			name:               "WithFSMount overwrites",
+			input:              base.WithFSMount(testFS, "/").WithFSMount(testFS2, "/"),
+			expectedFS:         []fsapi.FS{sysfs.Adapt(testFS2)},
+			expectedGuestPaths: []string{"/"},
 		},
 		{
-			name:     "WithFsMount nil",
-			input:    base.WithFSMount(nil, "/"),
-			expected: fsapi.UnimplementedFS{},
+			name:  "WithFsMount nil",
+			input: base.WithFSMount(nil, "/"),
 		},
 		{
-			name:     "WithDirMount overwrites",
-			input:    base.WithFSMount(testFS, "/").WithDirMount(".", "/"),
-			expected: sysfs.NewDirFS("."),
+			name:               "WithDirMount overwrites",
+			input:              base.WithFSMount(testFS, "/").WithDirMount(".", "/"),
+			expectedFS:         []fsapi.FS{sysfs.NewDirFS(".")},
+			expectedGuestPaths: []string{"/"},
 		},
 		{
-			name:  "Composition",
-			input: base.WithReadOnlyDirMount(".", "/").WithDirMount("/tmp", "/tmp"),
-			expected: func() fsapi.FS {
-				f, err := sysfs.NewRootFS(
-					[]fsapi.FS{sysfs.NewReadFS(sysfs.NewDirFS(".")), sysfs.NewDirFS("/tmp")},
-					[]string{"/", "/tmp"},
-				)
-				require.NoError(t, err)
-				return f
-			}(),
+			name:               "multiple",
+			input:              base.WithReadOnlyDirMount(".", "/").WithDirMount("/tmp", "/tmp"),
+			expectedFS:         []fsapi.FS{sysfs.NewReadFS(sysfs.NewDirFS(".")), sysfs.NewDirFS("/tmp")},
+			expectedGuestPaths: []string{"/", "/tmp"},
 		},
 	}
 
@@ -64,31 +60,9 @@ func TestFSConfig(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			sysCtx, err := tc.input.(*fsConfig).toFS()
-			require.NoError(t, err)
-			require.Equal(t, tc.expected, sysCtx)
-		})
-	}
-}
-
-func TestFSConfig_Errors(t *testing.T) {
-	tests := []struct {
-		name        string
-		input       FSConfig
-		expectedErr string
-	}{
-		{
-			name:        "multi-level path not yet supported",
-			input:       NewFSConfig().WithDirMount(".", "/usr/bin"),
-			expectedErr: "only single-level guest paths allowed: [.:/usr/bin]",
-		},
-	}
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.name, func(t *testing.T) {
-			_, err := tc.input.(*fsConfig).toFS()
-			require.EqualError(t, err, tc.expectedErr)
+			fs, guestPaths := tc.input.(*fsConfig).preopens()
+			require.Equal(t, tc.expectedFS, fs)
+			require.Equal(t, tc.expectedGuestPaths, guestPaths)
 		})
 	}
 }
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index 167404fa..987a12ae 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -54,7 +54,7 @@ func TestNewFSContext(t *testing.T) {
 
 		t.Run(tc.name, func(t *testing.T) {
 			c := Context{}
-			err := c.NewFSContext(nil, nil, nil, tc.fs, nil)
+			err := c.InitFSContext(nil, nil, nil, []fsapi.FS{tc.fs}, []string{"/"}, nil)
 			require.NoError(t, err)
 			fsc := c.fsc
 			defer fsc.Close()
@@ -92,7 +92,7 @@ func TestFSContext_CloseFile(t *testing.T) {
 	testFS := sysfs.Adapt(embedFS)
 
 	c := Context{}
-	err = c.NewFSContext(nil, nil, nil, testFS, nil)
+	err = c.InitFSContext(nil, nil, nil, []fsapi.FS{testFS}, []string{"/"}, nil)
 	require.NoError(t, err)
 	fsc := c.fsc
 	defer fsc.Close()
@@ -122,14 +122,14 @@ func TestFSContext_CloseFile(t *testing.T) {
 	})
 }
 
-func TestUnimplementedFSContext(t *testing.T) {
+func TestFSContext_noPreopens(t *testing.T) {
 	c := Context{}
-	err := c.NewFSContext(nil, nil, nil, fsapi.UnimplementedFS{}, nil)
+	err := c.InitFSContext(nil, nil, nil, nil, nil, nil)
 	require.NoError(t, err)
 	testFS := &c.fsc
 	require.NoError(t, err)
 
-	expected := &FSContext{rootFS: fsapi.UnimplementedFS{}}
+	expected := &FSContext{}
 	noopStdin, _ := stdinFileEntry(nil)
 	expected.openedFiles.Insert(noopStdin)
 	noopStdout, _ := stdioWriterFileEntry("stdout", nil)
@@ -142,39 +142,7 @@ func TestUnimplementedFSContext(t *testing.T) {
 		require.NoError(t, err)
 
 		// Closes opened files
-		require.Equal(t, &FSContext{rootFS: fsapi.UnimplementedFS{}}, testFS)
-	})
-}
-
-func TestCompositeFSContext(t *testing.T) {
-	tmpDir1 := t.TempDir()
-	testFS1 := sysfs.NewDirFS(tmpDir1)
-
-	tmpDir2 := t.TempDir()
-	testFS2 := sysfs.NewDirFS(tmpDir2)
-
-	rootFS, err := sysfs.NewRootFS([]fsapi.FS{testFS2, testFS1}, []string{"/tmp", "/"})
-	require.NoError(t, err)
-
-	c := Context{}
-	err = c.NewFSContext(nil, nil, nil, rootFS, nil)
-	require.NoError(t, err)
-	testFS := &c.fsc
-
-	// Ensure the pre-opens have exactly the name specified, and are in order.
-	preopen3, ok := testFS.openedFiles.Lookup(3)
-	require.True(t, ok)
-	require.Equal(t, "/tmp", preopen3.Name)
-	preopen4, ok := testFS.openedFiles.Lookup(4)
-	require.True(t, ok)
-	require.Equal(t, "/", preopen4.Name)
-
-	t.Run("Close closes", func(t *testing.T) {
-		err := testFS.Close()
-		require.NoError(t, err)
-
-		// Closes opened files
-		require.Equal(t, &FSContext{rootFS: rootFS}, testFS)
+		require.Equal(t, &FSContext{}, testFS)
 	})
 }
 
@@ -182,7 +150,7 @@ func TestContext_Close(t *testing.T) {
 	testFS := sysfs.Adapt(testfs.FS{"foo": &testfs.File{}})
 
 	c := Context{}
-	err := c.NewFSContext(nil, nil, nil, testFS, nil)
+	err := c.InitFSContext(nil, nil, nil, []fsapi.FS{testFS}, []string{"/"}, nil)
 	require.NoError(t, err)
 	fsc := c.fsc
 
@@ -209,7 +177,7 @@ func TestContext_Close_Error(t *testing.T) {
 	testFS := sysfs.Adapt(testfs.FS{"foo": file})
 
 	c := Context{}
-	err := c.NewFSContext(nil, nil, nil, testFS, nil)
+	err := c.InitFSContext(nil, nil, nil, []fsapi.FS{testFS}, []string{"/"}, nil)
 	require.NoError(t, err)
 	fsc := c.fsc
 
@@ -226,21 +194,21 @@ func TestContext_Close_Error(t *testing.T) {
 
 func TestFSContext_Renumber(t *testing.T) {
 	tmpDir := t.TempDir()
-	dirFs := sysfs.NewDirFS(tmpDir)
+	dirFS := sysfs.NewDirFS(tmpDir)
 
 	const dirName = "dir"
-	errno := dirFs.Mkdir(dirName, 0o700)
+	errno := dirFS.Mkdir(dirName, 0o700)
 	require.EqualErrno(t, 0, errno)
 
 	c := Context{}
-	err := c.NewFSContext(nil, nil, nil, dirFs, nil)
+	err := c.InitFSContext(nil, nil, nil, []fsapi.FS{dirFS}, []string{"/"}, nil)
 	require.NoError(t, err)
 	fsc := c.fsc
 
 	defer fsc.Close()
 
 	for _, toFd := range []int32{10, 100, 100} {
-		fromFd, errno := fsc.OpenFile(dirFs, dirName, os.O_RDONLY, 0)
+		fromFd, errno := fsc.OpenFile(dirFS, dirName, os.O_RDONLY, 0)
 		require.EqualErrno(t, 0, errno)
 
 		prevDirFile, ok := fsc.LookupFile(fromFd)
@@ -358,3 +326,63 @@ func TestReaddDir_Rewind(t *testing.T) {
 		})
 	}
 }
+
+func TestStripPrefixesAndTrailingSlash(t *testing.T) {
+	tests := []struct {
+		path, expected string
+	}{
+		{
+			path:     ".",
+			expected: "",
+		},
+		{
+			path:     "/",
+			expected: "",
+		},
+		{
+			path:     "./",
+			expected: "",
+		},
+		{
+			path:     "./foo",
+			expected: "foo",
+		},
+		{
+			path:     ".foo",
+			expected: ".foo",
+		},
+		{
+			path:     "././foo",
+			expected: "foo",
+		},
+		{
+			path:     "/foo",
+			expected: "foo",
+		},
+		{
+			path:     "foo/",
+			expected: "foo",
+		},
+		{
+			path:     "//",
+			expected: "",
+		},
+		{
+			path:     "../../",
+			expected: "../..",
+		},
+		{
+			path:     "./../../",
+			expected: "../..",
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.path, func(t *testing.T) {
+			path := StripPrefixesAndTrailingSlash(tc.path)
+			require.Equal(t, tc.expected, path)
+		})
+	}
+}
diff --git a/internal/sys/sys_test.go b/internal/sys/sys_test.go
index 414d5fa3..f8d8dfc5 100644
--- a/internal/sys/sys_test.go
+++ b/internal/sys/sys_test.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/tetratelabs/wazero/internal/fsapi"
 	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/platform"
 	"github.com/tetratelabs/wazero/internal/sysfs"
@@ -21,7 +22,7 @@ func TestContext_WalltimeNanos(t *testing.T) {
 func TestDefaultSysContext(t *testing.T) {
 	testFS := sysfs.Adapt(fstest.FS)
 
-	sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, nil, testFS, nil)
+	sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, nil, []fsapi.FS{testFS}, []string{"/"}, nil)
 	require.NoError(t, err)
 
 	require.Nil(t, sysCtx.Args())
@@ -92,7 +93,7 @@ func TestNewContext_Args(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			sysCtx, err := NewContext(tc.maxSize, tc.args, nil, bytes.NewReader(make([]byte, 0)), nil, nil, nil, nil, 0, nil, 0, nil, nil, nil, nil)
+			sysCtx, err := NewContext(tc.maxSize, tc.args, nil, bytes.NewReader(make([]byte, 0)), nil, nil, nil, nil, 0, nil, 0, nil, nil, nil, nil, nil)
 			if tc.expectedErr == "" {
 				require.Nil(t, err)
 				require.Equal(t, tc.args, sysCtx.Args())
@@ -142,7 +143,7 @@ func TestNewContext_Environ(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			sysCtx, err := NewContext(tc.maxSize, nil, tc.environ, bytes.NewReader(make([]byte, 0)), nil, nil, nil, nil, 0, nil, 0, nil, nil, nil, nil)
+			sysCtx, err := NewContext(tc.maxSize, nil, tc.environ, bytes.NewReader(make([]byte, 0)), nil, nil, nil, nil, 0, nil, 0, nil, nil, nil, nil, nil)
 			if tc.expectedErr == "" {
 				require.Nil(t, err)
 				require.Equal(t, tc.environ, sysCtx.Environ())
@@ -178,7 +179,7 @@ func TestNewContext_Walltime(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, tc.time, tc.resolution, nil, 0, nil, nil, nil, nil)
+			sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, tc.time, tc.resolution, nil, 0, nil, nil, nil, nil, nil)
 			if tc.expectedErr == "" {
 				require.Nil(t, err)
 				require.Equal(t, tc.time, sysCtx.walltime)
@@ -214,7 +215,7 @@ func TestNewContext_Nanotime(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, tc.time, tc.resolution, nil, nil, nil, nil)
+			sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, tc.time, tc.resolution, nil, nil, nil, nil, nil)
 			if tc.expectedErr == "" {
 				require.Nil(t, err)
 				require.Equal(t, tc.time, sysCtx.nanotime)
@@ -259,14 +260,14 @@ func Test_clockResolutionInvalid(t *testing.T) {
 
 func TestNewContext_Nanosleep(t *testing.T) {
 	var aNs sys.Nanosleep = func(int64) {}
-	sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, aNs, nil, nil, nil)
+	sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, aNs, nil, nil, nil, nil)
 	require.Nil(t, err)
 	require.Equal(t, aNs, sysCtx.nanosleep)
 }
 
 func TestNewContext_Osyield(t *testing.T) {
 	var oy sys.Osyield = func() {}
-	sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, oy, nil, nil)
+	sysCtx, err := NewContext(0, nil, nil, nil, nil, nil, nil, nil, 0, nil, 0, nil, oy, nil, nil, nil)
 	require.Nil(t, err)
 	require.Equal(t, oy, sysCtx.osyield)
 }
diff --git a/internal/sysfs/rootfs_test.go b/internal/sysfs/rootfs_test.go
deleted file mode 100644
index fc47d535..00000000
--- a/internal/sysfs/rootfs_test.go
+++ /dev/null
@@ -1,448 +0,0 @@
-package sysfs
-
-import (
-	"errors"
-	"io/fs"
-	"os"
-	"path"
-	"sort"
-	"strings"
-	"syscall"
-	"testing"
-	gofstest "testing/fstest"
-
-	"github.com/tetratelabs/wazero/internal/fsapi"
-	"github.com/tetratelabs/wazero/internal/fstest"
-	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
-	"github.com/tetratelabs/wazero/internal/testing/require"
-)
-
-func TestNewRootFS(t *testing.T) {
-	t.Run("empty", func(t *testing.T) {
-		rootFS, err := NewRootFS(nil, nil)
-		require.NoError(t, err)
-
-		require.Equal(t, fsapi.UnimplementedFS{}, rootFS)
-	})
-	t.Run("only root", func(t *testing.T) {
-		testFS := NewDirFS(t.TempDir())
-
-		rootFS, err := NewRootFS([]fsapi.FS{testFS}, []string{""})
-		require.NoError(t, err)
-
-		// Should not be a composite filesystem
-		require.Equal(t, testFS, rootFS)
-	})
-	t.Run("only non root", func(t *testing.T) {
-		testFS := NewDirFS(".")
-
-		rootFS, err := NewRootFS([]fsapi.FS{testFS}, []string{"/tmp"})
-		require.NoError(t, err)
-
-		// unwrapping returns in original order
-		require.Equal(t, []fsapi.FS{testFS}, rootFS.(*CompositeFS).FS())
-		require.Equal(t, []string{"/tmp"}, rootFS.(*CompositeFS).GuestPaths())
-
-		// String is human-readable
-		require.Equal(t, "[.:/tmp]", rootFS.String())
-
-		// Guest can look up /tmp
-		f, errno := rootFS.OpenFile("/tmp", os.O_RDONLY, 0)
-		require.EqualErrno(t, 0, errno)
-		require.EqualErrno(t, 0, f.Close())
-
-		// Guest can look up / and see "/tmp" in it
-		f, errno = rootFS.OpenFile("/", os.O_RDONLY, 0)
-		require.EqualErrno(t, 0, errno)
-
-		dirents, errno := f.Readdir(-1)
-		require.EqualErrno(t, 0, errno)
-		require.Equal(t, 1, len(dirents))
-		require.Equal(t, "tmp", dirents[0].Name)
-		require.True(t, dirents[0].IsDir())
-	})
-	t.Run("multiple roots unsupported", func(t *testing.T) {
-		testFS := NewDirFS(".")
-
-		_, err := NewRootFS([]fsapi.FS{testFS, testFS}, []string{"/", "/"})
-		require.EqualError(t, err, "multiple root filesystems are invalid: [.:/ .:/]")
-	})
-	t.Run("virtual paths unsupported", func(t *testing.T) {
-		testFS := NewDirFS(".")
-
-		_, err := NewRootFS([]fsapi.FS{testFS}, []string{"usr/bin"})
-		require.EqualError(t, err, "only single-level guest paths allowed: [.:usr/bin]")
-	})
-	t.Run("multiple matches", func(t *testing.T) {
-		tmpDir1 := t.TempDir()
-		testFS1 := NewDirFS(tmpDir1)
-		require.NoError(t, os.Mkdir(path.Join(tmpDir1, "tmp"), 0o700))
-		require.NoError(t, os.WriteFile(path.Join(tmpDir1, "a"), []byte{1}, 0o600))
-
-		tmpDir2 := t.TempDir()
-		testFS2 := NewDirFS(tmpDir2)
-		require.NoError(t, os.WriteFile(path.Join(tmpDir2, "a"), []byte{2}, 0o600))
-
-		rootFS, err := NewRootFS([]fsapi.FS{testFS2, testFS1}, []string{"/tmp", "/"})
-		require.NoError(t, err)
-
-		// unwrapping returns in original order
-		require.Equal(t, []fsapi.FS{testFS2, testFS1}, rootFS.(*CompositeFS).FS())
-		require.Equal(t, []string{"/tmp", "/"}, rootFS.(*CompositeFS).GuestPaths())
-
-		// Should be a composite filesystem
-		require.NotEqual(t, testFS1, rootFS)
-		require.NotEqual(t, testFS2, rootFS)
-
-		t.Run("last wins", func(t *testing.T) {
-			f, errno := rootFS.OpenFile("/tmp/a", os.O_RDONLY, 0)
-			require.EqualErrno(t, 0, errno)
-			defer f.Close()
-
-			b := readAll(t, f)
-			require.Equal(t, []byte{2}, b)
-		})
-
-		// This test is covered by fstest.TestFS, but doing again here
-		t.Run("root includes prefix mount", func(t *testing.T) {
-			f, errno := rootFS.OpenFile(".", os.O_RDONLY, 0)
-			require.EqualErrno(t, 0, errno)
-			defer f.Close()
-
-			entries, errno := f.Readdir(-1)
-			require.EqualErrno(t, 0, errno)
-			names := make([]string, 0, len(entries))
-			for _, e := range entries {
-				names = append(names, e.Name)
-			}
-			sort.Strings(names)
-
-			require.Equal(t, []string{"a", "tmp"}, names)
-		})
-	})
-}
-
-func TestRootFS_String(t *testing.T) {
-	tmpFS := NewDirFS(".")
-	rootFS := NewDirFS(".")
-
-	testFS, err := NewRootFS([]fsapi.FS{rootFS, tmpFS}, []string{"/", "/tmp"})
-	require.NoError(t, err)
-
-	require.Equal(t, "[.:/ .:/tmp]", testFS.String())
-}
-
-func TestRootFS_Open(t *testing.T) {
-	tmpDir := t.TempDir()
-
-	// Create a subdirectory, so we can test reads outside the fsapi.FS root.
-	tmpDir = path.Join(tmpDir, t.Name())
-	require.NoError(t, os.Mkdir(tmpDir, 0o700))
-	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-
-	testRootFS := NewDirFS(tmpDir)
-	testDirFS := NewDirFS(t.TempDir())
-	testFS, err := NewRootFS([]fsapi.FS{testRootFS, testDirFS}, []string{"/", "/emptydir"})
-	require.NoError(t, err)
-
-	testOpen_Read(t, testFS, true)
-
-	testOpen_O_RDWR(t, tmpDir, testFS)
-
-	t.Run("path outside root valid", func(t *testing.T) {
-		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)
-
-		// fsapi.FS allows relative path lookups
-		require.True(t, errors.Is(err, fs.ErrNotExist))
-	})
-}
-
-func TestRootFS_Stat(t *testing.T) {
-	tmpDir := t.TempDir()
-	require.NoError(t, fstest.WriteTestFiles(tmpDir))
-
-	tmpFS := NewDirFS(t.TempDir())
-	testFS, err := NewRootFS([]fsapi.FS{NewDirFS(tmpDir), tmpFS}, []string{"/", "/tmp"})
-	require.NoError(t, err)
-	testStat(t, testFS)
-}
-
-func TestRootFS_examples(t *testing.T) {
-	tests := []struct {
-		name                 string
-		fs                   []fsapi.FS
-		guestPaths           []string
-		expected, unexpected []string
-	}{
-		// e.g. from Go project root:
-		//	$ GOOS=js GOARCH=wasm bin/go test -c -o template.wasm text/template
-		//	$ wazero run -mount=src/text/template:/ template.wasm -test.v
-		{
-			name: "go test text/template",
-			fs: []fsapi.FS{
-				&adapter{fs: testfs.FS{"go-example-stdout-ExampleTemplate-0.txt": &testfs.File{}}},
-				&adapter{fs: testfs.FS{"testdata/file1.tmpl": &testfs.File{}}},
-			},
-			guestPaths: []string{"/tmp", "/"},
-			expected:   []string{"/tmp/go-example-stdout-ExampleTemplate-0.txt", "testdata/file1.tmpl"},
-			unexpected: []string{"DOES NOT EXIST"},
-		},
-		// e.g. from TinyGo project root:
-		//	$ ./build/tinygo test -target wasi -c -o flate.wasm compress/flate
-		//	$ wazero run -mount=$(go env GOROOT)/src/compress/flate:/ flate.wasm -test.v
-		{
-			name: "tinygo test compress/flate",
-			fs: []fsapi.FS{
-				&adapter{fs: testfs.FS{}},
-				&adapter{fs: testfs.FS{"testdata/e.txt": &testfs.File{}}},
-				&adapter{fs: testfs.FS{"testdata/Isaac.Newton-Opticks.txt": &testfs.File{}}},
-			},
-			guestPaths: []string{"/", "../", "../../"},
-			expected:   []string{"../testdata/e.txt", "../../testdata/Isaac.Newton-Opticks.txt"},
-			unexpected: []string{"../../testdata/e.txt"},
-		},
-		// e.g. from Go project root:
-		//	$ GOOS=js GOARCH=wasm bin/go test -c -o net.wasm ne
-		//	$ wazero run -mount=src/net:/ net.wasm -test.v -test.short
-		{
-			name: "go test net",
-			fs: []fsapi.FS{
-				&adapter{fs: testfs.FS{"services": &testfs.File{}}},
-				&adapter{fs: testfs.FS{"testdata/aliases": &testfs.File{}}},
-			},
-			guestPaths: []string{"/etc", "/"},
-			expected:   []string{"/etc/services", "testdata/aliases"},
-			unexpected: []string{"services"},
-		},
-		// e.g. from wagi-python project root:
-		//	$ GOOS=js GOARCH=wasm bin/go test -c -o net.wasm ne
-		//	$ wazero run -hostlogging=filesystem -mount=.:/ -env=PYTHONHOME=/opt/wasi-python/lib/python3.11 \
-		//	  -env=PYTHONPATH=/opt/wasi-python/lib/python3.11 opt/wasi-python/bin/python3.wasm
-		{
-			name: "python",
-			fs: []fsapi.FS{
-				&adapter{fs: gofstest.MapFS{ // to allow resolution of "."
-					"pybuilddir.txt": &gofstest.MapFile{},
-					"opt/wasi-python/lib/python3.11/__phello__/__init__.py": &gofstest.MapFile{},
-				}},
-			},
-			guestPaths: []string{"/"},
-			expected: []string{
-				".",
-				"pybuilddir.txt",
-				"opt/wasi-python/lib/python3.11/__phello__/__init__.py",
-			},
-		},
-		// e.g. from Zig project root: TODO: verify this once cli works with multiple mounts
-		//	$ zig test --test-cmd wazero --test-cmd run --test-cmd -mount=.:/ -mount=/tmp:/tmp \
-		//	  --test-cmd-bin -target wasm32-wasi --zig-lib-dir ./lib ./lib/std/std.zig
-		{
-			name: "zig",
-			fs: []fsapi.FS{
-				&adapter{fs: testfs.FS{"zig-cache": &testfs.File{}}},
-				&adapter{fs: testfs.FS{"qSQRrUkgJX9L20mr": &testfs.File{}}},
-			},
-			guestPaths: []string{"/", "/tmp"},
-			expected:   []string{"zig-cache", "/tmp/qSQRrUkgJX9L20mr"},
-			unexpected: []string{"/qSQRrUkgJX9L20mr"},
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.name, func(t *testing.T) {
-			root, err := NewRootFS(tc.fs, tc.guestPaths)
-			require.NoError(t, err)
-
-			for _, p := range tc.expected {
-				f, errno := root.OpenFile(p, os.O_RDONLY, 0)
-				require.Zero(t, errno, p)
-				require.EqualErrno(t, 0, f.Close(), p)
-			}
-
-			for _, p := range tc.unexpected {
-				_, err := root.OpenFile(p, os.O_RDONLY, 0)
-				require.EqualErrno(t, syscall.ENOENT, err)
-			}
-		})
-	}
-}
-
-func Test_stripPrefixesAndTrailingSlash(t *testing.T) {
-	tests := []struct {
-		path, expected string
-	}{
-		{
-			path:     ".",
-			expected: "",
-		},
-		{
-			path:     "/",
-			expected: "",
-		},
-		{
-			path:     "./",
-			expected: "",
-		},
-		{
-			path:     "./foo",
-			expected: "foo",
-		},
-		{
-			path:     ".foo",
-			expected: ".foo",
-		},
-		{
-			path:     "././foo",
-			expected: "foo",
-		},
-		{
-			path:     "/foo",
-			expected: "foo",
-		},
-		{
-			path:     "foo/",
-			expected: "foo",
-		},
-		{
-			path:     "//",
-			expected: "",
-		},
-		{
-			path:     "../../",
-			expected: "../..",
-		},
-		{
-			path:     "./../../",
-			expected: "../..",
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.path, func(t *testing.T) {
-			pathI, pathLen := stripPrefixesAndTrailingSlash(tc.path)
-			require.Equal(t, tc.expected, tc.path[pathI:pathLen])
-		})
-	}
-}
-
-func Test_hasPathPrefix(t *testing.T) {
-	tests := []struct {
-		name                  string
-		path, prefix          string
-		expectEq, expectMatch bool
-	}{
-		{
-			name:        "empty prefix",
-			path:        "foo",
-			prefix:      "",
-			expectEq:    false,
-			expectMatch: true,
-		},
-		{
-			name:        "equal prefix",
-			path:        "foo",
-			prefix:      "foo",
-			expectEq:    true,
-			expectMatch: true,
-		},
-		{
-			name:        "sub path",
-			path:        "foo/bar",
-			prefix:      "foo",
-			expectMatch: true,
-		},
-		{
-			name:        "different sub path",
-			path:        "foo/bar",
-			prefix:      "bar",
-			expectMatch: false,
-		},
-		{
-			name:        "different path same length",
-			path:        "foo",
-			prefix:      "bar",
-			expectMatch: false,
-		},
-		{
-			name:        "longer path",
-			path:        "foo",
-			prefix:      "foo/bar",
-			expectMatch: false,
-		},
-		{
-			name:        "path shorter",
-			path:        "foo",
-			prefix:      "fooo",
-			expectMatch: false,
-		},
-		{
-			name:        "path longer",
-			path:        "fooo",
-			prefix:      "foo",
-			expectMatch: false,
-		},
-		{
-			name:        "shorter path",
-			path:        "foo",
-			prefix:      "foo/bar",
-			expectMatch: false,
-		},
-		{
-			name:        "wrong and shorter path",
-			path:        "foo",
-			prefix:      "bar/foo",
-			expectMatch: false,
-		},
-		{
-			name:        "same relative",
-			path:        "../..",
-			prefix:      "../..",
-			expectEq:    true,
-			expectMatch: true,
-		},
-		{
-			name:        "longer relative",
-			path:        "..",
-			prefix:      "../..",
-			expectMatch: false,
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.name, func(t *testing.T) {
-			path := "././." + tc.path + "/"
-			eq, match := hasPathPrefix(path, 5, 5+len(tc.path), tc.prefix)
-			require.Equal(t, tc.expectEq, eq)
-			require.Equal(t, tc.expectMatch, match)
-		})
-	}
-}
-
-// BenchmarkHasPrefixVsIterate shows that iteration is faster than re-slicing
-// for a prefix match.
-func BenchmarkHasPrefixVsIterate(b *testing.B) {
-	s := "../../.."
-	prefix := "../bar"
-	prefixLen := len(prefix)
-	b.Run("strings.HasPrefix", func(b *testing.B) {
-		for i := 0; i < b.N; i++ {
-			if strings.HasPrefix(s, prefix) { //nolint
-			}
-		}
-	})
-	b.Run("iterate", func(b *testing.B) {
-		for i := 0; i < b.N; i++ {
-			for i := 0; i < prefixLen; i++ {
-				if s[i] != prefix[i] {
-					break
-				}
-			}
-		}
-	})
-}
diff --git a/internal/sysfs/sysfs_test.go b/internal/sysfs/sysfs_test.go
index 2566ed1c..56ff29a2 100644
--- a/internal/sysfs/sysfs_test.go
+++ b/internal/sysfs/sysfs_test.go
@@ -296,15 +296,6 @@ func testStat(t *testing.T, testFS fsapi.FS) {
 	}
 }
 
-func readAll(t *testing.T, f fsapi.File) []byte {
-	st, errno := f.Stat()
-	require.EqualErrno(t, 0, errno)
-	buf := make([]byte, st.Size)
-	_, errno = f.Read(buf)
-	require.EqualErrno(t, 0, errno)
-	return buf
-}
-
 // requireReaddir ensures the input file is a directory, and returns its
 // entries.
 func requireReaddir(t *testing.T, f fsapi.File, n int, expectIno bool) []fsapi.Dirent {